diff --git a/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.hermes/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.hermes/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.hermes/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.hermes/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index e5f53b69a..cc36ac9b5 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // 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); + 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 = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -974,10 +1000,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 +1013,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'); }