From ddb609936a1ff5e0dfcb4e2bfe1dd2f693221599 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Sat, 22 Aug 2026 06:49:20 +0500 Subject: [PATCH] Fix: keep comment blanking out of script strings, preprocessor //, and Astro fences Naive HTML/CSS comment regexes were swallowing live markup between script-string delimiters, SCSS/Sass/Less line comments still reached the matchers, and indexOf treated --- inside a frontmatter template literal as the closing fence. Prepared with AI assistance. Co-authored-by: Cursor --- cli/engine/engines/regex/detect-text.mjs | 107 +++++++++++++++++++++-- tests/detect-antipatterns.test.js | 56 ++++++++++++ 2 files changed, 157 insertions(+), 6 deletions(-) diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 1545ea060..95cb2fd03 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -261,20 +261,115 @@ function blankHtmlComments(text) { return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + stripCssComments(blankHtmlComments(text.slice(lastIndex))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + 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 === '/' && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + 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; + } + cursor++; + } + return -1; +} + function blankAstroFrontmatterComments(text) { - if (!text.startsWith('---')) return text; - const close = text.indexOf('\n---', 3); + 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)) { - let next = stripCssComments(blankHtmlComments(text)); - if (ext === '.astro') next = blankAstroFrontmatterComments(next); - return next; + 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); } - if (STYLESHEET_EXTS.has(ext)) return stripCssComments(text); return text; } diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index b78b51a0f..9f04c86dd 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -495,6 +495,62 @@ describe('detectText — broken images in source comments', () => { expect(broken).toHaveLength(1); expect(broken[0].line).toBe(6); }); + + test('does not treat comment markers inside script strings as markup comments', () => { + const htmlDelimiters = [ + '', + '', + '', + ].join('\n'); + const cssDelimiters = [ + '', + '', + '', + ].join('\n'); + + for (const filePath of ['hero.astro', 'hero.vue', 'hero.svelte']) { + expect(detectText(htmlDelimiters, filePath).filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + expect(detectText(cssDelimiters, filePath).filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + } + }); + + test('ignores preprocessor line comments in stylesheets', () => { + const source = '// font-family: Inter\n.hero { color: red; }'; + + for (const filePath of ['hero.scss', 'hero.sass', 'hero.less']) { + expect(detectText(source, filePath).filter(r => r.antipattern === 'overused-font')).toHaveLength(0); + } + }); + + test('still detects live font-family after a preprocessor line comment', () => { + const source = '// skip this\n.hero { font-family: Inter; }'; + + const findings = detectText(source, 'hero.scss').filter(r => r.antipattern === 'overused-font'); + expect(findings).toHaveLength(1); + expect(findings[0].line).toBe(2); + }); + + test('does not blank https URLs in SCSS', () => { + const source = '.hero { background: url(https://example.com/i.png); }\n.hero { font-family: Inter; }'; + + const findings = detectText(source, 'hero.scss').filter(r => r.antipattern === 'overused-font'); + expect(findings).toHaveLength(1); + expect(findings[0].line).toBe(2); + }); + + test('ignores frontmatter comments after a --- line inside a template literal', () => { + const source = [ + '---', + 'const md = `', + '---', + '`;', + '// Comment-only image', + '---', + '
ok
', + ].join('\n'); + + expect(detectText(source, 'hero.astro').filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); }); describe('detectText — CSS borders', () => {