import fs from 'node:fs' import path from 'node:path' import tsParser from '@typescript-eslint/parser' const TS_TSX_FILE_PATTERN = /\.(?:ts|tsx)$/ const TYPE_COVERAGE_EXCLUDE_BASENAMES = new Set([ 'type', 'types', 'declarations', ]) const GENERATED_FILE_COMMENT_PATTERNS = [ /@generated/i, /\bauto-?generated\b/i, /\bgenerated by\b/i, /\bgenerate by\b/i, /\bdo not edit\b/i, /\bdon not edit\b/i, ] const PARSER_OPTIONS = { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true }, } const collectedExcludedFilesCache = new Map() export const COMPONENT_COVERAGE_EXCLUDE_LABEL = 'type-only files, pure barrel files, generated files, pure static files' export function isTypeCoverageExcludedComponentFile(filePath) { return TYPE_COVERAGE_EXCLUDE_BASENAMES.has(getPathBaseNameWithoutExtension(filePath)) } export function getComponentCoverageExclusionReasons(filePath, sourceCode) { if (!isEligibleComponentSourceFilePath(filePath)) return [] const reasons = [] if (isTypeCoverageExcludedComponentFile(filePath)) reasons.push('type-only') if (typeof sourceCode !== 'string' || sourceCode.length === 0) return reasons if (isGeneratedComponentFile(sourceCode)) reasons.push('generated') const ast = parseComponentFile(sourceCode) if (!ast) return reasons if (isPureBarrelComponentFile(ast)) reasons.push('pure-barrel') else if (isPureStaticComponentFile(ast)) reasons.push('pure-static') return reasons } export function collectComponentCoverageExcludedFiles(rootDir, options = {}) { const normalizedRootDir = path.resolve(rootDir) const pathPrefix = normalizePathPrefix(options.pathPrefix ?? '') const cacheKey = `${normalizedRootDir}::${pathPrefix}` const cached = collectedExcludedFilesCache.get(cacheKey) if (cached) return cached const files = [] walkComponentFiles(normalizedRootDir, (absolutePath) => { const relativePath = path.relative(normalizedRootDir, absolutePath).split(path.sep).join('/') const prefixedPath = pathPrefix ? `${pathPrefix}/${relativePath}` : relativePath const sourceCode = fs.readFileSync(absolutePath, 'utf8') if (getComponentCoverageExclusionReasons(prefixedPath, sourceCode).length > 0) files.push(prefixedPath) }) files.sort((a, b) => a.localeCompare(b)) collectedExcludedFilesCache.set(cacheKey, files) return files } function normalizePathPrefix(pathPrefix) { return pathPrefix.replace(/\\/g, '/').replace(/\/$/, '') } function walkComponentFiles(currentDir, onFile) { if (!fs.existsSync(currentDir)) return const entries = fs.readdirSync(currentDir, { withFileTypes: true }) for (const entry of entries) { const entryPath = path.join(currentDir, entry.name) if (entry.isDirectory()) { if (entry.name === '__tests__' || entry.name === '__mocks__') continue walkComponentFiles(entryPath, onFile) continue } if (!isEligibleComponentSourceFilePath(entry.name)) continue onFile(entryPath) } } function isEligibleComponentSourceFilePath(filePath) { return TS_TSX_FILE_PATTERN.test(filePath) && !isTestLikePath(filePath) } function isTestLikePath(filePath) { return /(?:^|\/)__tests__\//.test(filePath) || /(?:^|\/)__mocks__\//.test(filePath) || /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath) || /\.stories\.(?:ts|tsx)$/.test(filePath) || /\.d\.ts$/.test(filePath) } function getPathBaseNameWithoutExtension(filePath) { if (!filePath) return '' const normalizedPath = filePath.replace(/\\/g, '/') const fileName = normalizedPath.split('/').pop() ?? '' return fileName.replace(TS_TSX_FILE_PATTERN, '') } function isGeneratedComponentFile(sourceCode) { const leadingText = sourceCode.split('\n').slice(0, 5).join('\n') return GENERATED_FILE_COMMENT_PATTERNS.some(pattern => pattern.test(leadingText)) } function parseComponentFile(sourceCode) { try { return tsParser.parse(sourceCode, PARSER_OPTIONS) } catch { return null } } function isPureBarrelComponentFile(ast) { let hasRuntimeReExports = false for (const statement of ast.body) { if (statement.type === 'ExportAllDeclaration') { hasRuntimeReExports = true continue } if (statement.type === 'ExportNamedDeclaration' && statement.source) { hasRuntimeReExports = hasRuntimeReExports || statement.exportKind !== 'type' continue } if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration') continue return false } return hasRuntimeReExports } function isPureStaticComponentFile(ast) { const importedStaticBindings = collectImportedStaticBindings(ast.body) const staticBindings = new Set() let hasRuntimeValue = false for (const statement of ast.body) { if (statement.type === 'ImportDeclaration') continue if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration') continue if (statement.type === 'ExportAllDeclaration') return false if (statement.type === 'ExportNamedDeclaration' && statement.source) return false if (statement.type === 'ExportDefaultDeclaration') { if (!isStaticExpression(statement.declaration, staticBindings, importedStaticBindings)) return false hasRuntimeValue = true continue } if (statement.type === 'ExportNamedDeclaration' && statement.declaration) { if (!handleStaticDeclaration(statement.declaration, staticBindings, importedStaticBindings)) return false hasRuntimeValue = true continue } if (statement.type === 'ExportNamedDeclaration' && statement.specifiers.length > 0) { const allStaticSpecifiers = statement.specifiers.every((specifier) => { if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type') return false return specifier.local.type === 'Identifier' && staticBindings.has(specifier.local.name) }) if (!allStaticSpecifiers) return false hasRuntimeValue = true continue } if (!handleStaticDeclaration(statement, staticBindings, importedStaticBindings)) return false hasRuntimeValue = true } return hasRuntimeValue } function handleStaticDeclaration(statement, staticBindings, importedStaticBindings) { if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const') return false for (const declarator of statement.declarations) { if (declarator.id.type !== 'Identifier' || !declarator.init) return false if (!isStaticExpression(declarator.init, staticBindings, importedStaticBindings)) return false staticBindings.add(declarator.id.name) } return true } function collectImportedStaticBindings(statements) { const importedBindings = new Set() for (const statement of statements) { if (statement.type !== 'ImportDeclaration') continue const importSource = String(statement.source.value ?? '') const isTypeLikeSource = isTypeCoverageExcludedComponentFile(importSource) const importIsStatic = statement.importKind === 'type' || isTypeLikeSource if (!importIsStatic) continue for (const specifier of statement.specifiers) { if (specifier.local?.type === 'Identifier') importedBindings.add(specifier.local.name) } } return importedBindings } function isStaticExpression(node, staticBindings, importedStaticBindings) { switch (node.type) { case 'Literal': return true case 'Identifier': return staticBindings.has(node.name) || importedStaticBindings.has(node.name) case 'TemplateLiteral': return node.expressions.every(expression => isStaticExpression(expression, staticBindings, importedStaticBindings)) case 'ArrayExpression': return node.elements.every(element => !element || isStaticExpression(element, staticBindings, importedStaticBindings)) case 'ObjectExpression': return node.properties.every((property) => { if (property.type === 'SpreadElement') return isStaticExpression(property.argument, staticBindings, importedStaticBindings) if (property.type !== 'Property' || property.method) return false if (property.computed && !isStaticExpression(property.key, staticBindings, importedStaticBindings)) return false if (property.shorthand) return property.value.type === 'Identifier' && staticBindings.has(property.value.name) return isStaticExpression(property.value, staticBindings, importedStaticBindings) }) case 'UnaryExpression': return isStaticExpression(node.argument, staticBindings, importedStaticBindings) case 'BinaryExpression': case 'LogicalExpression': return isStaticExpression(node.left, staticBindings, importedStaticBindings) && isStaticExpression(node.right, staticBindings, importedStaticBindings) case 'ConditionalExpression': return isStaticExpression(node.test, staticBindings, importedStaticBindings) && isStaticExpression(node.consequent, staticBindings, importedStaticBindings) && isStaticExpression(node.alternate, staticBindings, importedStaticBindings) case 'MemberExpression': return isStaticMemberExpression(node, staticBindings, importedStaticBindings) case 'ChainExpression': return isStaticExpression(node.expression, staticBindings, importedStaticBindings) case 'TSAsExpression': case 'TSSatisfiesExpression': case 'TSTypeAssertion': case 'TSNonNullExpression': return isStaticExpression(node.expression, staticBindings, importedStaticBindings) case 'ParenthesizedExpression': return isStaticExpression(node.expression, staticBindings, importedStaticBindings) default: return false } } function isStaticMemberExpression(node, staticBindings, importedStaticBindings) { if (!isStaticExpression(node.object, staticBindings, importedStaticBindings)) return false if (!node.computed) return node.property.type === 'Identifier' return isStaticExpression(node.property, staticBindings, importedStaticBindings) }