diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index b88ebf0b8..0b88cdb63 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) { return !ext || PAGE_ANALYZER_EXTS.has(ext); } +const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +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']); + +function isInsideOpeningJsxTag(source) { + const tagStart = source.lastIndexOf('<'); + if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false; + + let quote = ''; + for (let cursor = tagStart + 1; cursor < source.length; cursor++) { + const char = source[cursor]; + if (quote) { + if (char === '\\') cursor++; + else if (char === quote) quote = ''; + } else if (char === "'" || char === '"') { + quote = char; + } else if (char === '>') { + return false; + } + } + return true; +} + +/** + * Blank JavaScript comments without moving any following source. Regex + * findings keep their original line numbers, while prose examples inside + * comments cannot masquerade as rendered markup. + */ +function stripJsComments(content, options = {}) { + let state = 'code'; + let output = ''; + let lastSignificant = ''; + let previousSignificant = ''; + let antePreviousSignificant = ''; + let currentWord = ''; + let currentWordPrefix = ''; + let wordSeparated = false; + let regexCharClass = false; + let jsxExpressionDepth = 0; + let lastClosedBraceKind = ''; + const braceKinds = []; + const templateExpressionDepths = []; + + const braceKind = (startsJsxExpression = false) => ( + !startsJsxExpression && ( + !lastSignificant || + lastSignificant === ')' || + lastSignificant === ';' || + lastSignificant === '}' || + (previousSignificant === '=' && lastSignificant === '>') || + BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord) + ) ? 'block' : 'expression' + ); + + const recordSignificant = (char) => { + if (/\s/.test(char)) { + wordSeparated = true; + return; + } + const isWordChar = /[\w$]/.test(char); + if (isWordChar && (wordSeparated || !currentWord)) { + currentWord = ''; + currentWordPrefix = lastSignificant; + } else if (!isWordChar) { + currentWordPrefix = ''; + } + wordSeparated = false; + antePreviousSignificant = previousSignificant; + previousSignificant = lastSignificant; + lastSignificant = char; + currentWord = isWordChar ? currentWord + char : ''; + }; + + for (let i = 0; i < content.length; i++) { + const char = content[i]; + const next = content[i + 1]; + + if (state === 'line-comment') { + if (char === '\n') { + output += char; + state = 'code'; + } else { + output += ' '; + } + continue; + } + + if (state === 'block-comment') { + if (char === '*' && next === '/') { + output += ' '; + i++; + state = 'code'; + } else { + output += char === '\n' ? '\n' : ' '; + } + continue; + } + + if (state === 'regex') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if (char === '[') { + regexCharClass = true; + } else if (char === ']') { + regexCharClass = false; + } else if (char === '/' && !regexCharClass) { + state = 'code'; + recordSignificant('/'); + } + continue; + } + + if (state === 'template' && char === '$' && next === '{') { + output += '${'; + i++; + recordSignificant('$'); + recordSignificant('{'); + templateExpressionDepths.push(1); + braceKinds.push('expression'); + if (jsxExpressionDepth) jsxExpressionDepth++; + state = 'code'; + continue; + } + + if (state !== 'code') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ( + (state === 'single-quote' && char === "'") || + (state === 'double-quote' && char === '"') || + (state === 'template' && char === '`') + ) { + state = 'code'; + recordSignificant(char); + } + continue; + } + + const jsxUrlSeparator = options.jsx && char === '/' && next === '/' && + jsxExpressionDepth === 0 && + (output.endsWith('http:') || + output.endsWith('https:') || + (/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) && + /^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2)))); + const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') && + previousSignificant === lastSignificant && + antePreviousSignificant !== lastSignificant; + if (char === '/' && next === '/' && jsxUrlSeparator) { + output += '//'; + i++; + recordSignificant('/'); + recordSignificant('/'); + } else if (char === '/' && next === '/') { + output += ' '; + i++; + state = 'line-comment'; + } else if (char === '/' && next === '*') { + output += ' '; + i++; + state = 'block-comment'; + } else if (templateExpressionDepths.length && char === '{') { + output += char; + templateExpressionDepths[templateExpressionDepths.length - 1]++; + braceKinds.push(braceKind()); + if (jsxExpressionDepth) jsxExpressionDepth++; + recordSignificant(char); + } else if (templateExpressionDepths.length && char === '}') { + output += char; + const depthIndex = templateExpressionDepths.length - 1; + templateExpressionDepths[depthIndex]--; + lastClosedBraceKind = braceKinds.pop() || ''; + if (jsxExpressionDepth) jsxExpressionDepth--; + recordSignificant(char); + if (templateExpressionDepths[depthIndex] === 0) { + templateExpressionDepths.pop(); + state = 'template'; + } + } else if ( + char === '/' && + (!lastSignificant || + (/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) || + (lastSignificant === '}' && lastClosedBraceKind === 'block') || + (previousSignificant === '=' && lastSignificant === '>') || + (currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord))) + ) { + output += char; + state = 'regex'; + regexCharClass = false; + } else { + output += char; + const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 && + (/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) || + isInsideOpeningJsxTag(output.slice(0, -1))); + if (char === '{') braceKinds.push(braceKind(startsJsxExpression)); + else if (char === '}') lastClosedBraceKind = braceKinds.pop() || ''; + if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++; + else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--; + recordSignificant(char); + if (char === "'") state = 'single-quote'; + else if (char === '"') state = 'double-quote'; + else if (char === '`') state = 'template'; + } + } + + return output; +} + +function stripCssComments(content) { + return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) { const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']); +function findQuotedStringEnd(content, start, quote) { + for (let cursor = start + 1; cursor < content.length; cursor++) { + if (content[cursor] === '\\') cursor++; + else if (content[cursor] === quote) return cursor; + } + return -1; +} + +function findRegexLiteralEnd(content, start) { + let inCharacterClass = false; + for (let cursor = start + 1; cursor < content.length; cursor++) { + const char = content[cursor]; + if (char === '\\') { + cursor++; + } else if (char === '[') { + inCharacterClass = true; + } else if (char === ']') { + inCharacterClass = false; + } else if (char === '/' && !inCharacterClass) { + while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++; + return cursor; + } else if (char === '\n' || char === '\r') { + return -1; + } + } + return -1; +} + +function findTemplateExpressionEnd(content, start) { + let depth = 1; + let lastSignificant = ''; + let previousSignificant = ''; + let antePreviousSignificant = ''; + let currentWord = ''; + let currentWordPrefix = ''; + let wordSeparated = false; + let lastClosedBraceKind = ''; + const braceKinds = []; + + const braceKind = () => ( + lastSignificant === ')' || + lastSignificant === ';' || + lastSignificant === '}' || + (previousSignificant === '=' && lastSignificant === '>') || + BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord) + ? 'block' + : 'expression' + ); + + const recordSignificant = (char) => { + if (/\s/.test(char)) { + wordSeparated = true; + return; + } + const isWordChar = /[\w$]/.test(char); + if (isWordChar && (wordSeparated || !currentWord)) { + currentWord = ''; + currentWordPrefix = lastSignificant; + } else if (!isWordChar) { + currentWordPrefix = ''; + } + wordSeparated = false; + antePreviousSignificant = previousSignificant; + previousSignificant = lastSignificant; + lastSignificant = char; + currentWord = isWordChar ? currentWord + char : ''; + }; + + for (let cursor = start; cursor < content.length; cursor++) { + const char = content[cursor]; + const next = content[cursor + 1]; + const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') && + previousSignificant === lastSignificant && + antePreviousSignificant !== lastSignificant; + if (char === "'" || char === '"') { + cursor = findQuotedStringEnd(content, cursor, char); + if (cursor === -1) return -1; + recordSignificant(')'); + } else if (char === '/' && next === '/') { + const lineEnd = content.indexOf('\n', cursor + 2); + if (lineEnd === -1) return -1; + cursor = lineEnd; + } else if (char === '/' && next === '*') { + const commentEnd = content.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 1; + } else if ( + char === '/' && + (!lastSignificant || + (/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) || + (lastSignificant === '}' && lastClosedBraceKind === 'block') || + (previousSignificant === '=' && lastSignificant === '>') || + (currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord))) + ) { + cursor = findRegexLiteralEnd(content, cursor); + if (cursor === -1) return -1; + recordSignificant(')'); + } else if (char === '`') { + cursor = findTemplateLiteralEnd(content, cursor); + if (cursor === -1) return -1; + recordSignificant(')'); + } else if (char === '{') { + depth++; + braceKinds.push(braceKind()); + recordSignificant(char); + } else if (char === '}') { + depth--; + if (depth === 0) return cursor; + lastClosedBraceKind = braceKinds.pop() || ''; + recordSignificant(char); + } else { + recordSignificant(char); + } + } + return -1; +} + +function findTemplateLiteralEnd(content, start) { + for (let cursor = start + 1; cursor < content.length; cursor++) { + const char = content[cursor]; + if (char === '\\') { + cursor++; + } else if (char === '`') { + return cursor; + } else if (char === '$' && content[cursor + 1] === '{') { + cursor = findTemplateExpressionEnd(content, cursor + 2); + if (cursor === -1) return -1; + } + } + return -1; +} + +function findCSSinJSTemplates(content) { + const templates = []; + const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g; + let match; + while ((match = tagRe.exec(content)) !== null) { + let cursor = match.index + match[0].length; + while (/\s/.test(content[cursor] || '')) cursor++; + + if (content[cursor] === '<') { + let depth = 0; + while (cursor < content.length) { + const char = content[cursor]; + if (char === '<') depth++; + else if (char === '>' && content[cursor - 1] !== '=') depth--; + cursor++; + if (depth === 0) break; + } + if (depth !== 0) continue; + while (/\s/.test(content[cursor] || '')) cursor++; + } + + if (content[cursor] !== '`') continue; + const contentStart = cursor + 1; + cursor = findTemplateLiteralEnd(content, cursor); + if (cursor === -1) continue; + + templates.push({ + tagStart: match.index, + contentStart, + contentEnd: cursor, + }); + tagRe.lastIndex = cursor + 1; + } + return templates; +} + function extractCSSinJS(content, ext) { ext = ext.toLowerCase(); if (!CSS_IN_JS_EXTENSIONS.has(ext)) return []; - const blocks = []; - const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g; - let m; - while ((m = re.exec(content)) !== null) { - const before = content.substring(0, m.index); + return findCSSinJSTemplates(content).map((template) => { + const before = content.substring(0, template.tagStart); const startLine = before.split('\n').length; - blocks.push({ content: m[1], startLine }); + return { + content: content.slice(template.contentStart, template.contentEnd), + startLine, + }; + }); +} + +function stripCssInJsComments(content, ext) { + if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content; + const templates = findCSSinJSTemplates(content); + let output = ''; + let cursor = 0; + for (const template of templates) { + output += content.slice(cursor, template.contentStart); + output += stripCssComments(content.slice(template.contentStart, template.contentEnd)); + cursor = template.contentEnd; } - return blocks; + return output + content.slice(cursor); } function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) { @@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) { function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; - const lines = content.split('\n'); const ext = extFromFilePath(filePath); + const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { + jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', + }) : content; + 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 @@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) { phase: 'source', ruleId: 'codex-grid-background', target: filePath, - }, () => scanCssTextForGridBackground(content).map(hit => { - const line = content.substring(0, hit.index).split('\n').length; + }, () => scanCssTextForGridBackground(source).map(hit => { + const line = source.substring(0, hit.index).split('\n').length; return finding('codex-grid-background', filePath, hit.snippet, line); }))); @@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) { phase: 'extract', ruleId: 'css-in-js', target: filePath, - }, () => extractCSSinJS(content, ext)) - : extractCSSinJS(content, ext); + }, () => extractCSSinJS(source, ext)) + : extractCSSinJS(source, ext); for (const block of cssJsBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = stripCssComments(block.content); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'css-in-js', })); - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 1)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1)); } if (options?.designSystem) { diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index a9e8ebe4b..bfc84fab8 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -217,6 +217,168 @@ describe('detectText — Tailwind side-tab', () => { }); }); +describe('detectText — broken images in source comments', () => { + test('ignores img tags mentioned in JavaScript comments', () => { + const source = [ + '/** Extra classes on the itself. */', + '// Keep the original URL as an fallback.', + '/*', + ' * This wrapper eventually renders an element.', + ' */', + 'const quoteMatcher = /["\']/;', + '// A regex before this comment must not expose its example.', + 'const ratio = "width" / size;', + '// Division after a string must not expose its example.', + 'const template = `value: ${/* */ fallback}`;', + 'export interface Props { imgClassName?: string }', + ].join('\n'); + + const findings = detectText(source, 'image-wrapper.ts'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('still detects real JSX img tags with no usable src', () => { + const source = [ + 'Missing source', + 'Empty source', + 'Placeholder source', + ].join('\n'); + + const findings = detectText(source, 'gallery.tsx'); + + expect(findings + .filter(r => r.antipattern === 'broken-image') + .map(r => r.snippet) + .sort()).toEqual([ + 'Missing source', + ' { + const source = [ + 'function matches(value) { return /[/*]/.test(value); }', + 'Empty source', + ].join('\n'); + + const findings = detectText(source, 'gallery.tsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + }); + + test('keeps later JSX visible after a regex literal following export default', () => { + const source = [ + 'export default /[/*]/;', + 'Empty source', + ].join('\n'); + + const findings = detectText(source, 'gallery.tsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + }); + + test('does not treat a return-named property division as a regex literal', () => { + const source = [ + 'const ratio = obj.return / divisor;', + '// Comment-only image', + ].join('\n'); + + const findings = detectText(source, 'gallery.tsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('does not treat division after a postfix update as a regex literal', () => { + const source = [ + 'const ratio = count++ / divisor;', + '// Comment-only image', + ].join('\n'); + + const findings = detectText(source, 'gallery.tsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('keeps a regex visible after a postfix update and binary operator', () => { + const source = 'let i = 0; const match = i++ + /[/*]/.test(value); Empty source'; + + const findings = detectText(source, 'gallery.tsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + }); + + test('keeps regex literals visible after remaining prefix contexts', () => { + const sources = [ + 'for (const item of /[/*]/) {} After for-of', + 'const match = value < /[/*]/.source; After comparison', + 'if (ready) {} /[/*]/.test(value); After block', + ]; + + for (const source of sources) { + const findings = detectText(source, 'gallery.tsx'); + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + } + }); + + test('keeps division after an object literal distinct from a regex', () => { + const source = 'const ratio = {} / divisor; // Comment-only image'; + + const findings = detectText(source, 'gallery.tsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('keeps same-line JSX visible after bare URL text', () => { + const source = '

https://example.com Empty source

'; + + const findings = detectText(source, 'gallery.tsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + }); + + test('keeps same-line JSX visible after a bare URL in a .js file', () => { + const source = '

https://example.com Empty source

'; + + const findings = detectText(source, 'gallery.js'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + }); + + test('keeps same-line JSX visible after a protocol-relative URL', () => { + const source = '

//cdn.example.com/logo.svg Empty source

'; + + const findings = detectText(source, 'gallery.jsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + }); + + test('still strips a domain-like comment after self-closing JSX', () => { + const source = 'const card = ; //cdn.example.com Comment-only image'; + + const findings = detectText(source, 'gallery.jsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('strips a domain-like comment inside a JSX expression', () => { + const source = 'const card =
{value //cdn.example.com Comment-only image'; + + const findings = detectText(source, 'gallery.jsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('strips a domain-like comment inside a JSX attribute expression', () => { + const source = 'const card = limit //cdn.example.com Comment-only image'; + + const findings = detectText(source, 'gallery.jsx'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); +}); + describe('detectText — CSS borders', () => { test('detects border-left shorthand', () => { const f = detectText('.card { border-left: 4px solid #3b82f6; }', 'test.css'); @@ -1561,6 +1723,39 @@ describe('codex-grid-background variants', () => { const findings = detectText(css, 'timeline.css'); expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(0); }); + + test('regex source engine ignores grids in JavaScript comments', () => { + const source = `/* .demo { + background-image: + linear-gradient(#eee 1px, transparent 1px), + linear-gradient(90deg, #eee 1px, transparent 1px); + background-size: 24px 24px; + } */`; + const findings = detectText(source, 'demo.ts'); + expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(0); + }); + + test('regex source engine ignores grids in CSS-in-JS comments', () => { + const source = `const Demo = styled.div\` + /* .demo { background-image: + linear-gradient(#eee 1px, transparent 1px), + linear-gradient(90deg, #eee 1px, transparent 1px); + background-size: 24px 24px; } */ + \`;`; + const findings = detectText(source, 'demo.tsx'); + expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(0); + }); + + test('regex source engine still detects live CSS-in-JS grids', () => { + const source = `const Demo = styled.div\` + .demo { background-image: + linear-gradient(#eee 1px, transparent 1px), + linear-gradient(90deg, #eee 1px, transparent 1px); + background-size: 24px 24px; } + \`;`; + const findings = detectText(source, 'demo.tsx'); + expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(1); + }); }); // --------------------------------------------------------------------------- @@ -2371,6 +2566,48 @@ describe('extractCSSinJS', () => { expect(blocks.some(b => b.content.includes('border-left: 4px solid'))).toBe(true); }); + test('extracts a styled-components template with TypeScript props', () => { + const tsx = "const Card = styled.div`\n border-left: 4px solid blue;\n`;"; + const blocks = extractCSSinJS(tsx, '.tsx'); + expect(blocks.length).toBeGreaterThanOrEqual(1); + expect(blocks.some(b => b.content.includes('border-left: 4px solid'))).toBe(true); + }); + + test('extracts a styled-components template with nested TypeScript props', () => { + const tsx = "const Card = styled.div>`\n border-left: 4px solid blue;\n`;"; + const blocks = extractCSSinJS(tsx, '.tsx'); + expect(blocks.length).toBeGreaterThanOrEqual(1); + expect(blocks.some(b => b.content.includes('border-left: 4px solid'))).toBe(true); + }); + + test('extracts through nested template literals in interpolations', () => { + const tsx = "const Card = styled.div`\n color: ${() => `var(--accent)`};\n /* after interpolation */\n`;"; + const blocks = extractCSSinJS(tsx, '.tsx'); + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toContain('after interpolation'); + }); + + test('extracts through regex literals in interpolations', () => { + const tsx = "const Card = styled.div`\n color: ${/`/.test(value) || /[{}]/.test(value)};\n /* after interpolation */\n`;"; + const blocks = extractCSSinJS(tsx, '.tsx'); + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toContain('after interpolation'); + }); + + test('extracts through division after a postfix update in interpolations', () => { + const tsx = "const Card = styled.div`\n width: ${i++ / divisor}px;\n /* after interpolation */\n`;"; + const blocks = extractCSSinJS(tsx, '.tsx'); + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toContain('after interpolation'); + }); + + test('extracts through regex literals after statement blocks', () => { + const tsx = "const Card = styled.div`\n color: ${() => { if (enabled) {} /`/.test(value); return 'red'; }};\n /* after interpolation */\n`;"; + const blocks = extractCSSinJS(tsx, '.tsx'); + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toContain('after interpolation'); + }); + test('extracts styled(Component) template literal', () => { const tsx = "const Box = styled(BaseBox)`\n border-right: 5px solid #8b5cf6;\n`;"; const blocks = extractCSSinJS(tsx, '.tsx'); @@ -2509,6 +2746,42 @@ describe('detectText -- CSS-in-JS', () => { const f = detectText(tsx, 'Card.tsx'); expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); }); + + test('does not scan CSS-in-JS comments as live rules', () => { + const tsx = "const style = css`\n /* .card { border-left: 4px solid #3b82f6; border-radius: 8px; } */\n`;"; + const f = detectText(tsx, 'Card.tsx'); + expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); + }); + + test('does not scan comments in generic styled templates as live rules', () => { + const tsx = "const Card = styled.div`\n /* .commented-only { border-left: 4px solid #3b82f6; border-radius: 8px; } */\n`;"; + const f = detectText(tsx, 'Card.tsx'); + expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); + }); + + test('does not scan comments in nested generic styled templates as live rules', () => { + const tsx = "const Card = styled.div>`\n /* .commented-only { border-left: 4px solid #3b82f6; border-radius: 8px; } */\n`;"; + const f = detectText(tsx, 'Card.tsx'); + expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); + }); + + test('does not scan comments after nested interpolation templates as live rules', () => { + const tsx = "const Card = styled.div`\n color: ${() => `var(--accent)`};\n /* .commented-only { border-left: 4px solid #3b82f6; border-radius: 8px; } */\n`;"; + const f = detectText(tsx, 'Card.tsx'); + expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); + }); + + test('does not scan comments after interpolation regexes as live rules', () => { + const tsx = "const Card = styled.div`\n color: ${/`/.test(value) || /[{}]/.test(value)};\n /* .commented-only { border-left: 4px solid #3b82f6; border-radius: 8px; } */\n`;"; + const f = detectText(tsx, 'Card.tsx'); + expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); + }); + + test('does not scan comments after postfix division or block-following regexes', () => { + const tsx = "const Card = styled.div`\n width: ${i++ / divisor}px;\n color: ${() => { if (enabled) {} /`/.test(value); return 'red'; }};\n /* .commented-only { border-left: 4px solid #3b82f6; border-radius: 8px; } */\n`;"; + const f = detectText(tsx, 'Card.tsx'); + expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); + }); }); // ---------------------------------------------------------------------------