mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 00:56:30 +03:00
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.
This commit is contained in:
@@ -1277,23 +1277,67 @@ if (IS_BROWSER) {
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function selectorNodesForLiveDom(root, selector) {
|
||||
const raw = String(selector || '').trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const exact = Array.from(root.querySelectorAll(raw));
|
||||
if (exact.length > 0) return exact;
|
||||
} catch { /* Dynamic/unsupported pseudos get the fallback below. */ }
|
||||
const fallback = raw
|
||||
.replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '')
|
||||
.trim()
|
||||
.replace(/,\s*(?=,|$)/g, '');
|
||||
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
|
||||
try { return Array.from(root.querySelectorAll(fallback)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// 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. Same-origin CSS and readable CORS sheets participate; browser
|
||||
// security exceptions for cross-origin sheets are expected and skipped.
|
||||
// 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 appendRules = (rules) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) {
|
||||
appendSheet(rule.styleSheet);
|
||||
continue;
|
||||
}
|
||||
const cssText = rule.cssText || '';
|
||||
if (rule.selectorText) {
|
||||
if (selectorNodesForLiveDom(document, rule.selectorText)?.length > 0) parts.push(cssText);
|
||||
continue;
|
||||
}
|
||||
let nested = [];
|
||||
let hasNestedRules = false;
|
||||
try {
|
||||
const ruleList = rule.cssRules;
|
||||
hasNestedRules = ruleList != null;
|
||||
nested = Array.from(ruleList || []);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const isKeyframes = /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
||||
if (hasNestedRules && !isKeyframes) {
|
||||
appendRules(nested);
|
||||
continue;
|
||||
}
|
||||
if (cssText) parts.push(cssText);
|
||||
}
|
||||
};
|
||||
const appendSheet = (sheet) => {
|
||||
if (!sheet || seen.has(sheet)) return;
|
||||
seen.add(sheet);
|
||||
let rules;
|
||||
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
|
||||
catch { return; }
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) appendSheet(rule.styleSheet);
|
||||
else if (rule.cssText) parts.push(rule.cssText);
|
||||
}
|
||||
appendRules(rules);
|
||||
};
|
||||
let sheets;
|
||||
try { sheets = Array.from(document.styleSheets || []); }
|
||||
@@ -1686,16 +1730,10 @@ if (IS_BROWSER) {
|
||||
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 true;
|
||||
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 => {
|
||||
|
||||
@@ -8178,23 +8178,67 @@ if (IS_BROWSER) {
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function selectorNodesForLiveDom(root, selector) {
|
||||
const raw = String(selector || '').trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const exact = Array.from(root.querySelectorAll(raw));
|
||||
if (exact.length > 0) return exact;
|
||||
} catch { /* Dynamic/unsupported pseudos get the fallback below. */ }
|
||||
const fallback = raw
|
||||
.replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '')
|
||||
.trim()
|
||||
.replace(/,\s*(?=,|$)/g, '');
|
||||
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
|
||||
try { return Array.from(root.querySelectorAll(fallback)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// 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. Same-origin CSS and readable CORS sheets participate; browser
|
||||
// security exceptions for cross-origin sheets are expected and skipped.
|
||||
// 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 appendRules = (rules) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) {
|
||||
appendSheet(rule.styleSheet);
|
||||
continue;
|
||||
}
|
||||
const cssText = rule.cssText || '';
|
||||
if (rule.selectorText) {
|
||||
if (selectorNodesForLiveDom(document, rule.selectorText)?.length > 0) parts.push(cssText);
|
||||
continue;
|
||||
}
|
||||
let nested = [];
|
||||
let hasNestedRules = false;
|
||||
try {
|
||||
const ruleList = rule.cssRules;
|
||||
hasNestedRules = ruleList != null;
|
||||
nested = Array.from(ruleList || []);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const isKeyframes = /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
||||
if (hasNestedRules && !isKeyframes) {
|
||||
appendRules(nested);
|
||||
continue;
|
||||
}
|
||||
if (cssText) parts.push(cssText);
|
||||
}
|
||||
};
|
||||
const appendSheet = (sheet) => {
|
||||
if (!sheet || seen.has(sheet)) return;
|
||||
seen.add(sheet);
|
||||
let rules;
|
||||
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
|
||||
catch { return; }
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) appendSheet(rule.styleSheet);
|
||||
else if (rule.cssText) parts.push(rule.cssText);
|
||||
}
|
||||
appendRules(rules);
|
||||
};
|
||||
let sheets;
|
||||
try { sheets = Array.from(document.styleSheets || []); }
|
||||
@@ -8587,16 +8631,10 @@ if (IS_BROWSER) {
|
||||
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 true;
|
||||
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 => {
|
||||
|
||||
@@ -1390,13 +1390,25 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
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 stripes = linkedFindings.filter(finding => finding.type === 'repeating-stripes-gradient');
|
||||
assert.equal(stripes.length, 1, JSON.stringify(linkedFindings));
|
||||
assert.equal(stripes.length, 1, JSON.stringify({ linkedFindings, linkedCssom }));
|
||||
assert.equal(stripes[0].severity, 'advisory');
|
||||
assert.equal(stripes[0].advisory, true);
|
||||
assert.equal(linkedFindings.some(finding => finding.type === 'codex-grid-background'), false);
|
||||
assert.equal(linkedFindings.some(finding => finding.type === 'layout-transition'), false);
|
||||
await linkedPage.close();
|
||||
|
||||
const fontPage = await browser.newPage();
|
||||
|
||||
+17
-8
@@ -1,11 +1,20 @@
|
||||
.flag-linked-stripes {
|
||||
width: 160px;
|
||||
height: 80px;
|
||||
background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px);
|
||||
@media (min-width: 1px) {
|
||||
.flag-linked-stripes {
|
||||
width: 160px;
|
||||
height: 80px;
|
||||
background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* A shipped but unused selector must remain outside live URL findings. */
|
||||
.unused-linked-grid {
|
||||
background: linear-gradient(90deg, #d9d9d9 1px, transparent 1px), linear-gradient(180deg, #d9d9d9 1px, transparent 1px);
|
||||
background-size: 72px 72px;
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
.unused-linked-transition {
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user