mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c5f0babe | ||
|
|
1902ef03c2 | ||
|
|
a0552bba0c | ||
|
|
7139130f99 | ||
|
|
cf19c53d32 | ||
|
|
a5af5b2f06 | ||
|
|
ecfcd77221 | ||
|
|
720222e1df | ||
|
|
eec431e778 | ||
|
|
95b28ed6d7 | ||
|
|
0fa4202ca9 | ||
|
|
45148003af | ||
|
|
312e627eea | ||
|
|
e4438ea586 |
@@ -427,6 +427,8 @@ npx impeccable ignores add-value overused-font Inter --reason "Brand font"
|
||||
|
||||
The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
|
||||
|
||||
Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports.
|
||||
|
||||
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
|
||||
|
||||
For a waiver that should travel with one file instead of the repo config, add an inline comment in the file: `<!-- impeccable-disable overused-font: exported brand doc -->`. The marker works in any comment syntax, scopes to the whole file (or one line with `impeccable-disable-line` / `impeccable-disable-next-line`), and is bypassed by `--no-inline-ignores` or `--no-config`.
|
||||
|
||||
@@ -1228,14 +1228,17 @@ if (IS_BROWSER) {
|
||||
isHidden: isElementHidden(el),
|
||||
findings: findings.map(f => {
|
||||
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
||||
const severity = f.severity || ap?.severity || 'warning';
|
||||
return {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
severity,
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
// Per-finding promotions override the registry default, so derive
|
||||
// this strictly from the effective severity.
|
||||
advisory: severity === 'advisory',
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -1277,6 +1280,381 @@ if (IS_BROWSER) {
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function pseudoElementHostSelector(selector) {
|
||||
const raw = String(selector || '');
|
||||
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
|
||||
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
|
||||
const consumeFunction = (start) => {
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
for (let i = start; i < raw.length; i += 1) {
|
||||
const char = raw[i];
|
||||
if (char === '\\') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === '(') depth += 1;
|
||||
if (char === ')' && --depth === 0) return i + 1;
|
||||
}
|
||||
return raw.length;
|
||||
};
|
||||
|
||||
let output = '';
|
||||
let found = false;
|
||||
for (let i = 0; i < raw.length;) {
|
||||
const char = raw[i];
|
||||
if (char === '\\') {
|
||||
output += raw.slice(i, Math.min(raw.length, i + 2));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
const quote = char;
|
||||
const start = i;
|
||||
i += 1;
|
||||
while (i < raw.length) {
|
||||
if (raw[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const value = raw[i];
|
||||
i += 1;
|
||||
if (value === quote) break;
|
||||
}
|
||||
output += raw.slice(start, i);
|
||||
continue;
|
||||
}
|
||||
if (char !== ':') {
|
||||
output += char;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = i + 1;
|
||||
let isPseudoElement = false;
|
||||
if (raw[end] === ':') {
|
||||
end += 1;
|
||||
const nameStart = end;
|
||||
while (isNameChar(raw[end])) end += 1;
|
||||
isPseudoElement = end > nameStart;
|
||||
} else {
|
||||
const nameStart = end;
|
||||
while (isNameChar(raw[end])) end += 1;
|
||||
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
|
||||
}
|
||||
if (!isPseudoElement) {
|
||||
output += char;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (raw[end] === '(') end = consumeFunction(end);
|
||||
found = true;
|
||||
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
|
||||
i = end;
|
||||
}
|
||||
if (!found) return null;
|
||||
return output.trim().replace(/,\s*(?=,|$)/g, '');
|
||||
}
|
||||
|
||||
function selectorNodesForLiveDom(root, selector) {
|
||||
const raw = String(selector || '').trim();
|
||||
if (!raw) return null;
|
||||
const fallback = pseudoElementHostSelector(raw);
|
||||
if (fallback == null) {
|
||||
// An empty result from a valid full selector is authoritative. In
|
||||
// particular, do not broaden inactive :hover/:focus/:not() rules to
|
||||
// their host element by stripping pseudo-classes.
|
||||
try { return Array.from(root.querySelectorAll(raw)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Resolve pseudo-elements to their originating live elements. An attached
|
||||
// pseudo-element (`.card::before`) belongs to the element before it, while
|
||||
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
|
||||
// to a matching element at that position (`main > *`). Replacing every
|
||||
// pseudo indiscriminately with an empty string leaves the latter as the
|
||||
// invalid selector `main >` and makes absent hosts indistinguishable from
|
||||
// selectors the DOM API cannot parse.
|
||||
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
|
||||
try { return Array.from(root.querySelectorAll(fallback)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
let containerProbeSequence = 0;
|
||||
|
||||
function isContainerCssRule(rule) {
|
||||
return rule?.constructor?.name === 'CSSContainerRule'
|
||||
|| /^\s*@container\b/i.test(rule?.cssText || '');
|
||||
}
|
||||
|
||||
function styleRuleAppliesToLiveMatches(rule, matches) {
|
||||
const style = rule?.style;
|
||||
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
|
||||
const sequence = ++containerProbeSequence;
|
||||
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
|
||||
const value = `impeccable-container-active-${sequence}`;
|
||||
const previousValue = style.getPropertyValue(property);
|
||||
const previousPriority = style.getPropertyPriority(property);
|
||||
try {
|
||||
style.setProperty(property, value, 'important');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pseudoElements = [...new Set(
|
||||
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
|
||||
)];
|
||||
try {
|
||||
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
|
||||
try {
|
||||
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
|
||||
return computed.getPropertyValue(property).trim() === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
} finally {
|
||||
if (previousValue) style.setProperty(property, previousValue, previousPriority);
|
||||
else style.removeProperty(property);
|
||||
}
|
||||
}
|
||||
|
||||
function conditionalCssRuleIsActive(rule) {
|
||||
const type = Number(rule?.type);
|
||||
const constructorName = rule?.constructor?.name || '';
|
||||
if (constructorName === 'CSSMediaRule' || type === 4) {
|
||||
const condition = rule.conditionText || rule.media?.mediaText || '';
|
||||
if (!condition || typeof window.matchMedia !== 'function') return true;
|
||||
try { return window.matchMedia(condition).matches; }
|
||||
catch { return true; }
|
||||
}
|
||||
if (constructorName === 'CSSSupportsRule' || type === 12) {
|
||||
const condition = rule.conditionText || '';
|
||||
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
|
||||
try { return CSS.supports(condition); }
|
||||
catch { return true; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitCssCommaList(value) {
|
||||
const parts = [];
|
||||
let current = '';
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (const char of String(value || '')) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
current += char;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === ',') {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function normalizeAnimationName(value) {
|
||||
const name = String(value || '').trim();
|
||||
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
|
||||
return name.slice(1, -1);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function animationNamesDeclaredByRule(rule) {
|
||||
const style = rule?.style;
|
||||
if (!style) return [];
|
||||
let value = '';
|
||||
try {
|
||||
value = style.animationName
|
||||
|| style.getPropertyValue?.('animation-name')
|
||||
|| style.webkitAnimationName
|
||||
|| style.getPropertyValue?.('-webkit-animation-name')
|
||||
|| '';
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return splitCssCommaList(value)
|
||||
.map(normalizeAnimationName)
|
||||
.filter(name => name && name.toLowerCase() !== 'none');
|
||||
}
|
||||
|
||||
function keyframesRuleName(rule, cssText) {
|
||||
const constructorName = rule?.constructor?.name || '';
|
||||
const type = Number(rule?.type);
|
||||
const isKeyframes = constructorName === 'CSSKeyframesRule'
|
||||
|| constructorName === 'WebKitCSSKeyframesRule'
|
||||
|| type === 7
|
||||
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
||||
if (!isKeyframes) return '';
|
||||
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
|
||||
return normalizeAnimationName(rule?.name || match?.[1] || '');
|
||||
}
|
||||
|
||||
function cssPropertyName(property) {
|
||||
if (property.startsWith('--')) return property;
|
||||
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function resolvedAnimationKeyframes(candidateNames) {
|
||||
if (typeof document.getAnimations !== 'function') return null;
|
||||
let animations;
|
||||
try { animations = document.getAnimations(); }
|
||||
catch { return null; }
|
||||
|
||||
const resolved = new Map();
|
||||
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
|
||||
for (const animation of animations) {
|
||||
const name = normalizeAnimationName(animation?.animationName || '');
|
||||
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
|
||||
let frames;
|
||||
try { frames = animation.effect?.getKeyframes?.() || []; }
|
||||
catch { continue; }
|
||||
const blocks = [];
|
||||
for (const frame of frames) {
|
||||
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
|
||||
if (!Number.isFinite(rawOffset)) continue;
|
||||
const offset = Math.round(rawOffset * 1000000) / 10000;
|
||||
const declarations = Object.entries(frame)
|
||||
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
|
||||
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
|
||||
const easing = String(frame.easing || '').trim();
|
||||
if (easing && easing.toLowerCase() !== 'linear') {
|
||||
declarations.push(`animation-timing-function: ${easing};`);
|
||||
}
|
||||
if (declarations.length === 0) continue;
|
||||
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
|
||||
}
|
||||
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
|
||||
// already present in the HTML pattern corpus, so limit this walk to linked
|
||||
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
|
||||
// and admit only selector rules that target the live DOM. That prevents
|
||||
// unused utilities from feeding both selector-scoped and page-level checks.
|
||||
// Same-origin CSS and readable CORS sheets participate; browser security
|
||||
// exceptions for cross-origin sheets are expected and skipped.
|
||||
function linkedStylesheetText() {
|
||||
const parts = [];
|
||||
const seen = new Set();
|
||||
const animationNames = new Set();
|
||||
const keyframeCandidates = new Map();
|
||||
const appendRules = (rules, requiresAppliedMatch = false) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) {
|
||||
appendSheet(rule.styleSheet);
|
||||
continue;
|
||||
}
|
||||
const cssText = rule.cssText || '';
|
||||
if (rule.selectorText) {
|
||||
const matches = selectorNodesForLiveDom(document, rule.selectorText);
|
||||
// Only declarations with a resolvable live host enter the corpus.
|
||||
// Unresolvable selectors are uncertain, not evidence that a pattern
|
||||
// rendered, and retaining them would leak unused CSS into findings.
|
||||
if (
|
||||
matches?.length > 0
|
||||
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
|
||||
) {
|
||||
parts.push(cssText);
|
||||
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let nested = [];
|
||||
let hasNestedRules = false;
|
||||
try {
|
||||
const ruleList = rule.cssRules;
|
||||
hasNestedRules = ruleList != null;
|
||||
nested = Array.from(ruleList || []);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const keyframesName = keyframesRuleName(rule, cssText);
|
||||
if (keyframesName) {
|
||||
// Keyframes do not merge: when a name is defined more than once, the
|
||||
// later effective definition replaces the earlier one.
|
||||
keyframeCandidates.set(keyframesName, {
|
||||
name: keyframesName,
|
||||
cssText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (hasNestedRules) {
|
||||
if (!conditionalCssRuleIsActive(rule)) continue;
|
||||
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
|
||||
continue;
|
||||
}
|
||||
// Other selector-less leaf at-rules cannot be tied to a rendered node.
|
||||
}
|
||||
};
|
||||
const appendSheet = (sheet) => {
|
||||
if (!sheet || seen.has(sheet)) return;
|
||||
seen.add(sheet);
|
||||
let rules;
|
||||
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
|
||||
catch { return; }
|
||||
appendRules(rules);
|
||||
};
|
||||
let sheets;
|
||||
try { sheets = Array.from(document.styleSheets || []); }
|
||||
catch { return ''; }
|
||||
for (const sheet of sheets) {
|
||||
const owner = sheet.ownerNode;
|
||||
if (owner?.tagName?.toLowerCase() !== 'link') continue;
|
||||
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
|
||||
appendSheet(sheet);
|
||||
}
|
||||
// Motion checks need the effective body of a live animation's keyframes.
|
||||
// Let the browser resolve duplicate names across source order, imports,
|
||||
// conditional groups, and cascade layers, then serialize those computed
|
||||
// frames back into the pattern corpus. Browsers also make container-nested
|
||||
// keyframes globally available, so lexical grouping is not a reliable
|
||||
// activity signal. When the Web Animations API is unavailable, fall back to
|
||||
// the last source-order definition referenced by a retained linked rule.
|
||||
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
|
||||
if (resolvedKeyframes) {
|
||||
parts.push(...resolvedKeyframes.values());
|
||||
} else {
|
||||
for (const candidate of keyframeCandidates.values()) {
|
||||
if (!animationNames.has(candidate.name)) continue;
|
||||
parts.push(candidate.cssText);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
||||
}
|
||||
@@ -1650,18 +2028,16 @@ if (IS_BROWSER) {
|
||||
// (the CSS ships here, but the pattern never renders — the live DOM is
|
||||
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||
// ancestor is waived. Selector-less findings stay page-level.
|
||||
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
||||
const html = docClone.outerHTML;
|
||||
const corpora = buildHtmlPatternCorpora(html);
|
||||
const linkedCss = linkedStylesheetText();
|
||||
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
|
||||
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
|
||||
if (!f.selector) return true;
|
||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
||||
let matches;
|
||||
try {
|
||||
matches = document.querySelectorAll(query);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||
if (!matches) return false;
|
||||
if (matches.length === 0) return false;
|
||||
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
|
||||
return matches.some(el => !scopedIgnoreActive(el, f.id));
|
||||
});
|
||||
if (scopedHtmlFindings.length > 0) {
|
||||
const mapped = scopedHtmlFindings.map(f => {
|
||||
|
||||
+26
-6
@@ -37,13 +37,30 @@ function fileUrlToLocalPath(url) {
|
||||
}
|
||||
}
|
||||
|
||||
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
|
||||
|
||||
// Some agent runners hand a shell-ready URL list to Node as one argv value.
|
||||
// A browser accepts the spaces as part of one encoded URL, producing a
|
||||
// plausible scan attributed to a bogus joined path. Expand only when every
|
||||
// whitespace-delimited token is independently a URL, preserving ordinary
|
||||
// filesystem paths that contain spaces.
|
||||
function expandJoinedUrlTargets(targets) {
|
||||
return targets.flatMap((target) => {
|
||||
if (!/\s/.test(target)) return [target];
|
||||
const parts = target.trim().split(/\s+/).filter(Boolean);
|
||||
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
|
||||
? parts
|
||||
: [target];
|
||||
});
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
@@ -168,6 +185,10 @@ Advisory findings:
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -185,7 +206,7 @@ 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;
|
||||
http(s):// and file:// URLs)
|
||||
http(s):// and file:// URLs; accessible linked CSS included)
|
||||
|
||||
Examples:
|
||||
impeccable detect src/
|
||||
@@ -283,7 +304,7 @@ async function detectCli() {
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
@@ -297,13 +318,12 @@ async function detectCli() {
|
||||
// real cascade, real computed styles, real layout. Callers that want a
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlRe = /^(?:https?|file):\/\//i;
|
||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
|
||||
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
severity: 'advisory',
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
@@ -1961,7 +1961,10 @@ function enclosingCssSelector(cssText, index) {
|
||||
// `{` belongs to some other selector.
|
||||
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
||||
if (closeBeforeIndex > open) return null;
|
||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
||||
// Ignore delimiters inside comments when locating the previous declaration.
|
||||
// Keeping comment length intact preserves indices into the original source.
|
||||
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
|
||||
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
||||
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
||||
@@ -5293,14 +5296,17 @@ function checkTypography() {
|
||||
}
|
||||
|
||||
if (totalTextElements >= 20) {
|
||||
// A font is "primary" if it's used by at least 15% of text elements
|
||||
const PRIMARY_THRESHOLD = 0.15;
|
||||
for (const [font, count] of fontUsage) {
|
||||
// Report the actual primary face: the uniquely most-used family. The old
|
||||
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const [primary] = ranked;
|
||||
const tied = ranked[1]?.[1] === primary?.[1];
|
||||
if (primary && !tied) {
|
||||
const [font, count] = primary;
|
||||
const share = count / totalTextElements;
|
||||
if (share < PRIMARY_THRESHOLD) continue;
|
||||
if (!OVERUSED_FONTS.has(font)) continue;
|
||||
if (isBrandFontOnOwnDomain(font)) continue;
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8126,14 +8132,17 @@ if (IS_BROWSER) {
|
||||
isHidden: isElementHidden(el),
|
||||
findings: findings.map(f => {
|
||||
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
||||
const severity = f.severity || ap?.severity || 'warning';
|
||||
return {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
severity,
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
// Per-finding promotions override the registry default, so derive
|
||||
// this strictly from the effective severity.
|
||||
advisory: severity === 'advisory',
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -8175,6 +8184,381 @@ if (IS_BROWSER) {
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function pseudoElementHostSelector(selector) {
|
||||
const raw = String(selector || '');
|
||||
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
|
||||
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
|
||||
const consumeFunction = (start) => {
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
for (let i = start; i < raw.length; i += 1) {
|
||||
const char = raw[i];
|
||||
if (char === '\\') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === '(') depth += 1;
|
||||
if (char === ')' && --depth === 0) return i + 1;
|
||||
}
|
||||
return raw.length;
|
||||
};
|
||||
|
||||
let output = '';
|
||||
let found = false;
|
||||
for (let i = 0; i < raw.length;) {
|
||||
const char = raw[i];
|
||||
if (char === '\\') {
|
||||
output += raw.slice(i, Math.min(raw.length, i + 2));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
const quote = char;
|
||||
const start = i;
|
||||
i += 1;
|
||||
while (i < raw.length) {
|
||||
if (raw[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const value = raw[i];
|
||||
i += 1;
|
||||
if (value === quote) break;
|
||||
}
|
||||
output += raw.slice(start, i);
|
||||
continue;
|
||||
}
|
||||
if (char !== ':') {
|
||||
output += char;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = i + 1;
|
||||
let isPseudoElement = false;
|
||||
if (raw[end] === ':') {
|
||||
end += 1;
|
||||
const nameStart = end;
|
||||
while (isNameChar(raw[end])) end += 1;
|
||||
isPseudoElement = end > nameStart;
|
||||
} else {
|
||||
const nameStart = end;
|
||||
while (isNameChar(raw[end])) end += 1;
|
||||
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
|
||||
}
|
||||
if (!isPseudoElement) {
|
||||
output += char;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (raw[end] === '(') end = consumeFunction(end);
|
||||
found = true;
|
||||
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
|
||||
i = end;
|
||||
}
|
||||
if (!found) return null;
|
||||
return output.trim().replace(/,\s*(?=,|$)/g, '');
|
||||
}
|
||||
|
||||
function selectorNodesForLiveDom(root, selector) {
|
||||
const raw = String(selector || '').trim();
|
||||
if (!raw) return null;
|
||||
const fallback = pseudoElementHostSelector(raw);
|
||||
if (fallback == null) {
|
||||
// An empty result from a valid full selector is authoritative. In
|
||||
// particular, do not broaden inactive :hover/:focus/:not() rules to
|
||||
// their host element by stripping pseudo-classes.
|
||||
try { return Array.from(root.querySelectorAll(raw)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Resolve pseudo-elements to their originating live elements. An attached
|
||||
// pseudo-element (`.card::before`) belongs to the element before it, while
|
||||
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
|
||||
// to a matching element at that position (`main > *`). Replacing every
|
||||
// pseudo indiscriminately with an empty string leaves the latter as the
|
||||
// invalid selector `main >` and makes absent hosts indistinguishable from
|
||||
// selectors the DOM API cannot parse.
|
||||
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
|
||||
try { return Array.from(root.querySelectorAll(fallback)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
let containerProbeSequence = 0;
|
||||
|
||||
function isContainerCssRule(rule) {
|
||||
return rule?.constructor?.name === 'CSSContainerRule'
|
||||
|| /^\s*@container\b/i.test(rule?.cssText || '');
|
||||
}
|
||||
|
||||
function styleRuleAppliesToLiveMatches(rule, matches) {
|
||||
const style = rule?.style;
|
||||
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
|
||||
const sequence = ++containerProbeSequence;
|
||||
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
|
||||
const value = `impeccable-container-active-${sequence}`;
|
||||
const previousValue = style.getPropertyValue(property);
|
||||
const previousPriority = style.getPropertyPriority(property);
|
||||
try {
|
||||
style.setProperty(property, value, 'important');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pseudoElements = [...new Set(
|
||||
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
|
||||
)];
|
||||
try {
|
||||
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
|
||||
try {
|
||||
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
|
||||
return computed.getPropertyValue(property).trim() === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
} finally {
|
||||
if (previousValue) style.setProperty(property, previousValue, previousPriority);
|
||||
else style.removeProperty(property);
|
||||
}
|
||||
}
|
||||
|
||||
function conditionalCssRuleIsActive(rule) {
|
||||
const type = Number(rule?.type);
|
||||
const constructorName = rule?.constructor?.name || '';
|
||||
if (constructorName === 'CSSMediaRule' || type === 4) {
|
||||
const condition = rule.conditionText || rule.media?.mediaText || '';
|
||||
if (!condition || typeof window.matchMedia !== 'function') return true;
|
||||
try { return window.matchMedia(condition).matches; }
|
||||
catch { return true; }
|
||||
}
|
||||
if (constructorName === 'CSSSupportsRule' || type === 12) {
|
||||
const condition = rule.conditionText || '';
|
||||
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
|
||||
try { return CSS.supports(condition); }
|
||||
catch { return true; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitCssCommaList(value) {
|
||||
const parts = [];
|
||||
let current = '';
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (const char of String(value || '')) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
current += char;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === ',') {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function normalizeAnimationName(value) {
|
||||
const name = String(value || '').trim();
|
||||
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
|
||||
return name.slice(1, -1);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function animationNamesDeclaredByRule(rule) {
|
||||
const style = rule?.style;
|
||||
if (!style) return [];
|
||||
let value = '';
|
||||
try {
|
||||
value = style.animationName
|
||||
|| style.getPropertyValue?.('animation-name')
|
||||
|| style.webkitAnimationName
|
||||
|| style.getPropertyValue?.('-webkit-animation-name')
|
||||
|| '';
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return splitCssCommaList(value)
|
||||
.map(normalizeAnimationName)
|
||||
.filter(name => name && name.toLowerCase() !== 'none');
|
||||
}
|
||||
|
||||
function keyframesRuleName(rule, cssText) {
|
||||
const constructorName = rule?.constructor?.name || '';
|
||||
const type = Number(rule?.type);
|
||||
const isKeyframes = constructorName === 'CSSKeyframesRule'
|
||||
|| constructorName === 'WebKitCSSKeyframesRule'
|
||||
|| type === 7
|
||||
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
||||
if (!isKeyframes) return '';
|
||||
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
|
||||
return normalizeAnimationName(rule?.name || match?.[1] || '');
|
||||
}
|
||||
|
||||
function cssPropertyName(property) {
|
||||
if (property.startsWith('--')) return property;
|
||||
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function resolvedAnimationKeyframes(candidateNames) {
|
||||
if (typeof document.getAnimations !== 'function') return null;
|
||||
let animations;
|
||||
try { animations = document.getAnimations(); }
|
||||
catch { return null; }
|
||||
|
||||
const resolved = new Map();
|
||||
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
|
||||
for (const animation of animations) {
|
||||
const name = normalizeAnimationName(animation?.animationName || '');
|
||||
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
|
||||
let frames;
|
||||
try { frames = animation.effect?.getKeyframes?.() || []; }
|
||||
catch { continue; }
|
||||
const blocks = [];
|
||||
for (const frame of frames) {
|
||||
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
|
||||
if (!Number.isFinite(rawOffset)) continue;
|
||||
const offset = Math.round(rawOffset * 1000000) / 10000;
|
||||
const declarations = Object.entries(frame)
|
||||
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
|
||||
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
|
||||
const easing = String(frame.easing || '').trim();
|
||||
if (easing && easing.toLowerCase() !== 'linear') {
|
||||
declarations.push(`animation-timing-function: ${easing};`);
|
||||
}
|
||||
if (declarations.length === 0) continue;
|
||||
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
|
||||
}
|
||||
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
|
||||
// already present in the HTML pattern corpus, so limit this walk to linked
|
||||
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
|
||||
// and admit only selector rules that target the live DOM. That prevents
|
||||
// unused utilities from feeding both selector-scoped and page-level checks.
|
||||
// Same-origin CSS and readable CORS sheets participate; browser security
|
||||
// exceptions for cross-origin sheets are expected and skipped.
|
||||
function linkedStylesheetText() {
|
||||
const parts = [];
|
||||
const seen = new Set();
|
||||
const animationNames = new Set();
|
||||
const keyframeCandidates = new Map();
|
||||
const appendRules = (rules, requiresAppliedMatch = false) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) {
|
||||
appendSheet(rule.styleSheet);
|
||||
continue;
|
||||
}
|
||||
const cssText = rule.cssText || '';
|
||||
if (rule.selectorText) {
|
||||
const matches = selectorNodesForLiveDom(document, rule.selectorText);
|
||||
// Only declarations with a resolvable live host enter the corpus.
|
||||
// Unresolvable selectors are uncertain, not evidence that a pattern
|
||||
// rendered, and retaining them would leak unused CSS into findings.
|
||||
if (
|
||||
matches?.length > 0
|
||||
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
|
||||
) {
|
||||
parts.push(cssText);
|
||||
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let nested = [];
|
||||
let hasNestedRules = false;
|
||||
try {
|
||||
const ruleList = rule.cssRules;
|
||||
hasNestedRules = ruleList != null;
|
||||
nested = Array.from(ruleList || []);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const keyframesName = keyframesRuleName(rule, cssText);
|
||||
if (keyframesName) {
|
||||
// Keyframes do not merge: when a name is defined more than once, the
|
||||
// later effective definition replaces the earlier one.
|
||||
keyframeCandidates.set(keyframesName, {
|
||||
name: keyframesName,
|
||||
cssText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (hasNestedRules) {
|
||||
if (!conditionalCssRuleIsActive(rule)) continue;
|
||||
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
|
||||
continue;
|
||||
}
|
||||
// Other selector-less leaf at-rules cannot be tied to a rendered node.
|
||||
}
|
||||
};
|
||||
const appendSheet = (sheet) => {
|
||||
if (!sheet || seen.has(sheet)) return;
|
||||
seen.add(sheet);
|
||||
let rules;
|
||||
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
|
||||
catch { return; }
|
||||
appendRules(rules);
|
||||
};
|
||||
let sheets;
|
||||
try { sheets = Array.from(document.styleSheets || []); }
|
||||
catch { return ''; }
|
||||
for (const sheet of sheets) {
|
||||
const owner = sheet.ownerNode;
|
||||
if (owner?.tagName?.toLowerCase() !== 'link') continue;
|
||||
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
|
||||
appendSheet(sheet);
|
||||
}
|
||||
// Motion checks need the effective body of a live animation's keyframes.
|
||||
// Let the browser resolve duplicate names across source order, imports,
|
||||
// conditional groups, and cascade layers, then serialize those computed
|
||||
// frames back into the pattern corpus. Browsers also make container-nested
|
||||
// keyframes globally available, so lexical grouping is not a reliable
|
||||
// activity signal. When the Web Animations API is unavailable, fall back to
|
||||
// the last source-order definition referenced by a retained linked rule.
|
||||
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
|
||||
if (resolvedKeyframes) {
|
||||
parts.push(...resolvedKeyframes.values());
|
||||
} else {
|
||||
for (const candidate of keyframeCandidates.values()) {
|
||||
if (!animationNames.has(candidate.name)) continue;
|
||||
parts.push(candidate.cssText);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
||||
}
|
||||
@@ -8548,18 +8932,16 @@ if (IS_BROWSER) {
|
||||
// (the CSS ships here, but the pattern never renders — the live DOM is
|
||||
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||
// ancestor is waived. Selector-less findings stay page-level.
|
||||
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
||||
const html = docClone.outerHTML;
|
||||
const corpora = buildHtmlPatternCorpora(html);
|
||||
const linkedCss = linkedStylesheetText();
|
||||
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
|
||||
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
|
||||
if (!f.selector) return true;
|
||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
||||
let matches;
|
||||
try {
|
||||
matches = document.querySelectorAll(query);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||
if (!matches) return false;
|
||||
if (matches.length === 0) return false;
|
||||
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
|
||||
return matches.some(el => !scopedIgnoreActive(el, f.id));
|
||||
});
|
||||
if (scopedHtmlFindings.length > 0) {
|
||||
const mapped = scopedHtmlFindings.map(f => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
|
||||
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
||||
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
|
||||
@@ -394,7 +394,7 @@ async function detectUrl(rawUrl, options = {}) {
|
||||
// Per-finding severity promotion (e.g. hero-region pulsing dot)
|
||||
// overrides the registry default carried by finding().
|
||||
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
|
||||
return item;
|
||||
return deriveAdvisoryFlag(item);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
checkElementBorders,
|
||||
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
|
||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||
// overrides the registry default.
|
||||
if (f.severity) item.severity = f.severity;
|
||||
findings.push(item);
|
||||
findings.push(deriveAdvisoryFlag(item));
|
||||
}
|
||||
// Text-content analyzers (em-dash overuse, marketing buzzwords,
|
||||
// numbered section markers, aphoristic cadence) live in the regex
|
||||
|
||||
@@ -4,6 +4,12 @@ function getAP(id) {
|
||||
return getAntipattern(id);
|
||||
}
|
||||
|
||||
function deriveAdvisoryFlag(item) {
|
||||
if (item.severity === 'advisory') item.advisory = true;
|
||||
else delete item.advisory;
|
||||
return item;
|
||||
}
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
@@ -11,8 +17,7 @@ function finding(id, filePath, snippet, line = 0) {
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
return base;
|
||||
return deriveAdvisoryFlag(base);
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
export { getAP, finding, deriveAdvisoryFlag };
|
||||
|
||||
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
severity: 'advisory',
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
@@ -588,9 +588,10 @@ function getAntipattern(id) {
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
// `severity` is the canonical registry field. The runtime finding serializer
|
||||
// derives its `advisory: true` compatibility/output flag from this set.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
|
||||
@@ -688,7 +688,10 @@ function enclosingCssSelector(cssText, index) {
|
||||
// `{` belongs to some other selector.
|
||||
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
||||
if (closeBeforeIndex > open) return null;
|
||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
||||
// Ignore delimiters inside comments when locating the previous declaration.
|
||||
// Keeping comment length intact preserves indices into the original source.
|
||||
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
|
||||
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
||||
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
||||
@@ -4020,14 +4023,17 @@ function checkTypography() {
|
||||
}
|
||||
|
||||
if (totalTextElements >= 20) {
|
||||
// A font is "primary" if it's used by at least 15% of text elements
|
||||
const PRIMARY_THRESHOLD = 0.15;
|
||||
for (const [font, count] of fontUsage) {
|
||||
// Report the actual primary face: the uniquely most-used family. The old
|
||||
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const [primary] = ranked;
|
||||
const tied = ranked[1]?.[1] === primary?.[1];
|
||||
if (primary && !tied) {
|
||||
const [font, count] = primary;
|
||||
const share = count / totalTextElements;
|
||||
if (share < PRIMARY_THRESHOLD) continue;
|
||||
if (!OVERUSED_FONTS.has(font)) continue;
|
||||
if (isBrandFontOnOwnDomain(font)) continue;
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,17 +138,20 @@ export const IMMEDIATE_TIER_RULES = new Set([
|
||||
// the agent is never nagged about a taste call a human might make on purpose.
|
||||
// A project opts back in with `.impeccable/config.json`:
|
||||
// { "detector": { "advisoryRules": "include" } }
|
||||
// This set is the hook's own copy of the registry's `advisory: true` rules,
|
||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
||||
// self-contained and testable without loading the detector. Keep it in sync
|
||||
// with the registry (cli/engine/registry/antipatterns.mjs).
|
||||
// This legacy id fallback keeps older detector findings recognizable when they
|
||||
// carry neither the current runtime flag nor the canonical advisory severity.
|
||||
// Current findings are classified by their serialized metadata below.
|
||||
export const ADVISORY_RULES = new Set([
|
||||
'em-dash-overuse',
|
||||
]);
|
||||
|
||||
export function isAdvisoryFinding(finding) {
|
||||
const id = finding && normalizeIgnoreRule(finding.antipattern);
|
||||
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
||||
return Boolean(id && (
|
||||
ADVISORY_RULES.has(id)
|
||||
|| finding.advisory === true
|
||||
|| finding.severity === 'advisory'
|
||||
));
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -52,6 +53,21 @@ function isolatedBrowserFixtureCases(name) {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
function runDetectCli(args) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[path.join(ROOT, 'skill', 'scripts', 'detect.mjs'), ...args],
|
||||
{ cwd: path.join(ROOT, 'tests', 'fixtures'), encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => resolve({
|
||||
code: typeof error?.code === 'number' ? error.code : 0,
|
||||
stdout,
|
||||
stderr,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
// Static server: maps /fixtures/* to tests/fixtures/* and
|
||||
// /js/detect-antipatterns-browser.js to cli/engine/detect-antipatterns-browser.js
|
||||
@@ -370,6 +386,8 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
}
|
||||
assert.doesNotMatch(snippets, /pass-/, `no pass-case cursor should be flagged, got: ${snippets}`);
|
||||
assert.equal(hits.length, 3, `expected 3 blinking-cursor findings, got ${hits.length}: ${snippets}`);
|
||||
assert.equal(hits.every(hit => hit.severity === 'warning'), true, JSON.stringify(hits));
|
||||
assert.equal(hits.some(hit => hit.advisory === true), false, JSON.stringify(hits));
|
||||
});
|
||||
|
||||
it('typography side-by-side: element-level flag cases get regular overlays', async () => {
|
||||
@@ -1346,6 +1364,171 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('CLI expands a joined multi-URL target and attributes both scans', async () => {
|
||||
const first = `${baseUrl}/fixtures/antipatterns/quality.html`;
|
||||
const second = `${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`;
|
||||
const result = await runDetectCli([
|
||||
'--json',
|
||||
'--viewport',
|
||||
'1280x800',
|
||||
`${first} ${second}`,
|
||||
]);
|
||||
assert.equal(result.code, 2, result.stderr);
|
||||
const files = new Set(JSON.parse(result.stdout).map(finding => finding.file));
|
||||
assert.ok(files.has(first), `missing first URL attribution: ${JSON.stringify([...files])}`);
|
||||
assert.ok(files.has(second), `missing second URL attribution: ${JSON.stringify([...files])}`);
|
||||
assert.equal(files.has(`${first} ${second}`), false);
|
||||
});
|
||||
|
||||
it('URL scans read linked CSS, serialize severity advisories, and flag only the dominant font', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await launchBrowser(puppeteer, {
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
try {
|
||||
const linkedPage = await browser.newPage();
|
||||
await linkedPage.goto(`${baseUrl}/fixtures/antipatterns/linked-url-patterns.html`, { waitUntil: 'load' });
|
||||
await linkedPage.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
await linkedPage.evaluate(browserScript);
|
||||
const linkedCssom = await linkedPage.evaluate(() => Array.from(document.styleSheets).map(sheet => ({
|
||||
owner: sheet.ownerNode?.tagName || null,
|
||||
rules: Array.from(sheet.cssRules || []).map(rule => ({
|
||||
cssText: rule.cssText,
|
||||
selectorText: rule.selectorText || null,
|
||||
nested: Array.from(rule.cssRules || []).map(child => ({
|
||||
cssText: child.cssText,
|
||||
selectorText: child.selectorText || null,
|
||||
})),
|
||||
})),
|
||||
})));
|
||||
const linkedFindings = await linkedPage.evaluate(() => window.impeccableDetect({ serialize: true })
|
||||
.flatMap(group => group.findings || []));
|
||||
const containerBackgrounds = await linkedPage.evaluate(async () => {
|
||||
const activeAnimation = document.querySelector('.active-container-animation-reference');
|
||||
const inactiveAnimation = document.querySelector('.inactive-container-animation-reference');
|
||||
const overriddenAnimation = document.querySelector('.overridden-keyframes-animation');
|
||||
const layeredAnimation = document.querySelector('.layered-keyframes-animation');
|
||||
const before = {
|
||||
active: getComputedStyle(activeAnimation).transform,
|
||||
inactive: getComputedStyle(inactiveAnimation).transform,
|
||||
overridden: getComputedStyle(overriddenAnimation).transform,
|
||||
layered: getComputedStyle(layeredAnimation).transform,
|
||||
};
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
return {
|
||||
inactive: getComputedStyle(document.querySelector('.inactive-container-stripes')).backgroundImage,
|
||||
active: getComputedStyle(document.querySelector('.active-container-halo')).backgroundImage,
|
||||
pseudoClass: getComputedStyle(document.querySelector('.inactive-pseudo-stripes')).backgroundImage,
|
||||
activeTransforms: [before.active, getComputedStyle(activeAnimation).transform],
|
||||
inactiveTransforms: [before.inactive, getComputedStyle(inactiveAnimation).transform],
|
||||
overriddenTransforms: [before.overridden, getComputedStyle(overriddenAnimation).transform],
|
||||
layeredTransforms: [before.layered, getComputedStyle(layeredAnimation).transform],
|
||||
activeAnimationKeyframes: activeAnimation.getAnimations()[0]?.effect?.getKeyframes() || [],
|
||||
overriddenAnimationKeyframes: overriddenAnimation.getAnimations()[0]?.effect?.getKeyframes() || [],
|
||||
layeredAnimationKeyframes: layeredAnimation.getAnimations()[0]?.effect?.getKeyframes() || [],
|
||||
};
|
||||
});
|
||||
assert.equal(containerBackgrounds.inactive, 'none');
|
||||
assert.equal(containerBackgrounds.pseudoClass, 'none');
|
||||
assert.match(containerBackgrounds.active, /radial-gradient/i);
|
||||
assert.notEqual(containerBackgrounds.activeTransforms[0], containerBackgrounds.activeTransforms[1]);
|
||||
assert.notEqual(
|
||||
containerBackgrounds.inactiveTransforms[0],
|
||||
containerBackgrounds.inactiveTransforms[1],
|
||||
JSON.stringify(containerBackgrounds),
|
||||
);
|
||||
assert.equal(
|
||||
containerBackgrounds.overriddenTransforms[0],
|
||||
containerBackgrounds.overriddenTransforms[1],
|
||||
JSON.stringify(containerBackgrounds),
|
||||
);
|
||||
assert.equal(
|
||||
containerBackgrounds.activeAnimationKeyframes.some(frame => /translateX\([^)]*%\)/i.test(frame.transform || '')),
|
||||
true,
|
||||
JSON.stringify(containerBackgrounds.activeAnimationKeyframes),
|
||||
);
|
||||
assert.equal(
|
||||
containerBackgrounds.overriddenAnimationKeyframes.some(frame => frame.transform && frame.transform !== 'none'),
|
||||
false,
|
||||
JSON.stringify(containerBackgrounds.overriddenAnimationKeyframes),
|
||||
);
|
||||
assert.notEqual(
|
||||
containerBackgrounds.layeredTransforms[0],
|
||||
containerBackgrounds.layeredTransforms[1],
|
||||
JSON.stringify(containerBackgrounds),
|
||||
);
|
||||
assert.equal(
|
||||
containerBackgrounds.layeredAnimationKeyframes.some(frame => /translateX\([^)]*%\)/i.test(frame.transform || '')),
|
||||
true,
|
||||
JSON.stringify(containerBackgrounds.layeredAnimationKeyframes),
|
||||
);
|
||||
const grids = linkedFindings.filter(finding => finding.type === 'codex-grid-background');
|
||||
assert.equal(grids.length, 1, JSON.stringify({ linkedFindings, linkedCssom }));
|
||||
assert.equal(grids[0].severity, 'advisory');
|
||||
assert.equal(grids[0].advisory, true);
|
||||
assert.equal(
|
||||
linkedFindings.some(finding => finding.type === 'bounce-easing'
|
||||
&& finding.detail === 'cubic-bezier(0.34, 1.56, 0.64, 1)'),
|
||||
true,
|
||||
JSON.stringify({ linkedFindings, linkedCssom }),
|
||||
);
|
||||
const marquees = linkedFindings.filter(finding => finding.type === 'marquee');
|
||||
assert.equal(marquees.length, 4, JSON.stringify({ linkedFindings, linkedCssom }));
|
||||
assert.deepEqual(
|
||||
new Set(marquees.map(finding => finding.detail.match(/^\S+/)?.[0])),
|
||||
new Set([
|
||||
'.linked-marquee',
|
||||
'.active-container-animation-reference',
|
||||
'.inactive-container-animation-reference',
|
||||
'.layered-keyframes-animation',
|
||||
]),
|
||||
);
|
||||
const pulsingDots = linkedFindings.filter(finding => finding.type === 'pulsing-dot');
|
||||
assert.equal(pulsingDots.length, 1, JSON.stringify({ linkedFindings, linkedCssom }));
|
||||
assert.match(pulsingDots[0].detail, /\.linked-pulse-dot/);
|
||||
assert.equal(linkedFindings.some(finding => finding.type === 'radial-halo'), true);
|
||||
assert.equal(
|
||||
linkedFindings.some(finding => finding.type === 'repeating-stripes-gradient'),
|
||||
false,
|
||||
JSON.stringify({ linkedFindings, linkedCssom, containerBackgrounds }),
|
||||
);
|
||||
assert.equal(
|
||||
linkedFindings.some(finding => finding.type === 'gradient-text'),
|
||||
false,
|
||||
JSON.stringify({ linkedFindings, linkedCssom, containerBackgrounds }),
|
||||
);
|
||||
assert.equal(linkedFindings.some(finding => finding.type === 'layout-transition'), false);
|
||||
assert.equal(linkedFindings.some(finding => finding.type === 'ai-color-palette'), false);
|
||||
assert.equal(
|
||||
linkedFindings.some(finding => finding.type === 'organic-clip-path'),
|
||||
true,
|
||||
JSON.stringify({ linkedFindings, linkedCssom }),
|
||||
);
|
||||
await linkedPage.close();
|
||||
|
||||
const fontPage = await browser.newPage();
|
||||
const primary = Array.from({ length: 82 }, (_, i) => `<span class="primary">Primary ${i}</span>`).join('');
|
||||
const secondary = Array.from({ length: 18 }, (_, i) => `<span class="secondary">Secondary ${i}</span>`).join('');
|
||||
await fontPage.setContent(`<!doctype html><style>
|
||||
.primary { font-family: Geist, sans-serif; }
|
||||
.secondary { font-family: "Geist Mono", monospace; }
|
||||
</style><main>${primary}${secondary}</main>`);
|
||||
await fontPage.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
await fontPage.evaluate(browserScript);
|
||||
const fontFindings = await fontPage.evaluate(() => window.impeccableDetect({ serialize: true })
|
||||
.flatMap(group => group.findings || [])
|
||||
.filter(finding => finding.type === 'overused-font'));
|
||||
assert.equal(fontFindings.length, 1, JSON.stringify(fontFindings));
|
||||
assert.match(fontFindings[0].detail, /Primary font: geist \(82% of text\)/i);
|
||||
assert.doesNotMatch(fontFindings[0].detail, /geist mono/i);
|
||||
await fontPage.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Only a real browser reproduces this one: Chrome keeps oklch(), lch(), and
|
||||
// color(srgb ...) verbatim in getComputedStyle output, so a detector that
|
||||
// cannot parse those reads every surface as unset, walks out of the page,
|
||||
|
||||
@@ -2717,23 +2717,30 @@ describe('CLI', () => {
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('Usage:');
|
||||
expect(stdout).toContain('--quiet');
|
||||
expect(stdout).toContain('Human-readable findings go to stderr');
|
||||
expect(stdout).not.toContain('--gpt');
|
||||
expect(stdout).not.toContain('--gemini');
|
||||
});
|
||||
|
||||
test('generated-UI tells run by default in the CLI', () => {
|
||||
test('severity advisory is non-blocking, flagged in JSON, and suppressible', () => {
|
||||
const { stdout, code } = run('--json', path.join(FIXTURES, 'gpt-tells.html'));
|
||||
expect(code).toBe(2);
|
||||
const ids = JSON.parse(stdout).map(f => f.antipattern);
|
||||
expect(code).toBe(0);
|
||||
const findings = JSON.parse(stdout);
|
||||
const ids = findings.map(f => f.antipattern);
|
||||
expect(ids).toContain('gpt-thin-border-wide-shadow');
|
||||
expect(ids).toContain('repeating-stripes-gradient');
|
||||
expect(ids).toContain('codex-grid-background');
|
||||
expect(ids).toContain('theater-slop-phrase');
|
||||
expect(findings.every(f => f.severity === 'advisory' && f.advisory === true)).toBe(true);
|
||||
|
||||
const hidden = run('--json', '--no-advisory', path.join(FIXTURES, 'gpt-tells.html'));
|
||||
expect(hidden.code).toBe(0);
|
||||
expect(JSON.parse(hidden.stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
test('legacy provider flags are accepted as deprecated no-ops', () => {
|
||||
const { stdout, stderr, code } = run('--gpt', '--json', path.join(FIXTURES, 'gpt-tells.html'));
|
||||
expect(code).toBe(2);
|
||||
expect(code).toBe(0);
|
||||
expect(stderr).toContain('--gpt and --gemini are deprecated and ignored');
|
||||
expect(JSON.parse(stdout).some(f => f.antipattern === 'codex-grid-background')).toBe(true);
|
||||
});
|
||||
@@ -2744,14 +2751,30 @@ describe('CLI', () => {
|
||||
expect(stderr).not.toContain('cannot access detect');
|
||||
});
|
||||
|
||||
test('keeps a local path containing spaces as one scan target', () => {
|
||||
const fixture = writeStaticFixture({
|
||||
'page with spaces.html': '<!doctype html><html><body><main><h1>Plain page</h1></main></body></html>',
|
||||
});
|
||||
const file = path.join(fixture.dir, 'page with spaces.html');
|
||||
try {
|
||||
const { stdout, stderr, code } = run('--json', file);
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(stdout)).toEqual([]);
|
||||
expect(stderr).not.toContain('cannot access');
|
||||
} finally {
|
||||
fs.rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('should-pass exits 0', () => {
|
||||
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test('should-flag exits 2 with findings', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'should-flag.html'));
|
||||
const { stdout, code, stderr } = run(path.join(FIXTURES, 'should-flag.html'));
|
||||
expect(code).toBe(2);
|
||||
expect(stdout).toBe('');
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
@@ -2899,7 +2922,7 @@ colors:
|
||||
`);
|
||||
|
||||
const full = runIn(dir, '--json', 'index.css');
|
||||
expect(full.code).toBe(2);
|
||||
expect(full.code).toBe(0);
|
||||
const fullIds = JSON.parse(full.stdout).map((finding) => finding.antipattern);
|
||||
expect(fullIds).toContain('design-system-font-size');
|
||||
expect(fullIds).toContain('design-system-color');
|
||||
|
||||
@@ -10,7 +10,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const cli = path.join(root, 'cli', 'bin', 'cli.js');
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-stdin-dispatch-'));
|
||||
|
||||
function detectStdinFile(filePath) {
|
||||
function detectStdinFile(filePath, expectedStatus = 2) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cli, 'detect', '--json', '--no-config', '--no-design-system'],
|
||||
@@ -19,7 +19,7 @@ function detectStdinFile(filePath) {
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
assert.equal(result.status, expectedStatus, result.stderr);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
@@ -57,9 +57,11 @@ describe('detect CLI stdin file dispatch', () => {
|
||||
}
|
||||
`);
|
||||
|
||||
const findings = detectStdinFile(filePath);
|
||||
const findings = detectStdinFile(filePath, 0);
|
||||
assert.ok(findings.some(
|
||||
(item) => item.file === filePath && item.antipattern === 'codex-grid-background',
|
||||
(item) => item.file === filePath
|
||||
&& item.antipattern === 'codex-grid-background'
|
||||
&& item.advisory === true,
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
@media (min-width: 1px) {
|
||||
[data-token="::before"] {
|
||||
width: 160px;
|
||||
height: 80px;
|
||||
background: linear-gradient(90deg, #d9d9d9 1px, transparent 1px), linear-gradient(180deg, #d9d9d9 1px, transparent 1px);
|
||||
background-size: 72px 72px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Literal pseudo-element text inside an attribute value is data, not selector
|
||||
syntax. This rule has no live match even though an empty-value decoy does. */
|
||||
[data-decoy="::before"] {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* Escaped colons are identifier data, not a legacy pseudo-element. */
|
||||
.\:\:before {
|
||||
width: 240px;
|
||||
height: 160px;
|
||||
clip-path: polygon(2% 4%, 17% 1%, 31% 7%, 47% 3%, 62% 9%, 79% 2%, 96% 13%, 91% 31%, 98% 49%, 89% 68%, 95% 87%, 74% 96%, 51% 91%, 29% 98%, 8% 84%, 3% 61%);
|
||||
}
|
||||
|
||||
/* A valid hostless pseudo-element selector cannot be queried through the DOM
|
||||
selector API. It must remain in the corpus rather than count as unused. */
|
||||
main > ::before {
|
||||
content: "Rendered pseudo-element text";
|
||||
display: block;
|
||||
width: 160px;
|
||||
animation: bounce-linked-pseudo 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce-linked-pseudo {
|
||||
50% { transform: translateY(2px); }
|
||||
}
|
||||
|
||||
.linked-marquee {
|
||||
animation: linked-horizontal-loop 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes linked-horizontal-loop {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.linked-keyframe-overshoot {
|
||||
animation: linked-keyframe-curve 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes linked-keyframe-curve {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
to { transform: translateY(2px); }
|
||||
}
|
||||
|
||||
.overridden-keyframes-animation {
|
||||
animation: overridden-horizontal-loop 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes overridden-horizontal-loop {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* The later same-name definition is the one Chromium renders. */
|
||||
@keyframes overridden-horizontal-loop {
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@layer linked-keyframes-low, linked-keyframes-high;
|
||||
|
||||
.layered-keyframes-animation {
|
||||
animation: layered-horizontal-loop 2s linear infinite;
|
||||
}
|
||||
|
||||
@layer linked-keyframes-high {
|
||||
@keyframes layered-horizontal-loop {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Lower layer appears later in source, but does not override the high layer. */
|
||||
@layer linked-keyframes-low {
|
||||
@keyframes layered-horizontal-loop {
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
}
|
||||
|
||||
.linked-pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: linked-signal-cycle 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes linked-signal-cycle {
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* Chromium makes nested keyframes globally available even while the enclosing
|
||||
container condition is false, so this live reference must still scan. */
|
||||
.inactive-container-animation-reference {
|
||||
animation: inactive-container-horizontal-loop 8s linear infinite;
|
||||
}
|
||||
|
||||
@container (width > 2000px) {
|
||||
@keyframes inactive-container-horizontal-loop {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
}
|
||||
|
||||
.active-container-animation-reference {
|
||||
animation: active-container-horizontal-loop 8s linear infinite;
|
||||
}
|
||||
|
||||
/* The declaration is intentionally outside the container group. */
|
||||
@container (width > 900px) {
|
||||
@keyframes active-container-horizontal-loop {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
}
|
||||
|
||||
/* A pseudo-element whose originating element is absent must remain outside
|
||||
live URL findings instead of being retained as an unresolvable selector. */
|
||||
.absent > ::before {
|
||||
background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px);
|
||||
}
|
||||
|
||||
/* These selectors exist in the live DOM, but their conditions are inactive.
|
||||
URL scans must not treat their declarations as rendered page styles. */
|
||||
@media (max-width: 1px) {
|
||||
.inactive-media-stripes {
|
||||
background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (display: imaginary-layout) {
|
||||
.inactive-supports-stripes {
|
||||
background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* The host exists, but the complete pseudo-class selector is inactive. */
|
||||
.inactive-pseudo-stripes:not(.active) {
|
||||
background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px);
|
||||
}
|
||||
|
||||
.container-query-host {
|
||||
container-type: inline-size;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.container-query-host-active {
|
||||
width: 960px;
|
||||
}
|
||||
|
||||
@container (width > 900px) {
|
||||
.inactive-container-stripes {
|
||||
background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px);
|
||||
}
|
||||
|
||||
.active-container-halo {
|
||||
width: 640px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(80, 111, 255, 0.85), transparent 70%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Non-selector at-rules in an inactive container must not enter page-level
|
||||
pattern scans just because their CSSOM text is readable. */
|
||||
@container (width > 2000px) {
|
||||
@keyframes inactive-container-gradient-text {
|
||||
from {
|
||||
background: linear-gradient(90deg, #111, #999);
|
||||
background-clip: text;
|
||||
}
|
||||
to { background: none; }
|
||||
}
|
||||
}
|
||||
|
||||
.unused-linked-transition {
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Linked URL pattern detection</title>
|
||||
<link rel="stylesheet" href="/fixtures/antipatterns/linked-url-patterns.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Linked stylesheet pattern</h1>
|
||||
<div class="flag-linked-grid" data-token="::before">Rendered decorative grid</div>
|
||||
<div data-decoy="">Empty attribute-value decoy</div>
|
||||
<div class="::before">Escaped identifier selector</div>
|
||||
<div class="linked-marquee">Rendered linked marquee animation</div>
|
||||
<div class="linked-keyframe-overshoot">Rendered linked keyframe easing</div>
|
||||
<div class="overridden-keyframes-animation">Overridden linked keyframes</div>
|
||||
<div class="layered-keyframes-animation">Layer-priority linked keyframes</div>
|
||||
<div class="linked-pulse-dot"></div>
|
||||
<div class="inactive-media-stripes">Inactive media stripes</div>
|
||||
<div class="inactive-supports-stripes">Inactive supports stripes</div>
|
||||
<div class="inactive-pseudo-stripes active">Inactive pseudo-class stripes</div>
|
||||
<div class="container-query-host">
|
||||
<div class="inactive-container-stripes">Inactive container-query stripes</div>
|
||||
<div class="inactive-container-animation-reference">False-container keyframes reference</div>
|
||||
</div>
|
||||
<div class="container-query-host container-query-host-active">
|
||||
<div class="active-container-halo">Active container-query halo</div>
|
||||
<div class="active-container-animation-reference">Active container keyframes reference</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -665,6 +665,7 @@ describe('filterFindings()', () => {
|
||||
const filtered = filterFindings(findings, content, '.ts', {
|
||||
ignoreRules: ['side-tab'],
|
||||
minSeverity: 'error',
|
||||
advisoryRules: 'include',
|
||||
limits: DEFAULT_CONFIG.limits,
|
||||
});
|
||||
assert.deepEqual(filtered.map((f) => f.antipattern), ['gradient-text', 'overused-font']);
|
||||
@@ -674,6 +675,7 @@ describe('filterFindings()', () => {
|
||||
const findings = [
|
||||
finding('side-tab', 1),
|
||||
finding('em-dash-overuse', 2),
|
||||
finding('design-system-radius', 3, { severity: 'advisory' }),
|
||||
finding('gradient-text', 3),
|
||||
];
|
||||
const filtered = filterFindings(findings, '', '.html', {
|
||||
@@ -700,6 +702,7 @@ describe('filterFindings()', () => {
|
||||
assert.ok(ADVISORY_RULES.has('em-dash-overuse'));
|
||||
assert.equal(isAdvisoryFinding(finding('em-dash-overuse', 1)), true);
|
||||
assert.equal(isAdvisoryFinding({ antipattern: 'anything', advisory: true }), true);
|
||||
assert.equal(isAdvisoryFinding({ antipattern: 'anything', severity: 'advisory' }), true);
|
||||
assert.equal(isAdvisoryFinding(finding('side-tab', 1)), false);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user