mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Fix detector URL scans and advisory handling (#709)
* Fix detector URL and advisory handling Recover joined URL arguments without splitting local paths, derive advisory behavior from registry severity across consumers, inspect readable linked CSS in URL scans, and report only the dominant primary font. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Filter linked CSS to rendered selectors Flatten linked stylesheet grouping rules and collect only selector rules that target the live DOM, preventing unused grouped and selector-less patterns from leaking into URL findings. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Fix detector review edge cases AI assistance disclosure: Codex implemented and verified these fixes under maintainer direction. * Preserve unresolved linked CSS selectors AI assistance disclosure: Codex implemented and verified this fix under maintainer direction. * Fix linked CSS selector filtering Resolve pseudo-element selectors to live hosts, reject unresolvable linked CSS findings, and make the regression assertions independent. Also ignore comment delimiters when recovering CSS rule selectors. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Skip unresolved container query CSS Exclude linked container-query groups when their current applicability cannot be resolved, with a browser regression proving inactive styles do not leak. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Detect active container query CSS Use a temporary custom-property probe so the browser decides whether a nested style rule actually applies in the current container layout. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Filter inactive linked CSS states Keep valid empty pseudo-class matches authoritative and omit selector-less linked at-rules that cannot be tied to rendered nodes. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Parse pseudo-elements without rewriting literals Preserve quoted attribute values and escaped identifiers while resolving real pseudo-elements to live hosts. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Restore live linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Handle grouped linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Respect keyframe definition order AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Resolve effective linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Fix keyframe easing detection Serialize effective per-keyframe easing back into the linked stylesheet corpus so overshoot motion is detected. Add a browser regression with a neutral animation name.\n\nAI assistance disclosure: Codex helped implement and test this fix under maintainer direction.
This commit is contained in:
@@ -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).
|
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.
|
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`.
|
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),
|
isHidden: isElementHidden(el),
|
||||||
findings: findings.map(f => {
|
findings: findings.map(f => {
|
||||||
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
||||||
|
const severity = f.severity || ap?.severity || 'warning';
|
||||||
return {
|
return {
|
||||||
type: f.type || f.id,
|
type: f.type || f.id,
|
||||||
category: ap ? ap.category : 'quality',
|
category: ap ? ap.category : 'quality',
|
||||||
severity: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||||
// treated as failures; carry the flag so the overlay/extension can
|
// treated as failures; carry the flag so the overlay/extension can
|
||||||
// render them with the mildest affordance and consumers can filter.
|
// 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,
|
detail: f.detail || f.snippet,
|
||||||
ignoreValue: f.ignoreValue || f.value || '',
|
ignoreValue: f.ignoreValue || f.value || '',
|
||||||
name: ap ? ap.name : (f.type || f.id),
|
name: ap ? ap.name : (f.type || f.id),
|
||||||
@@ -1277,6 +1280,381 @@ if (IS_BROWSER) {
|
|||||||
else groupMap.set(el, [...kept]);
|
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) {
|
function browserFindingsFromMap(groupMap) {
|
||||||
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
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
|
// (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
|
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||||
// ancestor is waived. Selector-less findings stay page-level.
|
// 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;
|
if (!f.selector) return true;
|
||||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
let matches;
|
|
||||||
try {
|
|
||||||
matches = document.querySelectorAll(query);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (matches.length === 0) 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) {
|
if (scopedHtmlFindings.length > 0) {
|
||||||
const mapped = scopedHtmlFindings.map(f => {
|
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
|
// Advisory findings are detected but never treated as failures: they list in a
|
||||||
// separate, visually dimmed section, are excluded from the failure count that
|
// separate, visually dimmed section, are excluded from the failure count that
|
||||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
// 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
|
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||||
// findings.mjs).
|
// findings.mjs).
|
||||||
function isAdvisory(finding) {
|
function isAdvisory(finding) {
|
||||||
return finding && finding.advisory === true;
|
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function partitionAdvisory(findings) {
|
function partitionAdvisory(findings) {
|
||||||
@@ -168,6 +185,10 @@ Advisory findings:
|
|||||||
counted as failures and never changing the exit code. They stay out of the
|
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.
|
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:
|
Project config:
|
||||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||||
@@ -185,7 +206,7 @@ Detection modes:
|
|||||||
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
||||||
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
||||||
URLs Puppeteer full browser rendering (auto-detected;
|
URLs Puppeteer full browser rendering (auto-detected;
|
||||||
http(s):// and file:// URLs)
|
http(s):// and file:// URLs; accessible linked CSS included)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
impeccable detect src/
|
impeccable detect src/
|
||||||
@@ -283,7 +304,7 @@ async function detectCli() {
|
|||||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
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); }
|
if (helpMode) { printUsage(); process.exit(0); }
|
||||||
|
|
||||||
@@ -297,13 +318,12 @@ async function detectCli() {
|
|||||||
// real cascade, real computed styles, real layout. Callers that want a
|
// real cascade, real computed styles, real layout. Callers that want a
|
||||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||||
// instead of the bare path (which stays on the static engine).
|
// instead of the bare path (which stays on the static engine).
|
||||||
const urlRe = /^(?:https?|file):\/\//i;
|
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
|
||||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const target of paths) {
|
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
|
// 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
|
// resolves from that file's project. A remote http(s) URL has no
|
||||||
// local project — it gets base options (no design system), never
|
// 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
|
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||||
// as failures, and skipped by the design hook unless a project opts in.
|
// as failures, and skipped by the design hook unless a project opts in.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
name: 'Em-dash overuse',
|
name: 'Em-dash overuse',
|
||||||
description:
|
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.',
|
'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.
|
// `{` belongs to some other selector.
|
||||||
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
||||||
if (closeBeforeIndex > open) return null;
|
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, ' ');
|
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;
|
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
||||||
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
||||||
@@ -5293,14 +5296,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
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;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
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),
|
isHidden: isElementHidden(el),
|
||||||
findings: findings.map(f => {
|
findings: findings.map(f => {
|
||||||
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
||||||
|
const severity = f.severity || ap?.severity || 'warning';
|
||||||
return {
|
return {
|
||||||
type: f.type || f.id,
|
type: f.type || f.id,
|
||||||
category: ap ? ap.category : 'quality',
|
category: ap ? ap.category : 'quality',
|
||||||
severity: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||||
// treated as failures; carry the flag so the overlay/extension can
|
// treated as failures; carry the flag so the overlay/extension can
|
||||||
// render them with the mildest affordance and consumers can filter.
|
// 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,
|
detail: f.detail || f.snippet,
|
||||||
ignoreValue: f.ignoreValue || f.value || '',
|
ignoreValue: f.ignoreValue || f.value || '',
|
||||||
name: ap ? ap.name : (f.type || f.id),
|
name: ap ? ap.name : (f.type || f.id),
|
||||||
@@ -8175,6 +8184,381 @@ if (IS_BROWSER) {
|
|||||||
else groupMap.set(el, [...kept]);
|
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) {
|
function browserFindingsFromMap(groupMap) {
|
||||||
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
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
|
// (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
|
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||||
// ancestor is waived. Selector-less findings stay page-level.
|
// 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;
|
if (!f.selector) return true;
|
||||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
let matches;
|
|
||||||
try {
|
|
||||||
matches = document.querySelectorAll(query);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (matches.length === 0) 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) {
|
if (scopedHtmlFindings.length > 0) {
|
||||||
const mapped = scopedHtmlFindings.map(f => {
|
const mapped = scopedHtmlFindings.map(f => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||||
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
||||||
import { checkContentHiddenAtRest } from '../../rules/checks.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)
|
// Per-finding severity promotion (e.g. hero-region pulsing dot)
|
||||||
// overrides the registry default carried by finding().
|
// overrides the registry default carried by finding().
|
||||||
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
|
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';
|
} from '../../design-system.mjs';
|
||||||
import { isFullPage } from '../../shared/page.mjs';
|
import { isFullPage } from '../../shared/page.mjs';
|
||||||
import { applyInlineIgnores } from '../../shared/inline-ignores.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 { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||||
import {
|
import {
|
||||||
checkElementBorders,
|
checkElementBorders,
|
||||||
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
|
|||||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||||
// overrides the registry default.
|
// overrides the registry default.
|
||||||
if (f.severity) item.severity = f.severity;
|
if (f.severity) item.severity = f.severity;
|
||||||
findings.push(item);
|
findings.push(deriveAdvisoryFlag(item));
|
||||||
}
|
}
|
||||||
// Text-content analyzers (em-dash overuse, marketing buzzwords,
|
// Text-content analyzers (em-dash overuse, marketing buzzwords,
|
||||||
// numbered section markers, aphoristic cadence) live in the regex
|
// numbered section markers, aphoristic cadence) live in the regex
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ function getAP(id) {
|
|||||||
return getAntipattern(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) {
|
function finding(id, filePath, snippet, line = 0) {
|
||||||
const ap = getAP(id);
|
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 };
|
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)
|
// 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
|
// can partition without a registry lookup. Only stamped when true to keep the
|
||||||
// finding shape stable for the vast majority of rules.
|
// finding shape stable for the vast majority of rules.
|
||||||
if (ap.advisory === true) base.advisory = true;
|
return deriveAdvisoryFlag(base);
|
||||||
return 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
|
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||||
// as failures, and skipped by the design hook unless a project opts in.
|
// as failures, and skipped by the design hook unless a project opts in.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
name: 'Em-dash overuse',
|
name: 'Em-dash overuse',
|
||||||
description:
|
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.',
|
'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:
|
// Advisory rules are detected and reported, but never treated as failures:
|
||||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
// 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.
|
// 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(
|
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) {
|
function isAdvisoryRule(id) {
|
||||||
|
|||||||
@@ -688,7 +688,10 @@ function enclosingCssSelector(cssText, index) {
|
|||||||
// `{` belongs to some other selector.
|
// `{` belongs to some other selector.
|
||||||
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
||||||
if (closeBeforeIndex > open) return null;
|
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, ' ');
|
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;
|
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
||||||
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
||||||
@@ -4020,14 +4023,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
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;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
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.
|
// the agent is never nagged about a taste call a human might make on purpose.
|
||||||
// A project opts back in with `.impeccable/config.json`:
|
// A project opts back in with `.impeccable/config.json`:
|
||||||
// { "detector": { "advisoryRules": "include" } }
|
// { "detector": { "advisoryRules": "include" } }
|
||||||
// This set is the hook's own copy of the registry's `advisory: true` rules,
|
// This legacy id fallback keeps older detector findings recognizable when they
|
||||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
// carry neither the current runtime flag nor the canonical advisory severity.
|
||||||
// self-contained and testable without loading the detector. Keep it in sync
|
// Current findings are classified by their serialized metadata below.
|
||||||
// with the registry (cli/engine/registry/antipatterns.mjs).
|
|
||||||
export const ADVISORY_RULES = new Set([
|
export const ADVISORY_RULES = new Set([
|
||||||
'em-dash-overuse',
|
'em-dash-overuse',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isAdvisoryFinding(finding) {
|
export function isAdvisoryFinding(finding) {
|
||||||
const id = finding && normalizeIgnoreRule(finding.antipattern);
|
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({
|
export const DEFAULT_CONFIG = Object.freeze({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, it, before, after } from 'node:test';
|
import { describe, it, before, after } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -52,6 +53,21 @@ function isolatedBrowserFixtureCases(name) {
|
|||||||
let server;
|
let server;
|
||||||
let baseUrl;
|
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 () => {
|
before(async () => {
|
||||||
// Static server: maps /fixtures/* to tests/fixtures/* and
|
// Static server: maps /fixtures/* to tests/fixtures/* and
|
||||||
// /js/detect-antipatterns-browser.js to cli/engine/detect-antipatterns-browser.js
|
// /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.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.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 () => {
|
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
|
// Only a real browser reproduces this one: Chrome keeps oklch(), lch(), and
|
||||||
// color(srgb ...) verbatim in getComputedStyle output, so a detector that
|
// color(srgb ...) verbatim in getComputedStyle output, so a detector that
|
||||||
// cannot parse those reads every surface as unset, walks out of the page,
|
// cannot parse those reads every surface as unset, walks out of the page,
|
||||||
|
|||||||
@@ -2717,23 +2717,30 @@ describe('CLI', () => {
|
|||||||
expect(code).toBe(0);
|
expect(code).toBe(0);
|
||||||
expect(stdout).toContain('Usage:');
|
expect(stdout).toContain('Usage:');
|
||||||
expect(stdout).toContain('--quiet');
|
expect(stdout).toContain('--quiet');
|
||||||
|
expect(stdout).toContain('Human-readable findings go to stderr');
|
||||||
expect(stdout).not.toContain('--gpt');
|
expect(stdout).not.toContain('--gpt');
|
||||||
expect(stdout).not.toContain('--gemini');
|
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'));
|
const { stdout, code } = run('--json', path.join(FIXTURES, 'gpt-tells.html'));
|
||||||
expect(code).toBe(2);
|
expect(code).toBe(0);
|
||||||
const ids = JSON.parse(stdout).map(f => f.antipattern);
|
const findings = JSON.parse(stdout);
|
||||||
|
const ids = findings.map(f => f.antipattern);
|
||||||
expect(ids).toContain('gpt-thin-border-wide-shadow');
|
expect(ids).toContain('gpt-thin-border-wide-shadow');
|
||||||
expect(ids).toContain('repeating-stripes-gradient');
|
expect(ids).toContain('repeating-stripes-gradient');
|
||||||
expect(ids).toContain('codex-grid-background');
|
expect(ids).toContain('codex-grid-background');
|
||||||
expect(ids).toContain('theater-slop-phrase');
|
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', () => {
|
test('legacy provider flags are accepted as deprecated no-ops', () => {
|
||||||
const { stdout, stderr, code } = run('--gpt', '--json', path.join(FIXTURES, 'gpt-tells.html'));
|
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(stderr).toContain('--gpt and --gemini are deprecated and ignored');
|
||||||
expect(JSON.parse(stdout).some(f => f.antipattern === 'codex-grid-background')).toBe(true);
|
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');
|
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', () => {
|
test('should-pass exits 0', () => {
|
||||||
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
|
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
|
||||||
expect(code).toBe(0);
|
expect(code).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should-flag exits 2 with findings', () => {
|
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(code).toBe(2);
|
||||||
|
expect(stdout).toBe('');
|
||||||
expect(stderr).toContain('side-tab');
|
expect(stderr).toContain('side-tab');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2899,7 +2922,7 @@ colors:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const full = runIn(dir, '--json', 'index.css');
|
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);
|
const fullIds = JSON.parse(full.stdout).map((finding) => finding.antipattern);
|
||||||
expect(fullIds).toContain('design-system-font-size');
|
expect(fullIds).toContain('design-system-font-size');
|
||||||
expect(fullIds).toContain('design-system-color');
|
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 cli = path.join(root, 'cli', 'bin', 'cli.js');
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-stdin-dispatch-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-stdin-dispatch-'));
|
||||||
|
|
||||||
function detectStdinFile(filePath) {
|
function detectStdinFile(filePath, expectedStatus = 2) {
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[cli, 'detect', '--json', '--no-config', '--no-design-system'],
|
[cli, 'detect', '--json', '--no-config', '--no-design-system'],
|
||||||
@@ -19,7 +19,7 @@ function detectStdinFile(filePath) {
|
|||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.equal(result.status, 2, result.stderr);
|
assert.equal(result.status, expectedStatus, result.stderr);
|
||||||
return JSON.parse(result.stdout);
|
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(
|
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', {
|
const filtered = filterFindings(findings, content, '.ts', {
|
||||||
ignoreRules: ['side-tab'],
|
ignoreRules: ['side-tab'],
|
||||||
minSeverity: 'error',
|
minSeverity: 'error',
|
||||||
|
advisoryRules: 'include',
|
||||||
limits: DEFAULT_CONFIG.limits,
|
limits: DEFAULT_CONFIG.limits,
|
||||||
});
|
});
|
||||||
assert.deepEqual(filtered.map((f) => f.antipattern), ['gradient-text', 'overused-font']);
|
assert.deepEqual(filtered.map((f) => f.antipattern), ['gradient-text', 'overused-font']);
|
||||||
@@ -674,6 +675,7 @@ describe('filterFindings()', () => {
|
|||||||
const findings = [
|
const findings = [
|
||||||
finding('side-tab', 1),
|
finding('side-tab', 1),
|
||||||
finding('em-dash-overuse', 2),
|
finding('em-dash-overuse', 2),
|
||||||
|
finding('design-system-radius', 3, { severity: 'advisory' }),
|
||||||
finding('gradient-text', 3),
|
finding('gradient-text', 3),
|
||||||
];
|
];
|
||||||
const filtered = filterFindings(findings, '', '.html', {
|
const filtered = filterFindings(findings, '', '.html', {
|
||||||
@@ -700,6 +702,7 @@ describe('filterFindings()', () => {
|
|||||||
assert.ok(ADVISORY_RULES.has('em-dash-overuse'));
|
assert.ok(ADVISORY_RULES.has('em-dash-overuse'));
|
||||||
assert.equal(isAdvisoryFinding(finding('em-dash-overuse', 1)), true);
|
assert.equal(isAdvisoryFinding(finding('em-dash-overuse', 1)), true);
|
||||||
assert.equal(isAdvisoryFinding({ antipattern: 'anything', advisory: true }), 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);
|
assert.equal(isAdvisoryFinding(finding('side-tab', 1)), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user