From 2b88aa523151b40f0b4065373f2dbf228142c0e2 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Tue, 25 Aug 2026 19:34:55 +0500 Subject: [PATCH 1/2] Fix: resolve root-relative linked stylesheets in static detect (#652) Root-relative hrefs like /static/app.css were treated as OS-absolute and silently dropped, hiding contrast findings. AI assistance: implemented with Cursor Grok 4.6. Co-authored-by: Cursor --- .../engines/static-html/css-cascade.mjs | 41 +++++++++++-- tests/detect-antipatterns.test.js | 60 +++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs index e5f53b69a..be46ceb57 100644 --- a/cli/engine/engines/static-html/css-cascade.mjs +++ b/cli/engine/engines/static-html/css-cascade.mjs @@ -964,6 +964,30 @@ function buildStaticWindow(staticDoc) { }; } +const warnedMissingStylesheets = new Set(); + +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + const rel = stripped.replace(/^\/+/, ''); + let dir = fileDir; + for (;;) { + const parent = path.dirname(dir); + if (parent === dir) break; // never use the filesystem root as document root + try { + const candidate = path.join(dir, rel); + if (fs.statSync(candidate).isFile()) return candidate; + } catch { /* missing or unreadable candidate */ } + // Stop at the project root so a coincidental ~/static/app.css cannot win. + try { + if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) break; + } catch { /* unreadable marker */ } + dir = parent; + } + return path.join(fileDir, rel); +} + function collectStaticCssText(root, fileDir, profile, filePath, modules) { const styleTexts = []; for (const styleEl of modules.selectAll('style', root.children || [])) { @@ -974,10 +998,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) { const rel = link.attribs?.rel || ''; const href = link.attribs?.href || ''; if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue; - // Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a - // literal path with the query in it; a versioned link otherwise made the - // whole stylesheet invisible to every element-level check. - const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]); + // Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs + // must not resolve as OS-absolute paths; otherwise the whole stylesheet is + // invisible to every element-level check. + const cssPath = resolveLinkedCssPath(fileDir, href); try { const css = profileStep(profile, { engine: 'static-html', @@ -987,7 +1011,14 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) { detail: href, }, () => fs.readFileSync(cssPath, 'utf-8')); styleTexts.push(css); - } catch { /* skip unreadable */ } + } catch { + if (!warnedMissingStylesheets.has(cssPath)) { + warnedMissingStylesheets.add(cssPath); + process.stderr.write( + `impeccable detect: could not read linked stylesheet ${href} (resolved to ${cssPath}); color and custom-property rules will be incomplete\n` + ); + } + } } return styleTexts.join('\n'); } diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 751276665..eba5efb4d 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -1321,6 +1321,66 @@ describe('detectHtml — static HTML/CSS engine', () => { expect(findingIds(f)).toContain('side-tab'); }); + test('resolves root-relative linked stylesheets with cache-busting query', async () => { + await withStaticFixture({ + 'index.html': ` + +
Card
`, + 'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }', + }, async ({ file }) => { + const f = await detectHtml(file); + expect(findingIds(f)).toContain('side-tab'); + }); + }); + + test('resolves root-relative linked stylesheets from nested pages via ancestor walk', async () => { + await withStaticFixture({ + 'pages/about.html': ` + +
Card
`, + 'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }', + }, async ({ dir }) => { + const f = await detectHtml(path.join(dir, 'pages', 'about.html')); + expect(findingIds(f)).toContain('side-tab'); + }); + }); + + test('does not resolve root-relative sheets above the project root', async () => { + await withStaticFixture({ + 'project/package.json': '{}', + 'project/index.html': ` + +
Card
`, + 'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }', + }, async ({ dir }) => { + const f = await detectHtml(path.join(dir, 'project', 'index.html')); + expect(findingIds(f)).not.toContain('side-tab'); + }); + }); + + test('warns when a linked stylesheet cannot be read', async () => { + const writes = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk, ...args) => { + writes.push(String(chunk)); + return origWrite(chunk, ...args); + }; + try { + await withStaticFixture({ + 'index.html': ` + +
Page
`, + }, async ({ file, dir }) => { + await detectHtml(file); + const msg = writes.join(''); + expect(msg).toContain('could not read linked stylesheet /missing/app.css'); + expect(msg).toContain(`resolved to ${path.join(dir, 'missing', 'app.css')}`); + }); + } finally { + process.stderr.write = origWrite; + } + }); + test('gradient-text: a style="" attribute alone carries the page-level flag', async () => { await withStaticFixture({ 'index.html': `t From daae1d4117fa98637f3a3e2c660f3bd98f4db58c Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Tue, 25 Aug 2026 19:47:54 +0500 Subject: [PATCH 2/2] Fix: reject root-relative .. segments and warn per scan Dot-segment hrefs like /../outside.css could leave the project, and a process-wide warning set hid missing-sheet notices on later detectHtml calls. AI assistance: implemented with Cursor Grok 4.6. Co-authored-by: Cursor --- cli/engine/engines/static-html/css-cascade.mjs | 8 +++++--- tests/detect-antipatterns.test.js | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs index be46ceb57..cc36ac9b5 100644 --- a/cli/engine/engines/static-html/css-cascade.mjs +++ b/cli/engine/engines/static-html/css-cascade.mjs @@ -964,13 +964,14 @@ function buildStaticWindow(staticDoc) { }; } -const warnedMissingStylesheets = new Set(); - function resolveLinkedCssPath(fileDir, href) { const stripped = href.split(/[?#]/)[0]; const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); if (!rootRelative) return path.resolve(fileDir, stripped); - const rel = stripped.replace(/^\/+/, ''); + // Drop "." and reject ".." so /../outside.css cannot walk out of dir. + const segments = stripped.replace(/^\/+/, '').split(/[/\\]/).filter(p => p && p !== '.'); + if (segments.some(p => p === '..')) return path.join(fileDir, segments.filter(p => p !== '..').join(path.sep)); + const rel = segments.join(path.sep); let dir = fileDir; for (;;) { const parent = path.dirname(dir); @@ -990,6 +991,7 @@ function resolveLinkedCssPath(fileDir, href) { function collectStaticCssText(root, fileDir, profile, filePath, modules) { const styleTexts = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index eba5efb4d..66f6f96d7 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -1358,6 +1358,19 @@ describe('detectHtml — static HTML/CSS engine', () => { }); }); + test('does not follow root-relative .. segments out of the page directory', async () => { + await withStaticFixture({ + 'project/package.json': '{}', + 'project/index.html': ` + +
Card
`, + 'outside.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }', + }, async ({ dir }) => { + const f = await detectHtml(path.join(dir, 'project', 'index.html')); + expect(findingIds(f)).not.toContain('side-tab'); + }); + }); + test('warns when a linked stylesheet cannot be read', async () => { const writes = []; const origWrite = process.stderr.write.bind(process.stderr); @@ -1371,9 +1384,11 @@ describe('detectHtml — static HTML/CSS engine', () => {
Page
`, }, async ({ file, dir }) => { + await detectHtml(file); await detectHtml(file); const msg = writes.join(''); - expect(msg).toContain('could not read linked stylesheet /missing/app.css'); + const hits = msg.split('could not read linked stylesheet /missing/app.css').length - 1; + expect(hits).toBe(2); expect(msg).toContain(`resolved to ${path.join(dir, 'missing', 'app.css')}`); }); } finally {