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