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.
This commit is contained in:
Paul Bakaus
2026-09-02 09:51:02 -07:00
parent 0fa4202ca9
commit 95b28ed6d7
6 changed files with 53 additions and 31 deletions
+16 -7
View File
@@ -1287,8 +1287,18 @@ if (IS_BROWSER) {
const exact = Array.from(root.querySelectorAll(raw));
if (exact.length > 0) return exact;
} catch { /* Dynamic/unsupported pseudos get the fallback below. */ }
// 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.
const fallback = raw
.replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '')
.replace(/(^|[\s>+~,])::[a-zA-Z-]+(\([^)]*\))?/g, '$1*')
.replace(/::[a-zA-Z-]+(\([^)]*\))?/g, '')
.replace(/:[a-zA-Z-]+(\([^)]*\))?/g, '')
.trim()
.replace(/,\s*(?=,|$)/g, '');
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
@@ -1333,11 +1343,10 @@ if (IS_BROWSER) {
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// A null result means the DOM selector API cannot resolve the CSSOM
// selector (commonly a hostless pseudo-element), not that it is
// unused. Keep those valid stylesheet rules; only a definitive empty
// result proves that no live element can receive the declaration.
if (matches === null || matches.length > 0) parts.push(cssText);
// 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) parts.push(cssText);
continue;
}
let nested = [];
@@ -1758,7 +1767,7 @@ if (IS_BROWSER) {
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return true;
if (!matches) return false;
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
});
+20 -8
View File
@@ -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`
@@ -8188,8 +8191,18 @@ if (IS_BROWSER) {
const exact = Array.from(root.querySelectorAll(raw));
if (exact.length > 0) return exact;
} catch { /* Dynamic/unsupported pseudos get the fallback below. */ }
// 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.
const fallback = raw
.replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '')
.replace(/(^|[\s>+~,])::[a-zA-Z-]+(\([^)]*\))?/g, '$1*')
.replace(/::[a-zA-Z-]+(\([^)]*\))?/g, '')
.replace(/:[a-zA-Z-]+(\([^)]*\))?/g, '')
.trim()
.replace(/,\s*(?=,|$)/g, '');
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
@@ -8234,11 +8247,10 @@ if (IS_BROWSER) {
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// A null result means the DOM selector API cannot resolve the CSSOM
// selector (commonly a hostless pseudo-element), not that it is
// unused. Keep those valid stylesheet rules; only a definitive empty
// result proves that no live element can receive the declaration.
if (matches === null || matches.length > 0) parts.push(cssText);
// 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) parts.push(cssText);
continue;
}
let nested = [];
@@ -8659,7 +8671,7 @@ if (IS_BROWSER) {
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return true;
if (!matches) return false;
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
});
+4 -1
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`
+5 -5
View File
@@ -1405,16 +1405,16 @@ describe('detectUrl — browser-only fixtures', () => {
})));
const linkedFindings = await linkedPage.evaluate(() => window.impeccableDetect({ serialize: true })
.flatMap(group => group.findings || []));
const stripes = linkedFindings.filter(finding => finding.type === 'repeating-stripes-gradient');
assert.equal(stripes.length, 1, JSON.stringify({ linkedFindings, linkedCssom }));
assert.equal(stripes[0].severity, 'advisory');
assert.equal(stripes[0].advisory, true);
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'),
true,
JSON.stringify({ linkedFindings, linkedCssom }),
);
assert.equal(linkedFindings.some(finding => finding.type === 'codex-grid-background'), false);
assert.equal(linkedFindings.some(finding => finding.type === 'repeating-stripes-gradient'), false);
assert.equal(linkedFindings.some(finding => finding.type === 'layout-transition'), false);
await linkedPage.close();
+7 -9
View File
@@ -1,8 +1,9 @@
@media (min-width: 1px) {
.flag-linked-stripes {
.flag-linked-grid {
width: 160px;
height: 80px;
background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px);
background: linear-gradient(90deg, #d9d9d9 1px, transparent 1px), linear-gradient(180deg, #d9d9d9 1px, transparent 1px);
background-size: 72px 72px;
}
}
@@ -19,13 +20,10 @@ main > ::before {
50% { transform: translateY(2px); }
}
/* Shipped but unused selectors must remain outside live URL findings, even
inside grouping rules or when their check does not serialize a selector. */
@supports (display: grid) {
.unused-linked-grid {
background: linear-gradient(90deg, #d9d9d9 1px, transparent 1px), linear-gradient(180deg, #d9d9d9 1px, transparent 1px);
background-size: 72px 72px;
}
/* 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.
+1 -1
View File
@@ -8,7 +8,7 @@
<body>
<main>
<h1>Linked stylesheet pattern</h1>
<div class="flag-linked-stripes">Rendered repeating stripes</div>
<div class="flag-linked-grid">Rendered decorative grid</div>
<div class="inactive-media-stripes">Inactive media stripes</div>
<div class="inactive-supports-stripes">Inactive supports stripes</div>
</main>