chore(infra): 更新 Nginx 部署配置与项目上下文文档

- 更新 CI/CD 子域名反向代理配置
- 调整 Nginx 静态文件服务器配置
- 更新 CONTEXT.md 领域共享语言文档
- 添加 CLAUDE.md 代理工作指南
- 更新 Playwright E2E 测试配置
This commit is contained in:
张翔
2026-07-07 06:52:07 +08:00
parent c9de806109
commit cf99e7556c
103 changed files with 49394 additions and 17 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,290 @@
import fs from 'node:fs';
import path from 'node:path';
import { loadDesignSystemForCwd } from '../design-system.mjs';
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 {
filterDetectionFindings,
readDetectionConfig,
shouldIgnoreDetectionFile,
} from '../../lib/impeccable-config.mjs';
import {
HTML_EXTENSIONS,
buildImportGraph,
detectFrameworkConfig,
isPortListening,
walkDir,
} from '../node/file-system.mjs';
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
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${formatFindingSummary(findings.length)}`);
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
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, inline
ignore comments, or DESIGN.md
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
--help Show this help message
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
and detector.designSystem.enabled.
Inline ignores:
In-file comments waive a finding where it lives and travel with the file:
<!-- impeccable-disable overused-font -- exported brand doc -->
.brand { font-family: Inter } /* impeccable-disable-line overused-font */
// impeccable-disable-next-line bounce-easing: intentional bounce
impeccable-disable applies to the whole file; -line / -next-line are scoped.
List one or more rule ids (comma-separated), or omit them / use * for all.
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 .
impeccable detect --no-config src/`);
}
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 quietMode = args.includes('--quiet');
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 configEnabled = !args.includes('--no-config');
const detectionConfig = configEnabled
? readDetectionConfig(process.cwd())
: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] };
const providers = [];
if (args.includes('--gpt')) providers.push('gpt');
if (args.includes('--gemini')) providers.push('gemini');
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
// apply by default. `--no-config` (raw scan) and the dedicated
// `--no-inline-ignores` both turn them off.
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
if (designSystem) scanOptions.designSystem = designSystem;
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/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
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)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
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 && !quietMode) {
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()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
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();
}
}
allFindings = filterDetectionFindings(allFindings, detectionConfig);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\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 };
@@ -0,0 +1,750 @@
import fs from 'node:fs';
import path from 'node:path';
import { finding } from './findings.mjs';
import { GENERIC_FONTS } from './shared/constants.mjs';
import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const FALLBACK_DIRS = ['.agents/context', 'docs'];
const COLOR_CHANNEL_TOLERANCE = 6;
const RADIUS_TOLERANCE_PX = 0.5;
const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi;
const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
function firstExisting(dir, names) {
for (const name of names) {
const abs = path.join(dir, name);
if (fs.existsSync(abs)) return abs;
}
return null;
}
function resolveDesignMdPath(cwd = process.cwd()) {
const root = firstExisting(cwd, DESIGN_NAMES);
if (root) return { path: root, contextDir: cwd };
for (const rel of FALLBACK_DIRS) {
const dir = path.resolve(cwd, rel);
const found = firstExisting(dir, DESIGN_NAMES);
if (found) return { path: found, contextDir: dir };
}
return null;
}
function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
const candidates = [
path.join(cwd, '.impeccable', 'design.json'),
path.join(cwd, 'DESIGN.json'),
path.join(contextDir, 'DESIGN.json'),
];
return candidates.find((candidate, index) =>
candidates.indexOf(candidate) === index && fs.existsSync(candidate)
) || null;
}
function parseFrontmatter(md) {
const lines = String(md || '').split(/\r?\n/);
if (lines[0]?.trim() !== '---') return null;
let end = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') { end = i; break; }
}
if (end === -1) return null;
try {
return parseYamlSubset(lines.slice(1, end).join('\n'));
} catch {
return null;
}
}
function parseYamlSubset(yaml) {
const root = {};
const stack = [{ indent: -1, obj: root }];
for (const raw of String(yaml || '').split(/\r?\n/)) {
if (!raw.trim() || /^\s*#/.test(raw)) continue;
const indent = raw.match(/^\s*/)[0].length;
const content = raw.slice(indent);
const colonIdx = findTopLevelColon(content);
if (colonIdx === -1) continue;
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
const parent = stack[stack.length - 1].obj;
if (rest === '') {
const obj = {};
parent[key] = obj;
stack.push({ indent, obj });
} else {
parent[key] = parseScalar(rest);
}
}
return root;
}
function findTopLevelColon(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inQuote) {
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === ':') {
return i;
}
}
return -1;
}
function unquoteYamlKey(key) {
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
return key.slice(1, -1);
}
return key;
}
function stripInlineYamlComment(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inQuote) {
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {
return s.slice(0, i).trimEnd();
}
}
return s;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if (s === 'true') return true;
if (s === 'false') return false;
if (s === 'null' || s === '~') return null;
if (/^-?\d+$/.test(s)) return Number(s);
if (/^-?\d*\.\d+$/.test(s)) return Number(s);
return s;
}
function safeReadJson(filePath) {
if (!filePath) return null;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
function normalizeFontName(value) {
return String(value || '')
.trim()
.replace(/\s*!important\s*$/i, '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function splitFontStack(stack) {
return String(stack || '')
.replace(/\s*!important\s*$/i, '')
.split(',')
.map(normalizeFontName)
.filter(Boolean);
}
function primaryFont(stack) {
if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return '';
return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || '';
}
function isLiteralFontStack(stack) {
const text = String(stack || '');
return !/[$`{}]|\s\+\s|\|\|/.test(text);
}
function cssColorLabel(raw) {
return String(raw || '').trim().replace(/\s+/g, ' ');
}
function colorKey(color) {
if (!color) return '';
return `${color.r},${color.g},${color.b}`;
}
function colorsClose(a, b) {
if (!a || !b) return false;
return Math.max(
Math.abs(a.r - b.r),
Math.abs(a.g - b.g),
Math.abs(a.b - b.b),
) <= COLOR_CHANNEL_TOLERANCE;
}
function hslToRgb(H, S, L, alpha = 1) {
const h = (((H % 360) + 360) % 360) / 360;
const s = Math.max(0, Math.min(1, S));
const l = Math.max(0, Math.min(1, L));
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
a: alpha,
};
}
function parseDesignColor(value) {
const text = String(value || '').trim();
const parsed = parseAnyColor(text);
if (parsed) return parsed;
const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i);
if (hsl) {
return hslToRgb(
parseFloat(hsl[1]),
parseFloat(hsl[2]) / 100,
parseFloat(hsl[3]) / 100,
hsl[4] !== undefined ? parseFloat(hsl[4]) : 1,
);
}
return null;
}
function addDesignColor(out, value, label) {
const parsed = parseDesignColor(value);
if (!parsed) return;
const key = colorKey(parsed);
if (!out.allowedColorKeys.has(key)) {
out.allowedColorKeys.set(key, { color: parsed, labels: [] });
}
out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value));
}
function addColorObject(out, colors, prefix = 'colors') {
if (!colors || typeof colors !== 'object') return;
for (const [name, value] of Object.entries(colors)) {
if (typeof value === 'string') {
addDesignColor(out, value, `${prefix}.${name}`);
}
}
}
function addSidecarColors(out, sidecar) {
const colorMeta = sidecar?.extensions?.colorMeta;
if (!colorMeta || typeof colorMeta !== 'object') return;
for (const [name, meta] of Object.entries(colorMeta)) {
if (!meta || typeof meta !== 'object') continue;
if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`);
if (Array.isArray(meta.tonalRamp)) {
for (const [index, value] of meta.tonalRamp.entries()) {
if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`);
}
}
}
}
function addTypographyFonts(out, typography) {
if (!typography || typeof typography !== 'object') return;
for (const role of Object.values(typography)) {
if (!role || typeof role !== 'object') continue;
if (typeof role.fontFamily !== 'string') continue;
for (const font of splitFontStack(role.fontFamily)) {
if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font);
}
}
}
function addRoundedScale(out, rounded) {
if (!rounded || typeof rounded !== 'object') return;
for (const [rawName, value] of Object.entries(rounded)) {
const name = unquoteYamlKey(rawName).toLowerCase();
addRoundedToken(out, name, value);
}
}
function addRoundedToken(out, name, value) {
if (typeof value !== 'string' && typeof value !== 'number') return;
const raw = String(value).trim();
if (!raw || /var\(/i.test(raw) || raw.includes('%')) return;
const px = resolveLengthPx(raw, 16);
if (px == null || !Number.isFinite(px)) return;
out.allowedRadii.push({ name, value: raw, px });
if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true;
}
function addSidecarRadii(out, sidecar) {
const roundedMeta = sidecar?.extensions?.roundedMeta;
if (!roundedMeta || typeof roundedMeta !== 'object') return;
for (const [rawName, meta] of Object.entries(roundedMeta)) {
const name = unquoteYamlKey(rawName).toLowerCase();
if (typeof meta === 'string' || typeof meta === 'number') {
addRoundedToken(out, `sidecar.${name}`, meta);
continue;
}
if (!meta || typeof meta !== 'object') continue;
for (const key of ['canonical', 'value']) {
if (typeof meta[key] === 'string' || typeof meta[key] === 'number') {
addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]);
}
}
for (const key of ['values', 'aliases']) {
if (!Array.isArray(meta[key])) continue;
for (const [index, value] of meta[key].entries()) {
addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value);
}
}
if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) {
out.hasPillRadius = true;
}
}
}
function normalizeDesignSystem(input = {}) {
const frontmatter = input.frontmatter || {};
const sidecar = input.sidecar || null;
const out = {
present: true,
sourcePath: input.sourcePath || null,
sidecarPath: input.sidecarPath || null,
mdNewerThanJson: input.mdNewerThanJson === true,
allowedFonts: new Set(),
allowedColorKeys: new Map(),
allowedRadii: [],
hasPillRadius: false,
};
addTypographyFonts(out, frontmatter.typography);
addColorObject(out, frontmatter.colors);
addSidecarColors(out, sidecar);
addRoundedScale(out, frontmatter.rounded);
addSidecarRadii(out, sidecar);
out.hasFonts = out.allowedFonts.size > 0;
out.hasColors = out.allowedColorKeys.size > 0;
out.hasRadii = out.allowedRadii.length > 0;
return out;
}
function loadDesignSystemForCwd(cwd = process.cwd()) {
const md = resolveDesignMdPath(cwd);
if (!md) return null;
let frontmatter = null;
let mdStat = null;
try {
mdStat = fs.statSync(md.path);
frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8'));
} catch {
return null;
}
if (!frontmatter || typeof frontmatter !== 'object') return null;
const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir);
const sidecar = safeReadJson(sidecarPath);
let sidecarStat = null;
try {
if (sidecarPath) sidecarStat = fs.statSync(sidecarPath);
} catch {
sidecarStat = null;
}
return normalizeDesignSystem({
frontmatter,
sidecar,
sourcePath: md.path,
sidecarPath,
mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000),
});
}
function isAllowedFont(font, designSystem) {
if (!font || GENERIC_FONTS.has(font)) return true;
if (!designSystem?.hasFonts) return true;
return designSystem.allowedFonts.has(font);
}
function isAllowedColorRaw(raw, designSystem) {
if (!designSystem?.hasColors) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
if (text.includes('var(')) return true;
const parsed = parseDesignColor(text);
if (!parsed) return true;
if ((parsed.a ?? 1) <= 0.05) return true;
for (const entry of designSystem.allowedColorKeys.values()) {
if (colorsClose(parsed, entry.color)) return true;
}
return false;
}
function isAllowedRadiusRaw(raw, designSystem) {
if (!designSystem?.hasRadii) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
if (text.includes('var(') || text.includes('%')) return true;
const px = resolveLengthPx(text, 16);
if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true;
if (designSystem.hasPillRadius && px >= 99) return true;
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
}
function lineLooksCommented(line) {
const trimmed = String(line || '').trim();
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
}
function isProbablyColorLiteral(line, match) {
const raw = match?.[0] || '';
const index = match.index ?? -1;
if (index < 0) return false;
if (isInsideCssAttributeSelector(line, index)) return false;
const before = line.slice(0, index);
const after = line.slice(index + raw.length);
if (raw.startsWith('#')) {
if (before.endsWith('&')) return false; // HTML numeric entity, e.g. &#8596;
const prevNonSpace = before.match(/\S(?=\s*$)/)?.[0] || '';
const nextNonSpace = after.match(/^\s*(\S)/)?.[1] || '';
if (prevNonSpace === '>' && nextNonSpace === '<') return false; // plain text, e.g. PR #155
}
const styleContext = /(?:^|[{\s;"'`(,])(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left))?(?:-color)?|outline(?:-color)?|box-shadow|text-shadow|fill|stroke)\s*:\s*[^;{}"'`]*/i.test(before);
const cssFunctionContext = /(?:linear-gradient|radial-gradient|conic-gradient|color-mix)\([^)]*$/i.test(before);
const jsColorKeyContext = /(?:^|[,{]\s*)(?:color|background|backgroundColor|borderColor|outlineColor|fill|stroke|boxShadow|textShadow)\s*[:=]\s*["'`]?[^"'`,}]*/i.test(before);
return styleContext || cssFunctionContext || jsColorKeyContext;
}
function isInsideCssAttributeSelector(line, index) {
if (index < 0) return false;
const before = line.slice(0, index);
const lastOpen = before.lastIndexOf('[');
if (lastOpen === -1) return false;
const lastClose = before.lastIndexOf(']');
if (lastClose > lastOpen) return false;
const after = line.slice(index);
const close = after.indexOf(']');
const block = after.indexOf('{');
return close !== -1 && (block === -1 || close < block);
}
function makeDesignFinding(id, filePath, snippet, line = 0, extras = {}) {
return { ...finding(id, filePath, snippet, line), ...extras };
}
function decodeGoogleFamily(value) {
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
try {
return decodeURIComponent(family);
} catch {
return family;
}
}
function checkFontStack(stack, filePath, line, designSystem, context) {
const primary = primaryFont(stack);
if (!primary || isAllowedFont(primary, designSystem)) return [];
const display = primary.replace(/\b\w/g, ch => ch.toUpperCase());
return [makeDesignFinding(
'design-system-font',
filePath,
`${context}: ${display} is not declared in DESIGN.md typography`,
line,
{ ignoreValue: display },
)];
}
function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
.filter(Boolean);
}
function checkRadiusValue(value, filePath, line, designSystem, context) {
const findings = [];
for (const token of extractRadiusTokens(value)) {
if (isAllowedRadiusRaw(token, designSystem)) continue;
findings.push(makeDesignFinding(
'design-system-radius',
filePath,
`${context}: ${token} is outside the DESIGN.md rounded scale`,
line,
{ ignoreValue: token },
));
}
return findings;
}
function checkSourceDesignSystem(content, filePath, options = {}) {
const designSystem = options.designSystem;
if (!designSystem?.present) return [];
const findings = [];
const lines = String(content || '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
if (lineLooksCommented(line)) continue;
if (designSystem.hasFonts) {
for (const match of line.matchAll(FONT_DECL_RE)) {
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'font-family'));
}
for (const match of line.matchAll(FONT_JS_RE)) {
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'fontFamily'));
}
for (const match of line.matchAll(GOOGLE_FONT_RE)) {
const url = match[0];
for (const familyMatch of url.matchAll(/[?&]family=([^&]+)/g)) {
const font = normalizeFontName(decodeGoogleFamily(familyMatch[1]));
if (!font || isAllowedFont(font, designSystem)) continue;
const display = decodeGoogleFamily(familyMatch[1]);
findings.push(makeDesignFinding(
'design-system-font',
filePath,
`Google Fonts: ${display} is not declared in DESIGN.md typography`,
lineNum,
{ ignoreValue: display },
));
}
}
}
if (designSystem.hasColors) {
for (const match of line.matchAll(CSS_COLOR_RE)) {
if (!isProbablyColorLiteral(line, match)) continue;
const raw = cssColorLabel(match[0]);
if (isAllowedColorRaw(raw, designSystem)) continue;
findings.push(makeDesignFinding(
'design-system-color',
filePath,
`Undocumented color ${raw} is outside DESIGN.md colors`,
lineNum,
{ ignoreValue: raw },
));
}
}
if (designSystem.hasRadii) {
for (const match of line.matchAll(BORDER_RADIUS_RE)) {
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'border-radius'));
}
for (const match of line.matchAll(BORDER_RADIUS_JS_RE)) {
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
}
}
}
return dedupeDesignFindings(findings);
}
function hasDirectText(el) {
return Array.from(el.childNodes || []).some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
}
function sampleText(el) {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
return text ? ` "${text.slice(0, 40)}"` : '';
}
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
if (!designSystem?.present) return [];
const findings = [];
const seenFonts = new Set();
const seenColors = new Set();
const seenRadii = new Set();
for (const el of document.querySelectorAll('*')) {
if (shouldSkipStaticDesignElement(el, window)) continue;
const tag = el.tagName?.toLowerCase?.() || 'unknown';
const style = window.getComputedStyle(el);
if (designSystem.hasFonts && hasDirectText(el)) {
const font = primaryFont(style.fontFamily || '');
if (font && !seenFonts.has(font) && !isAllowedFont(font, designSystem)) {
seenFonts.add(font);
findings.push(makeDesignFinding(
'design-system-font',
filePath,
`${tag}${sampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
0,
{ ignoreValue: font },
));
}
}
if (designSystem.hasColors) {
const colorChecks = [];
if (hasDirectText(el)) colorChecks.push(['text color', style.color]);
if (!isTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
}
}
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
for (const [kind, raw] of colorChecks) {
const label = cssColorLabel(raw);
if (isAllowedColorRaw(label, designSystem)) continue;
const key = `${kind}:${label}`;
if (seenColors.has(key)) continue;
seenColors.add(key);
findings.push(makeDesignFinding(
'design-system-color',
filePath,
`${kind} ${label} on ${tag}${sampleText(el)} is outside DESIGN.md colors`,
0,
{ ignoreValue: label },
));
}
}
if (designSystem.hasRadii) {
const rawRadius = String(style.borderRadius || '').trim();
if (!rawRadius) continue;
for (const token of extractRadiusTokens(rawRadius)) {
if (isAllowedRadiusRaw(token, designSystem)) continue;
if (seenRadii.has(token)) continue;
seenRadii.add(token);
findings.push(makeDesignFinding(
'design-system-radius',
filePath,
`border-radius ${token} on ${tag}${sampleText(el)} is outside the DESIGN.md rounded scale`,
0,
{ ignoreValue: token },
));
}
}
}
return findings;
}
function shouldSkipStaticDesignElement(el, window) {
const tag = el.tagName?.toLowerCase?.() || '';
if (STATIC_DESIGN_SKIP_TAGS.has(tag)) return true;
let current = el;
while (current) {
if (current.getAttribute?.('hidden') !== null || current.getAttribute?.('aria-hidden') === 'true') return true;
const style = window.getComputedStyle(current);
const display = String(style.display || '').toLowerCase();
const visibility = String(style.visibility || '').toLowerCase();
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse') return true;
current = current.parentElement;
}
return false;
}
function isTransparentCss(value) {
const text = String(value || '').trim().toLowerCase();
if (!text || text === 'transparent') return true;
const parsed = parseDesignColor(text);
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
}
function canonicalDesignFindingKey(item) {
if (!item?.antipattern?.startsWith?.('design-system-')) return null;
const value = item.ignoreValue || item.value || '';
if (item.antipattern === 'design-system-font') {
const context = /google fonts/i.test(item.snippet || '') ? 'google-font' : 'font';
const font = normalizeFontName(value);
return font ? `${item.antipattern}:${context}:${font}` : null;
}
if (item.antipattern === 'design-system-color') {
const parsed = parseDesignColor(value);
if (parsed) return `${item.antipattern}:color:${colorKey(parsed)}`;
const label = cssColorLabel(value).toLowerCase();
return label ? `${item.antipattern}:color:${label}` : null;
}
if (item.antipattern === 'design-system-radius') {
const px = resolveLengthPx(String(value || '').trim(), 16);
if (px != null && Number.isFinite(px)) return `${item.antipattern}:radius:${Math.round(px * 100) / 100}`;
const label = String(value || '').trim().toLowerCase();
return label ? `${item.antipattern}:radius:${label}` : null;
}
return null;
}
function mergeDesignSystemFindings(...groups) {
const out = [];
const seen = new Map();
for (const group of groups) {
for (const item of group || []) {
const key = canonicalDesignFindingKey(item);
if (key) {
if (seen.has(key)) {
const existing = out[seen.get(key)];
if ((existing.line || 0) <= 0 && (item.line || 0) > 0) existing.line = item.line;
continue;
}
seen.set(key, out.length);
}
out.push(item);
}
}
return out;
}
function dedupeDesignFindings(findings) {
const out = [];
const seen = new Set();
for (const item of findings) {
const key = [
item.antipattern,
item.line || 0,
normalizeFontName(item.ignoreValue || item.snippet || ''),
].join('\0');
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
export {
parseFrontmatter,
normalizeDesignSystem,
loadDesignSystemForCwd,
isAllowedFont,
isAllowedColorRaw,
isAllowedRadiusRaw,
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
mergeDesignSystemFindings,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Anti-Pattern Detector for Impeccable
* Copyright (c) 2026 Paul Bakaus
* SPDX-License-Identifier: Apache-2.0
*
* Public API facade. Runtime engines live under cli/engine/engines/.
*/
import { detectCli } from './cli/main.mjs';
export { ANTIPATTERNS, RULE_ENGINE_SUPPORT, getAntipattern, getRulesForCategory, getRuleEngineSupport } from './registry/antipatterns.mjs';
export { SAFE_TAGS, BORDER_SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS, KNOWN_SERIF_FONTS } from './shared/constants.mjs';
export { isNeutralColor, parseRgb, relativeLuminance, contrastRatio, parseGradientColors, hasChroma, getHue, colorToHex } from './shared/color.mjs';
export { isFullPage } from './shared/page.mjs';
export {
checkElementBorders,
checkElementMotion,
checkElementGlow,
checkPageTypography,
checkPageLayout,
checkHtmlPatterns,
} from './rules/checks.mjs';
export { createDetectorProfile, summarizeDetectorProfile } from './profile/profiler.mjs';
export {
parseFrontmatter as parseDesignFrontmatter,
normalizeDesignSystem,
loadDesignSystemForCwd,
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
} from './design-system.mjs';
export { detectHtml } from './engines/static-html/detect-html.mjs';
export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
export {
walkDir,
SCANNABLE_EXTENSIONS,
SKIP_DIRS,
buildImportGraph,
resolveImport,
detectFrameworkConfig,
isPortListening,
FRAMEWORK_CONFIGS,
} from './node/file-system.mjs';
export { formatFindings, detectCli } from './cli/main.mjs';
const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs') ||
process.argv[1]?.endsWith('detect-antipatterns.mjs/');
if (isMainModule) detectCli();
@@ -0,0 +1,277 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
function serializeDesignSystemForBrowser(designSystem) {
if (!designSystem?.present) return null;
return {
present: true,
hasFonts: designSystem.hasFonts === true,
allowedFonts: Array.from(designSystem.allowedFonts || []),
hasColors: designSystem.hasColors === true,
allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])
.map(entry => entry?.color)
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
.map(color => ({ r: color.r, g: color.g, b: color.b })),
hasRadii: designSystem.hasRadii === true,
allowedRadii: (designSystem.allowedRadii || [])
.map(entry => Number(entry?.px))
.filter(px => Number.isFinite(px)),
hasPillRadius: designSystem.hasPillRadius === true,
};
}
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
if (options?.visualContrast === false) return [];
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
? options.visualContrastMaxCandidates
: 12;
const scrollOffscreen = options?.visualContrastScrollOffscreen !== false;
const existingLowContrastSelectors = new Set(
serializedGroups
.filter(group => group.findings?.some(f => f.type === 'low-contrast'))
.map(group => group.selector)
.filter(Boolean)
);
let browserAnalyses = [];
const findings = [];
if (options?.visualContrastBrowser !== false) {
const browserFindings = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'browser-fallback',
target,
}, async () => {
browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => {
if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return [];
return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen });
}, { maxCandidates, scrollOffscreen });
return browserAnalyses
.filter(result => result.finding && !existingLowContrastSelectors.has(result.selector))
.map(result => result.finding);
});
findings.push(...browserFindings);
}
let candidates = browserAnalyses.length > 0 ? browserAnalyses : [];
if (candidates.length === 0) {
candidates = await profileStepAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'collect-candidates',
target,
}, () => page.evaluate(({ maxCandidates }) => {
if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return [];
return window.impeccableCollectVisualContrastCandidates({ maxCandidates });
}, { maxCandidates }));
}
const viewport = options?.viewport || { width: 1280, height: 800 };
const browserResolvedSelectors = new Set(
browserAnalyses
.filter(result => result.status === 'fail' || result.status === 'pass')
.map(result => result.selector)
.filter(Boolean)
);
const filtered = candidates.filter(candidate =>
!existingLowContrastSelectors.has(candidate.selector) &&
!browserResolvedSelectors.has(candidate.selector)
);
if (options?.visualContrastPixel === false) return findings;
for (const candidate of filtered) {
const result = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'pixel-diff',
target,
}, async () => {
const finding = await captureVisualContrastCandidate(page, candidate, viewport);
return finding ? [finding] : [];
});
findings.push(...result);
}
return findings;
}
// ---------------------------------------------------------------------------
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
const viewport = options?.viewport || { width: 1280, height: 800 };
const externalBrowser = options?.browser || null;
let puppeteer;
if (!externalBrowser) {
try {
puppeteer = await profileStepAsync(profile, {
engine: 'browser',
phase: 'setup',
ruleId: 'import-puppeteer',
target: url,
}, () => import('puppeteer'));
} catch {
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
}
}
// Read the browser detection script — reuse it instead of reimplementing
const browserScriptPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'detect-antipatterns-browser.js'
);
let browserScript;
try {
browserScript = profileStep(profile, {
engine: 'browser',
phase: 'setup',
ruleId: 'read-browser-script',
target: url,
}, () => fs.readFileSync(browserScriptPath, 'utf-8'));
} catch {
throw new Error(`Browser script not found at ${browserScriptPath}`);
}
// CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
// Chrome can't initialize its sandbox there. Disable the sandbox only when
// running in CI; local users keep the default hardened launch.
const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
const browser = externalBrowser || await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'launch-browser',
target: url,
}, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
const page = await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'new-page',
target: url,
}, () => browser.newPage());
let results = [];
try {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: `goto:${waitUntil}`,
target: url,
}, () => page.goto(url, { waitUntil, timeout: 30000 }));
if (settleMs > 0) {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'settle',
target: url,
}, () => new Promise(resolve => setTimeout(resolve, settleMs)));
}
// Inject the browser detection script and collect results
const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate((designSystem) => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
...(designSystem ? { designSystem } : {}),
};
}, browserDesignSystem));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'inject-browser-script',
target: url,
}, () => page.evaluate(browserScript));
let serializedGroups = [];
results = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'browser-scan',
target: url,
}, async () => {
serializedGroups = await page.evaluate(() => {
if (!window.impeccableDetect) return [];
return window.impeccableDetect({ decorate: false, serialize: true });
});
return serializedGroups.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '' }))
);
});
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
results.push(...visualFindings);
} finally {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-page',
target: url,
}, () => page.close().catch(() => {}));
if (!externalBrowser) {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-browser',
target: url,
}, () => browser.close());
}
}
return filterByProviders(results.map(f => {
const item = finding(f.id, url, f.snippet);
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
return item;
}), options.providers);
}
async function createBrowserDetector(options = {}) {
let puppeteer;
try {
puppeteer = await import('puppeteer');
} catch {
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
}
const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
const browser = options.browser || await puppeteer.default.launch({
headless: options.headless ?? true,
args: launchArgs,
});
const ownsBrowser = !options.browser;
const defaults = {
waitUntil: options.waitUntil || 'load',
settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100,
viewport: options.viewport || { width: 1280, height: 800 },
};
return {
browser,
async detectUrl(url, scanOptions = {}) {
return detectUrl(url, {
...defaults,
...scanOptions,
browser,
});
},
async close() {
if (ownsBrowser) await browser.close().catch(() => {});
},
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector };
@@ -0,0 +1,568 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
// ---------------------------------------------------------------------------
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
// ---------------------------------------------------------------------------
const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
const hasBorderRadius = (line) => /border-radius/i.test(line);
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
/** Strip HTML to plain text — drops script/style/comments/tags so
* content-text analyzers don't false-positive on code or CSS. */
function stripHtmlToText(html) {
return html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ');
}
const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);
function extFromFilePath(filePath) {
return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
}
function shouldRunPageAnalyzers(content, filePath) {
if (!isFullPage(content)) return false;
const ext = extFromFilePath(filePath);
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
test: (m) => +m[1] >= 3,
fmt: (m) => m[0] },
// --- Border accent on rounded ---
{ id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
test: (m, line) => hasRounded(line) && +m[1] >= 1,
fmt: (m) => m[0] },
{ id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
fmt: (m) => m[0] },
// --- Overused font ---
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
fmt: () => 'background-clip: text + gradient' },
// --- Gradient text (Tailwind) ---
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
test: (m, line) => /\bbg-gradient-to-/i.test(line),
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
fmt: (m) => `${m[0]} on heading` },
{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
fmt: (m) => `${m[0]} gradient` },
// --- Bounce/elastic easing ---
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
},
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
// --- Layout property transition ---
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
} },
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
} },
// --- Broken image: src="" or src="#" or src=" " ---
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
test: () => true,
fmt: (m) => m[0].slice(0, 100) },
// --- Broken image: <img> with no src attribute at all ---
{ id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,
test: (m) => !/\bsrc\s*=/i.test(m[0]),
fmt: (m) => m[0].slice(0, 100) },
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
const REM = 16;
let m;
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
while ((m = sizeRe.exec(content)) !== null) {
const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
}
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
while ((m = clampRe.exec(content)) !== null) {
sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
}
const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
if (sizes.size < 3) return [];
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio >= 2.0) return [];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
},
// Monotonous spacing (regex)
(content, filePath) => {
const vals = [];
let m;
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
const rounded = vals.map(v => Math.round(v / 4) * 4);
if (rounded.length < 10) return [];
const counts = {};
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
const maxCount = Math.max(...Object.values(counts));
const pct = maxCount / rounded.length;
const unique = [...new Set(rounded)].filter(v => v > 0);
if (pct <= 0.6 || unique.length > 3) return [];
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
},
// Em-dash overuse: 5+ em-dashes or "--" in body text content
// (occasional em-dash use in prose is fine; the pattern fires only
// when count crosses into AI-cadence territory).
(content, filePath) => {
const text = stripHtmlToText(content);
let count = 0;
const re = /[—]|--(?=\S)/g;
while (re.exec(text) !== null) count++;
if (count < 5) return [];
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
},
// Marketing buzzwords: SaaS phrase list
(content, filePath) => {
const text = stripHtmlToText(content);
const lower = text.toLowerCase();
const BUZZWORDS = [
'streamline your', 'empower your', 'supercharge your',
'unleash your', 'unleash the power', 'leverage the power',
'built for the modern', 'trusted by leading', 'trusted by the world',
'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade',
'next-generation', 'cutting-edge', 'transform your business',
'revolutionize', 'game-changer', 'game changing',
'mission-critical', 'best of breed', 'future-proof', 'future proof',
'seamless experience', 'seamlessly integrate',
'drive engagement', 'drive growth', 'drive results',
'harness the power',
];
let count = 0;
let firstSample = '';
for (const phrase of BUZZWORDS) {
let from = 0;
while (true) {
const idx = lower.indexOf(phrase, from);
if (idx === -1) break;
count++;
if (!firstSample) {
firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim();
}
from = idx + phrase.length;
}
}
if (count === 0) return [];
return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
},
// Numbered section markers (01 / 02 / 03 ...)
(content, filePath) => {
const text = stripHtmlToText(content);
const re = /\b(0[1-9]|1[0-2])\b/g;
const seen = new Set();
let m;
while ((m = re.exec(text)) !== null) seen.add(m[1]);
if (seen.size < 3) return [];
const sorted = [...seen].sort();
let sequential = 0;
for (let i = 1; i < sorted.length; i++) {
if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;
}
if (sequential < 2) return [];
return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];
},
// Aphoristic cadence: manufactured-contrast + short-rebuttal
(content, filePath) => {
const text = stripHtmlToText(content);
const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g;
const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g;
let count = 0;
let firstSample = '';
let m;
NOT_A_RE.lastIndex = 0;
while ((m = NOT_A_RE.exec(text)) !== null) {
count++;
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
}
SHORT_REBUTTAL_RE.lastIndex = 0;
while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) {
count++;
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
}
if (count < 3) return [];
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
// ---------------------------------------------------------------------------
// 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, options = {}) {
const { profile, phase = 'regex-matchers' } = options || {};
const findings = [];
if (!profile) {
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) {
// 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;
}
for (const matcher of REGEX_MATCHERS) {
const matcherFindings = profileFindings(profile, {
engine: 'regex',
phase,
ruleId: matcher.id,
target: filePath,
}, () => {
const matches = [];
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) {
// 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)) {
matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
}
}
}
return matches;
});
findings.push(...matcherFindings);
}
return findings;
}
/** Page-level analyzers that scan rendered text content (em-dash use,
* buzzword phrases, numbered section markers, aphoristic cadence).
* These are detector-agnostic — they work on any HTML/text source
* and don't need a parsed DOM. Exported so detectHtml can call them
* for `.html` files (which otherwise skip the regex engine). */
const TEXT_CONTENT_ANALYZER_IDS = [
'em-dash-overuse',
'marketing-buzzword',
'numbered-section-markers',
'aphoristic-cadence',
];
function runTextContentAnalyzers(content, filePath, options = {}) {
const profile = options?.profile;
if (!shouldRunPageAnalyzers(content, filePath)) return [];
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
const findings = [];
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
const analyzer = REGEX_ANALYZERS[3 + i];
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
findings.push(...profileFindings(profile, {
engine: 'regex',
phase: 'text-content',
ruleId,
target: filePath,
}, () => analyzer(content, filePath)));
}
return findings;
}
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = extFromFilePath(filePath);
// 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', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
// Extract and scan <style> blocks from Vue/Svelte SFCs
const styleBlocks = profile
? profileStep(profile, {
engine: 'regex',
phase: 'extract',
ruleId: 'style-blocks',
target: filePath,
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
}));
}
// Extract and scan CSS-in-JS template literals
const cssJsBlocks = profile
? profileStep(profile, {
engine: 'regex',
phase: 'extract',
ruleId: 'css-in-js',
target: filePath,
}, () => extractCSSinJS(content, ext))
: extractCSSinJS(content, ext);
for (const block of cssJsBlocks) {
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'css-in-js',
}));
}
if (options?.designSystem) {
findings.push(...profileFindings(profile, {
engine: 'regex',
phase: 'source',
ruleId: 'design-system',
target: filePath,
}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));
}
// 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 (shouldRunPageAnalyzers(content, filePath)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'em-dash-overuse',
'marketing-buzzword',
'numbered-section-markers',
'aphoristic-cadence',
'dark-glow',
];
for (let i = 0; i < REGEX_ANALYZERS.length; i++) {
const analyzer = REGEX_ANALYZERS[i];
deduped.push(...profileFindings(profile, {
engine: 'regex',
phase: 'page-analyzer',
ruleId: analyzerIds[i] || `analyzer-${i + 1}`,
target: filePath,
}, () => analyzer(content, filePath)));
}
}
const byProvider = filterByProviders(deduped, options?.providers);
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
}
export {
REGEX_MATCHERS,
REGEX_ANALYZERS,
TEXT_CONTENT_ANALYZER_IDS,
extractStyleBlocks,
extractCSSinJS,
runRegexMatchers,
runTextContentAnalyzers,
detectText,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import {
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
mergeDesignSystemFindings,
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
checkElementClippedOverflow,
checkElementColors,
checkElementGlow,
checkElementGptBorderShadow,
checkElementHeroEyebrow,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementOversizedH1,
checkElementQuality,
checkCreamPalette,
checkHtmlPatterns,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
import {
StaticDocument,
buildStaticStyleMap,
buildStaticWindow,
collectStaticCssText,
} from './css-cascade.mjs';
function checkStaticPageTypography(document, window) {
const findings = [];
const fonts = new Set();
const overusedFound = new Set();
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
if (!hasText) continue;
const ff = window.getComputedStyle(el).fontFamily || '';
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
if (!primary) continue;
fonts.add(primary);
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
}
for (const font of overusedFound) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
}
const sizes = new Set();
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio < 2.0) {
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
}
}
return findings;
}
function checkElementBrokenImage(el) {
const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src;
// Missing src attribute entirely
if (src === undefined || src === null) {
return [{ id: 'broken-image', snippet: '<img> with no src attribute' }];
}
const trimmed = String(src).trim();
// Empty or placeholder-only src values
if (trimmed === '' || trimmed === '#') {
return [{ id: 'broken-image', snippet: `<img src="${src}">` }];
}
return [];
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) },
{ id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) },
{ id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
{ id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) },
{ id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) },
{ id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) },
{ id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
{ id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) },
{ id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) },
{ id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) },
];
async function detectHtml(filePath, options = {}) {
const profile = options?.profile;
const html = profileStep(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'read-html',
target: filePath,
}, () => fs.readFileSync(filePath, 'utf-8'));
let modules;
try {
modules = await profileStepAsync(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
is: cssSelect.is,
csstree,
domutils,
};
});
} catch {
return detectText(html, filePath, options);
}
const resolvedPath = path.resolve(filePath);
const fileDir = path.dirname(resolvedPath);
const root = profileStep(profile, {
engine: 'static-html',
phase: 'parse-html',
ruleId: 'parse-document',
target: filePath,
}, () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true }));
const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules);
const document = new StaticDocument(root, modules);
buildStaticStyleMap(root, document, cssText, modules, profile, filePath);
const window = buildStaticWindow(document);
const customPropMap = null;
const findings = [];
const runElementCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback)
: callback();
const visitedByRule = new Map();
for (const rule of STATIC_ELEMENT_RULES) {
const elements = document.querySelectorAll(rule.selector);
visitedByRule.set(rule.id, elements.length);
for (const el of elements) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
}
if (options?.designSystem) {
const sourceDesignFindings = profileFindings(profile, {
engine: 'static-html',
phase: 'source',
ruleId: 'design-system',
target: filePath,
}, () => checkSourceDesignSystem(html, filePath, { designSystem: options.designSystem }));
const staticDesignFindings = profileFindings(profile, {
engine: 'static-html',
phase: 'page',
ruleId: 'design-system',
target: filePath,
}, () => collectStaticDesignSystemFindings(document, window, filePath, options.designSystem));
findings.push(...mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings));
}
if (isFullPage(html)) {
const runPageCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
: callback();
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('cream-palette', () => checkCreamPalette(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
findings.push(finding(f.id, filePath, f.snippet));
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
// engine. Call them from here so .html files get the same coverage
// as .css/.tsx files. These are scoped to text content only and
// don't overlap with static-html's element/page rules.
for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) {
findings.push(finding(f.antipattern, filePath, f.snippet));
}
}
const byProvider = filterByProviders(findings, options.providers);
// Static-HTML findings carry no line number, so only whole-file
// `impeccable-disable` directives apply here — exactly the standalone-document
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
}
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
@@ -0,0 +1,189 @@
function sanitizeScreenshotClip(clip, viewport) {
if (!clip) return null;
const x = Math.max(0, Math.floor(clip.x || 0));
const y = Math.max(0, Math.floor(clip.y || 0));
const width = Math.min(
Math.max(1, Math.ceil(clip.width || 0)),
Math.max(1, viewport?.width || 1600),
);
const height = Math.min(
Math.max(1, Math.ceil(clip.height || 0)),
320,
);
if (width < 1 || height < 1) return null;
return { x, y, width, height };
}
async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
const loadImage = (base64) => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
img.src = `data:image/png;base64,${base64}`;
});
const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
const width = Math.min(before.width, after.width);
const height = Math.min(before.height, after.height);
if (width < 1 || height < 1) return null;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(before, 0, 0, width, height);
const beforePixels = ctx.getImageData(0, 0, width, height).data;
ctx.clearRect(0, 0, width, height);
ctx.drawImage(after, 0, 0, width, height);
const afterPixels = ctx.getImageData(0, 0, width, height).data;
const luminance = ({ r, g, b }) => {
const convert = c => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
};
const ratio = (a, b) => {
const l1 = luminance(a);
const l2 = luminance(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
};
const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
? {
r: candidate.textColor.r,
g: candidate.textColor.g,
b: candidate.textColor.b,
}
: null;
const ratios = [];
let glyphPixels = 0;
let strongestDelta = 0;
for (let i = 0; i < beforePixels.length; i += 4) {
const delta = Math.abs(beforePixels[i] - afterPixels[i])
+ Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
+ Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
+ Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
strongestDelta = Math.max(strongestDelta, delta);
if (delta < 10) continue;
glyphPixels++;
const fg = cssTextColor || {
r: beforePixels[i],
g: beforePixels[i + 1],
b: beforePixels[i + 2],
};
const bg = {
r: afterPixels[i],
g: afterPixels[i + 1],
b: afterPixels[i + 2],
};
ratios.push(ratio(fg, bg));
}
if (ratios.length < 8) {
return {
glyphPixels,
strongestDelta,
worstRatio: null,
p10Ratio: null,
medianRatio: null,
};
}
ratios.sort((a, b) => a - b);
const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
return {
glyphPixels,
strongestDelta,
worstRatio: ratios[0],
p10Ratio: pick(10),
medianRatio: pick(50),
};
}, { beforeBase64, afterBase64, candidate });
}
async function captureVisualContrastCandidate(page, candidate, viewport) {
const clip = sanitizeScreenshotClip(candidate.clip, viewport);
if (!clip) return null;
const beforeBase64 = await page.screenshot({
encoding: 'base64',
clip,
captureBeyondViewport: true,
});
const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
let el;
try {
el = document.querySelector(selector);
} catch {
return false;
}
if (!el) return false;
let style = document.getElementById('impeccable-visual-contrast-hide-style');
if (!style) {
style = document.createElement('style');
style.id = 'impeccable-visual-contrast-hide-style';
style.textContent = [
'[data-impeccable-visual-contrast-target] {',
' color: transparent !important;',
' -webkit-text-fill-color: transparent !important;',
' text-shadow: none !important;',
'}',
'[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
' background-image: none !important;',
'}',
].join('\n');
document.head.appendChild(style);
}
el.setAttribute('data-impeccable-visual-contrast-target', token);
if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
return true;
}, {
selector: candidate.selector,
token,
backgroundClipText: candidate.backgroundClipText,
});
if (!applied) return null;
let afterBase64;
try {
afterBase64 = await page.screenshot({
encoding: 'base64',
clip,
captureBeyondViewport: true,
});
} finally {
await page.evaluate(({ selector }) => {
try {
const el = document.querySelector(selector);
if (el) {
el.removeAttribute('data-impeccable-visual-contrast-target');
el.removeAttribute('data-impeccable-bgclip-text');
}
} catch {
// Ignore invalid or stale selectors during cleanup.
}
}, { selector: candidate.selector }).catch(() => {});
}
const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
const measuredRatio = metrics.p10Ratio;
if (measuredRatio >= candidate.threshold) return null;
const textLabel = candidate.text ? ` "${candidate.text}"` : '';
const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
return {
id: 'low-contrast',
snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
};
}
export {
sanitizeScreenshotClip,
compareScreenshotContrast,
captureVisualContrastCandidate,
};
@@ -0,0 +1,12 @@
import { getAntipattern } from './registry/antipatterns.mjs';
function getAP(id) {
return getAntipattern(id);
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet };
}
export { getAP, finding };
@@ -0,0 +1,198 @@
import fs from 'node:fs';
import path from 'node:path';
// ---------------------------------------------------------------------------
// File walker
// ---------------------------------------------------------------------------
const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
'.svelte-kit', '__pycache__', '.turbo', '.vercel',
]);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function walkDir(dir) {
const files = [];
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
}
return files;
}
// ---------------------------------------------------------------------------
// 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 };
}
}
export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
walkDir,
resolveImport,
buildImportGraph,
FRAMEWORK_CONFIGS,
detectFrameworkConfig,
isPortListening,
};
@@ -0,0 +1,166 @@
function profileNow() {
return typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now();
}
function createDetectorProfile() {
return { events: [] };
}
function recordProfileEvent(profile, event) {
if (!profile) return;
const normalized = {
engine: event.engine || 'unknown',
phase: event.phase || 'unknown',
ruleId: event.ruleId || 'unknown',
target: event.target || '',
ms: Number.isFinite(event.ms) ? event.ms : 0,
findings: Number.isFinite(event.findings) ? event.findings : 0,
};
if (event.detail) normalized.detail = event.detail;
if (Array.isArray(event.findingIds) && event.findingIds.length) {
normalized.findingIds = event.findingIds;
}
if (typeof profile === 'function') {
profile(normalized);
} else if (typeof profile.record === 'function') {
profile.record(normalized);
} else if (Array.isArray(profile.events)) {
profile.events.push(normalized);
} else if (Array.isArray(profile)) {
profile.push(normalized);
}
}
function extractFindingIds(findings) {
if (!Array.isArray(findings) || findings.length === 0) return [];
return [...new Set(findings.map(f => f?.id || f?.type || f?.antipattern).filter(Boolean))];
}
function profileFindings(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
const findings = callback();
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: Array.isArray(findings) ? findings.length : 0,
findingIds: extractFindingIds(findings),
});
return findings;
}
function profileStep(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
try {
return callback();
} finally {
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: 0,
});
}
}
async function profileFindingsAsync(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
const findings = await callback();
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: Array.isArray(findings) ? findings.length : 0,
findingIds: extractFindingIds(findings),
});
return findings;
}
async function profileStepAsync(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
try {
return await callback();
} finally {
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: 0,
});
}
}
function percentile(sortedValues, pct) {
if (!sortedValues.length) return 0;
const idx = Math.min(
sortedValues.length - 1,
Math.max(0, Math.ceil((pct / 100) * sortedValues.length) - 1),
);
return sortedValues[idx];
}
function summarizeDetectorProfile(profile) {
const events = Array.isArray(profile)
? profile
: (Array.isArray(profile?.events) ? profile.events : []);
const groups = new Map();
for (const event of events) {
const key = [
event.engine || 'unknown',
event.phase || 'unknown',
event.ruleId || 'unknown',
event.target || '',
].join('\u0000');
let group = groups.get(key);
if (!group) {
group = {
engine: event.engine || 'unknown',
phase: event.phase || 'unknown',
ruleId: event.ruleId || 'unknown',
target: event.target || '',
calls: 0,
totalMs: 0,
findings: 0,
samples: [],
};
groups.set(key, group);
}
const ms = Number.isFinite(event.ms) ? event.ms : 0;
group.calls += 1;
group.totalMs += ms;
group.findings += Number.isFinite(event.findings) ? event.findings : 0;
group.samples.push(ms);
}
return [...groups.values()]
.map(group => {
const samples = group.samples.sort((a, b) => a - b);
return {
engine: group.engine,
phase: group.phase,
ruleId: group.ruleId,
target: group.target,
calls: group.calls,
totalMs: Number(group.totalMs.toFixed(3)),
avgMs: Number((group.totalMs / group.calls).toFixed(3)),
p50: Number(percentile(samples, 50).toFixed(3)),
p95: Number(percentile(samples, 95).toFixed(3)),
findings: group.findings,
};
})
.sort((a, b) => b.totalMs - a.totalMs);
}
export {
profileNow,
createDetectorProfile,
recordProfileEvent,
extractFindingIds,
profileFindings,
profileStep,
profileFindingsAsync,
profileStepAsync,
percentile,
summarizeDetectorProfile,
};
@@ -0,0 +1,459 @@
const ANTIPATTERNS = [
// ── AI slop: tells that something was AI-generated ──
{
id: 'side-tab',
category: 'slop',
name: 'Side-tab accent border',
description:
'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.',
skillSection: 'Visual Details',
skillGuideline: 'colored accent stripe',
},
{
id: 'border-accent-on-rounded',
category: 'slop',
name: 'Border accent on rounded element',
description:
'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
skillSection: 'Visual Details',
skillGuideline: 'colored accent stripe',
},
{
id: 'overused-font',
category: 'slop',
name: 'Overused font',
description:
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
skillSection: 'Typography',
skillGuideline: 'overused fonts like Inter',
},
{
id: 'single-font',
category: 'slop',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
skillSection: 'Typography',
skillGuideline: 'only one font family for the entire page',
},
{
id: 'flat-type-hierarchy',
category: 'slop',
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
skillSection: 'Typography',
skillGuideline: 'flat type hierarchy',
},
{
id: 'gradient-text',
category: 'slop',
name: 'Gradient text',
description:
'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
skillSection: 'Color & Contrast',
skillGuideline: 'gradient text for',
},
{
id: 'ai-color-palette',
category: 'slop',
name: 'AI color palette',
description:
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
skillSection: 'Color & Contrast',
skillGuideline: 'AI color palette',
},
{
id: 'cream-palette',
category: 'slop',
name: 'Cream / beige palette',
description:
'A warm cream or beige page background has become the default "tasteful" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.',
skillSection: 'Color & Contrast',
skillGuideline: 'cream and beige as the default surface',
},
{
id: 'nested-cards',
category: 'slop',
name: 'Nested cards',
description:
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
skillSection: 'Layout & Space',
skillGuideline: 'Nest cards inside cards',
},
{
id: 'monotonous-spacing',
category: 'slop',
name: 'Monotonous spacing',
description:
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
skillSection: 'Layout & Space',
skillGuideline: 'same spacing everywhere',
},
{
id: 'bounce-easing',
category: 'slop',
name: 'Bounce or elastic easing',
description:
'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
skillSection: 'Motion',
skillGuideline: 'bounce or elastic easing',
},
{
id: 'dark-glow',
category: 'slop',
name: 'Dark mode with glowing accents',
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'icon-tile-stack',
category: 'slop',
name: 'Icon tile stacked above heading',
description:
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
skillSection: 'Typography',
skillGuideline: 'large icons with rounded corners above every heading',
},
{
id: 'italic-serif-display',
category: 'slop',
name: 'Italic serif display headline',
description:
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
skillSection: 'Typography',
skillGuideline: 'oversized italic serif as the hero headline',
},
{
id: 'hero-eyebrow-chip',
category: 'slop',
name: 'Hero eyebrow / pill chip',
description:
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
skillSection: 'Typography',
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
id: 'repeated-section-kickers',
category: 'slop',
severity: 'advisory',
name: 'Repeated section kicker labels',
description:
'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
skillSection: 'Typography',
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
},
{
id: 'numbered-section-markers',
category: 'slop',
severity: 'advisory',
name: 'Numbered section markers (01 / 02 / 03)',
description:
'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.',
skillSection: 'Layout & Space',
skillGuideline: 'numbered section markers',
},
{
id: 'em-dash-overuse',
category: 'slop',
name: 'Em-dash overuse',
description:
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
skillSection: 'Copy',
skillGuideline: 'no em dashes',
},
{
id: 'marketing-buzzword',
category: 'slop',
name: 'Marketing buzzword',
description:
'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.',
skillSection: 'Copy',
skillGuideline: 'marketing buzzwords',
},
{
id: 'aphoristic-cadence',
category: 'slop',
name: 'Aphoristic-cadence copy',
description:
'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.',
skillSection: 'Copy',
skillGuideline: 'aphoristic cadence',
},
{
id: 'oversized-h1',
category: 'slop',
name: 'Oversized hero headline',
description:
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
skillSection: 'Typography',
skillGuideline: 'long headline set at display size',
},
{
id: 'extreme-negative-tracking',
category: 'slop',
name: 'Crushed letter spacing',
description:
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
skillSection: 'Typography',
skillGuideline: 'letter spacing crushed past legibility',
},
{
id: 'broken-image',
category: 'quality',
name: 'Broken or placeholder image',
description:
'<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.',
skillSection: 'Imagery',
skillGuideline: 'broken image references',
},
// ── Quality: general design and accessibility issues ──
{
id: 'gray-on-color',
category: 'quality',
name: 'Gray text on colored background',
description:
'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
skillSection: 'Color & Contrast',
skillGuideline: 'gray text on colored backgrounds',
},
{
id: 'low-contrast',
category: 'quality',
name: 'Low contrast text',
description:
'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
},
{
id: 'layout-transition',
category: 'quality',
name: 'Layout property animation',
description:
'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
skillSection: 'Motion',
skillGuideline: 'Animate layout properties',
},
{
id: 'line-length',
category: 'quality',
name: 'Line length too long',
description:
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
skillSection: 'Layout & Space',
skillGuideline: 'wrap beyond ~80 characters',
},
{
id: 'cramped-padding',
category: 'quality',
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.',
skillSection: 'Layout & Space',
skillGuideline: 'inside bordered or colored containers',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
name: 'Body text touching viewport edge',
description:
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
},
{
id: 'tight-leading',
category: 'quality',
name: 'Tight line height',
description:
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
},
{
id: 'skipped-heading',
category: 'quality',
name: 'Skipped heading level',
description:
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
},
{
id: 'justified-text',
category: 'quality',
name: 'Justified text',
description:
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
},
{
id: 'tiny-text',
category: 'quality',
name: 'Tiny body text',
description:
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
},
{
id: 'all-caps-body',
category: 'quality',
name: 'All-caps body text',
description:
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
skillSection: 'Typography',
skillGuideline: 'long body passages in uppercase',
},
{
id: 'wide-tracking',
category: 'quality',
name: 'Wide letter spacing on body text',
description:
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
},
{
id: 'text-overflow',
category: 'quality',
name: 'Content overflowing its container',
description:
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
skillSection: 'Layout & Space',
skillGuideline: 'content wider than its container',
},
{
id: 'clipped-overflow-container',
category: 'quality',
name: 'Positioned child clipped by overflow container',
description:
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
skillSection: 'Layout & Space',
skillGuideline: 'overflow container clipping positioned children',
},
{
id: 'design-system-font',
category: 'quality',
name: 'Font outside DESIGN.md',
description:
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
skillSection: 'Typography',
skillGuideline: 'font family outside the project design system',
},
{
id: 'design-system-color',
category: 'quality',
severity: 'advisory',
name: 'Color outside DESIGN.md',
description:
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
skillSection: 'Color & Contrast',
skillGuideline: 'literal color outside the project design system',
},
{
id: 'design-system-radius',
category: 'quality',
severity: 'advisory',
name: 'Radius outside DESIGN.md',
description:
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
skillSection: 'Visual Details',
skillGuideline: 'border radius outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
id: 'gpt-thin-border-wide-shadow',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Hairline border with wide shadow',
description:
'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.',
skillSection: 'Visual Details',
skillGuideline: 'hairline border plus wide diffuse shadow',
},
{
id: 'repeating-stripes-gradient',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Repeating-gradient stripes',
description:
'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.',
skillSection: 'Visual Details',
skillGuideline: 'repeating-gradient decorative stripes',
},
{
id: 'codex-grid-background',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Decorative grid-line background',
description:
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
skillSection: 'Visual Details',
skillGuideline: 'two-axis grid-line gradient background',
},
{
id: 'theater-slop-phrase',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Theater framing copy',
description:
'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.',
skillSection: 'Copy',
skillGuideline: 'theater framing copy',
},
{
id: 'image-hover-transform',
category: 'slop',
severity: 'advisory',
gated: 'gemini',
name: 'Image hover transform',
description:
'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.',
skillSection: 'Motion',
skillGuideline: 'image scale or rotate on hover',
},
];
const RULE_ENGINE_SUPPORT = {
regex: new Set(['source', 'page-analyzer']),
'static-html': new Set(['element', 'page']),
browser: new Set(['element', 'page', 'layout']),
visual: new Set(['visual-contrast']),
};
function getAntipattern(id) {
return ANTIPATTERNS.find(rule => rule.id === id);
}
function getRulesForCategory(category) {
return ANTIPATTERNS.filter(rule => rule.category === category);
}
function getRuleEngineSupport(engine) {
return RULE_ENGINE_SUPPORT[engine] || new Set();
}
// Set of provider tags that gate rules off by default (e.g. 'gpt', 'gemini').
const GATED_PROVIDERS = new Set(
ANTIPATTERNS.map(rule => rule.gated).filter(Boolean),
);
// Drop findings for rules gated behind a provider tag unless that provider
// was explicitly enabled (CLI --gpt / --gemini). Non-gated findings always
// pass through. `findings` carry the rule id on `.antipattern`.
function filterByProviders(findings, providers = []) {
const enabled = new Set(providers || []);
if (!GATED_PROVIDERS.size) return findings;
return findings.filter(f => {
const rule = getAntipattern(f.antipattern);
if (!rule || !rule.gated) return true;
return enabled.has(rule.gated);
});
}
export {
ANTIPATTERNS,
RULE_ENGINE_SUPPORT,
GATED_PROVIDERS,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
filterByProviders,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
// ─── Section 2: Color Utilities ─────────────────────────────────────────────
function isNeutralColor(color) {
if (!color || color === 'transparent') return true;
// rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0255 range.
const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgb) {
return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
}
// oklch()/lch() — chroma is the second numeric component.
// oklch chroma is ~00.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
// lch chroma is ~0150; >= 3 reads as tinted. jsdom emits both formats
// literally (it does NOT convert them to rgb).
const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);
if (oklch) return parseFloat(oklch[1]) < 0.02;
const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i);
if (lch) return parseFloat(lch[1]) < 3;
// oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
// oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
if (oklab) {
const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
return Math.hypot(a, b) < 0.02;
}
const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
if (lab) {
const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
return Math.hypot(a, b) < 3;
}
// hsl/hsla — saturation is the second numeric component (percent).
// Modern jsdom usually converts hsl() to rgb, but handle it directly for
// safety across versions and for any engine that preserves the format.
const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
if (hsl) return parseFloat(hsl[1]) < 10;
// hwb(hue whiteness% blackness%) — a pixel is fully gray when
// whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
if (hwb) {
const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
return (1 - Math.min(100, w + b) / 100) < 0.1;
}
// Unknown / unrecognized format — err on the side of DETECTING rather
// than silently skipping. This is the opposite of the previous default,
// which was the root cause of the oklch bug.
return false;
}
function parseRgb(color) {
if (!color || color === 'transparent') return null;
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return null;
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
}
function relativeLuminance({ r, g, b }) {
const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
);
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function contrastRatio(c1, c2) {
const l1 = relativeLuminance(c1);
const l2 = relativeLuminance(c2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
} else {
colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });
}
}
return colors;
}
function hasChroma(c, threshold = 30) {
if (!c) return false;
return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
}
function getHue(c) {
if (!c) return 0;
const r = c.r / 255, g = c.g / 255, b = c.b / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
if (max === min) return 0;
const d = max - min;
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return Math.round(h * 360);
}
function colorToHex(c) {
if (!c) return '?';
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
hasChroma,
getHue,
colorToHex,
};
@@ -0,0 +1,101 @@
// ─── Section 1: Constants ───────────────────────────────────────────────────
const SAFE_TAGS = 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', 'circle',
'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
]);
// Per-check safe-tags override for the border (side-tab / border-accent)
// rule. We intentionally re-allow <label> here because card-shaped clickable
// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
// canonical side-tab anti-pattern shapes and must be detected. The rule's
// other preconditions (non-neutral color, width >= 2px on a single side,
// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
// already filter out plain inline form labels so this does not introduce
// false positives. See modern-color-borders.html for the test matrix.
const BORDER_SAFE_TAGS = new Set(
[...SAFE_TAGS].filter(t => t !== 'label')
);
const OVERUSED_FONTS = new Set([
// Older monoculture (still ubiquitous):
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
// Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave):
'fraunces', 'instrument sans', 'instrument serif',
'geist', 'geist sans', 'geist mono',
'mona sans',
'plus jakarta sans', 'space grotesk', 'recoleta',
]);
// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
const GOOGLE_DOMAINS = [
'google.com', 'youtube.com', 'android.com', 'chromium.org',
'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
];
const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app'];
const GITHUB_DOMAINS = ['github.com', 'githubnext.com'];
const BRAND_FONT_DOMAINS = {
'roboto': GOOGLE_DOMAINS,
'google sans': GOOGLE_DOMAINS,
'product sans': GOOGLE_DOMAINS,
'geist': VERCEL_DOMAINS,
'geist sans': VERCEL_DOMAINS,
'geist mono': VERCEL_DOMAINS,
'mona sans': GITHUB_DOMAINS,
};
function isBrandFontOnOwnDomain(font) {
if (typeof location === 'undefined') return false;
const allowed = BRAND_FONT_DOMAINS[font];
if (!allowed) return false;
const host = location.hostname.toLowerCase();
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
}
const GENERIC_FONTS = new Set([
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
'inherit', 'initial', 'unset', 'revert',
]);
// WCAG large text thresholds are defined in points: 18pt normal text and
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
// Serif faces that show up in italic-display heroes. The rule also fires when
// the primary face is unknown but the stack ends in the generic `serif` token,
// which catches custom/private faces with a serif fallback.
const KNOWN_SERIF_FONTS = new Set([
'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair',
'cormorant', 'cormorant garamond', 'garamond', 'eb garamond',
'tiempos', 'tiempos headline', 'tiempos text',
'lora', 'vollkorn', 'spectral',
'source serif pro', 'source serif 4', 'source serif',
'ibm plex serif', 'merriweather',
'libre caslon', 'libre baskerville', 'baskerville',
'georgia', 'times new roman', 'times',
'dm serif display', 'dm serif text',
'instrument serif', 'gt sectra', 'ogg', 'canela',
'freight display', 'freight text',
]);
export {
SAFE_TAGS,
BORDER_SAFE_TAGS,
OVERUSED_FONTS,
GOOGLE_DOMAINS,
VERCEL_DOMAINS,
GITHUB_DOMAINS,
BRAND_FONT_DOMAINS,
isBrandFontOnOwnDomain,
GENERIC_FONTS,
WCAG_LARGE_TEXT_PX,
WCAG_LARGE_BOLD_TEXT_PX,
KNOWN_SERIF_FONTS,
};
@@ -0,0 +1,148 @@
/**
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
* the point they apply and travel with the artifact instead of (or alongside)
* an ignore in `.impeccable/config.json`.
*
* A config ignore is the right default for repo-wide policy. This complements it
* for the one case config can't cover: a waiver that belongs to a single file and
* needs to follow that file when it leaves the repo — a generated/exported
* standalone document, an emailed HTML file, a snippet scanned out of context.
*
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
* line, so the same marker works across every comment style impeccable scans —
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
* are stripped before the rule list is parsed.
*
* Syntax (reason optional; eslint `--` or biome `:` separator):
*
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
* impeccable-disable-line <rule>... [-- reason] the same line
* impeccable-disable-next-line <rule>... [-- reason] the following line
* impeccable-disable bare / `*` = every rule
*
* Examples:
*
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
*
* Behavior is suppression, for parity with config ignores: a matched directive
* drops the finding. The inline reason is self-documenting in the diff; it is not
* required and is discarded at scan time (only used here to keep reason words out
* of the parsed rule list).
*/
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
// space before the closer. `--+>` covers `-->` and any longer dash run.
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
function normalizeRule(token) {
return String(token || '').trim().toLowerCase();
}
// Split the directive remainder into rule tokens, dropping any human reason that
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
// are unambiguous separators.
function parseRuleList(remainder) {
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
if (reasonSep) text = text.slice(0, reasonSep.index);
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
return tokens;
}
function addRules(set, rules) {
for (const rule of rules) set.add(rule);
}
function getSet(map, key) {
let set = map.get(key);
if (!set) {
set = new Set();
map.set(key, set);
}
return set;
}
/**
* Parse every inline ignore directive in a file's raw text.
*
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
* direct lookup:
* - file: rules disabled for the whole file
* - line: line -> rules disabled on that exact line (disable-line)
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
*
* `*` in any set means "every rule".
*/
function parseInlineIgnores(content) {
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
const text = typeof content === 'string' ? content : '';
// Cheap bail-out: the substring must be present for any directive to exist.
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
if (!/impeccable-disable/i.test(text)) return result;
// Split on `\n` only, exactly as detectText numbers lines, so directive line
// keys line up with finding `line` values (incl. on `\r`-only line endings).
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
// never captured into the rule list.
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
DIRECTIVE_RE.lastIndex = 0;
let m;
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
const variant = m[1].toLowerCase();
const rules = parseRuleList(m[2]);
if (variant === 'disable') {
addRules(result.file, rules);
} else if (variant === 'disable-line') {
addRules(getSet(result.line, i + 1), rules);
} else {
// disable-next-line on line i+1 targets line i+2.
addRules(getSet(result.nextLine, i + 2), rules);
}
}
}
return result;
}
function setMatches(set, rule) {
return Boolean(set) && (set.has('*') || set.has(rule));
}
function isInlineIgnored(finding, directives) {
const rule = normalizeRule(finding && finding.antipattern);
if (!rule) return false;
if (setMatches(directives.file, rule)) return true;
const line = Number(finding && finding.line) || 0;
if (line > 0) {
if (setMatches(directives.line.get(line), rule)) return true;
if (setMatches(directives.nextLine.get(line), rule)) return true;
}
return false;
}
function hasDirectives(directives) {
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
}
/**
* Drop findings waived by an inline directive in the same file's source text.
* Findings without a usable line number (e.g. static-HTML page-level findings)
* are only matched by whole-file directives — which is the standalone-document
* case this primitive exists for.
*/
function applyInlineIgnores(findings, content) {
if (!Array.isArray(findings) || findings.length === 0) return findings;
const directives = parseInlineIgnores(content);
if (!hasDirectives(directives)) return findings;
return findings.filter((finding) => !isInlineIgnored(finding, directives));
}
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
@@ -0,0 +1,7 @@
/** Check if content looks like a full page (not a component/partial) */
function isFullPage(content) {
const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
}
export { isFullPage };