diff --git a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.grok/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.grok/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.grok/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.grok/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.hermes/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.hermes/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.hermes/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.hermes/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.vibe/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.vibe/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.vibe/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.vibe/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals