UNPKG

frontend-standards-checker

Version:

A comprehensive frontend standards validation tool with TypeScript support

447 lines 17.7 kB
import fs from 'fs'; import path from 'path'; /** * File scanner utility for finding and filtering project files */ export class FileScanner { rootDir; logger; gitignorePatterns = []; defaultIgnorePatterns; constructor(rootDir, logger) { this.rootDir = rootDir; this.logger = logger; this.defaultIgnorePatterns = [ 'node_modules', '.next', '.git', '__tests__', '__test__', 'coverage', 'dist', 'build', '.nyc_output', 'tmp', 'temp', ]; } /** * Scan a specific zone for files * @param zone Zone to scan * @param options Scan options * @returns Array of file information */ async scanZone(zone, options) { const zonePath = path.join(this.rootDir, zone); if (!fs.existsSync(zonePath)) { this.logger.warn(`Zone path does not exist: ${zonePath}`); return []; } const gitIgnorePatterns = await this.loadGitignorePatterns(); const allIgnorePatterns = [ ...this.defaultIgnorePatterns, ...gitIgnorePatterns.map((p) => p.pattern), ...options.ignorePatterns, ]; this.logger.debug(`Loading .gitignore patterns from: ${this.rootDir}`); this.logger.debug(`Found ${gitIgnorePatterns.length} gitignore patterns`); this.logger.debug(`Total ignore patterns: ${allIgnorePatterns.length}`); const files = await this.scanDirectory(zonePath, options); this.logger.debug(`Found ${files.length} files in zone: ${zone}`); return files; } /** * Scan directory recursively for files * @param dirPath Directory path to scan * @param options Scan options * @returns Array of file information */ async scanDirectory(dirPath, options) { const files = []; const gitIgnorePatterns = await this.loadGitignorePatterns(); try { const entries = await fs.promises.readdir(dirPath, { withFileTypes: true, }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const relativePath = path.relative(this.rootDir, fullPath); // Check if path should be ignored if (this.isIgnored(relativePath, gitIgnorePatterns)) { continue; } if (entry.isDirectory()) { // Recursively scan subdirectories const subFiles = await this.scanDirectory(fullPath, options); files.push(...subFiles); } else if (entry.isFile()) { // Check if file has valid extension const ext = path.extname(entry.name); if (options.extensions.includes(ext)) { const content = await fs.promises.readFile(fullPath, 'utf8'); const zone = this.determineZone(relativePath, options); const fileInfo = { path: relativePath, content, size: content.length, extension: ext, zone, fullPath: fullPath, }; files.push(fileInfo); } } } } catch (error) { this.logger.error(`Error scanning directory ${dirPath}:`, error); } return files; } /** * Load gitignore patterns from .gitignore file * @returns Array of gitignore patterns */ async loadGitignorePatterns() { if (this.gitignorePatterns.length > 0) { return this.gitignorePatterns; } const gitignorePath = path.join(this.rootDir, '.gitignore'); if (!fs.existsSync(gitignorePath)) { this.logger.debug('No .gitignore file found'); return []; } try { const content = await fs.promises.readFile(gitignorePath, 'utf8'); const lines = content .split('\n') .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')); const patterns = lines.map((line) => { const isNegation = line.startsWith('!'); const pattern = isNegation ? line.slice(1) : line; const isDirectory = pattern.endsWith('/'); return { pattern: isDirectory ? pattern.slice(0, -1) : pattern, isNegation, isDirectory, }; }); // Cache the patterns this.gitignorePatterns.push(...patterns); return patterns; } catch (error) { this.logger.error('Error reading .gitignore file:', error); return []; } } /** * Check if a file path should be ignored based on patterns * @param filePath File path to check * @param patterns Array of gitignore patterns * @returns True if file should be ignored */ isIgnored(filePath, patterns) { // Normalize path separators const normalizedPath = filePath.replace(/\\/g, '/'); // Check default ignore patterns first for (const pattern of this.defaultIgnorePatterns) { if (this.matchesPattern(normalizedPath, pattern)) { return true; } } let ignored = false; // Process gitignore patterns for (const { pattern, isNegation, isDirectory } of patterns) { const matches = this.matchesGitignorePattern(normalizedPath, pattern, isDirectory); if (matches) { ignored = !isNegation; } } return ignored; } /** * Check if path matches a simple pattern * @param filePath File path to check * @param pattern Pattern to match * @returns True if matches */ matchesPattern(filePath, pattern) { // Simple pattern matching for default ignore patterns return filePath.includes(pattern) || filePath.startsWith(pattern); } /** * Check if path matches a gitignore pattern * @param filePath File path to check * @param pattern Gitignore pattern * @param isDirectory Whether pattern is for directories only * @returns True if matches */ matchesGitignorePattern(filePath, pattern, isDirectory) { // Convert gitignore pattern to regex let regexPattern = pattern .replace(/\./g, '\\.') .replace(/\*\*/g, '.*') .replace(/\*/g, '[^/]*') .replace(/\?/g, '[^/]'); // Handle directory patterns if (isDirectory) { regexPattern += '(/.*)?$'; } else { regexPattern += '$'; } // Handle patterns that start with / if (pattern.startsWith('/')) { regexPattern = '^' + regexPattern.slice(1); } else { regexPattern = '(^|/)' + regexPattern; } try { const regex = new RegExp(regexPattern); return regex.test(filePath); } catch (error) { this.logger.warn(`Invalid gitignore pattern: ${pattern}`, error); return false; } } /** * Determine which zone a file belongs to * @param filePath File path * @param options Scan options * @returns Zone name */ determineZone(filePath, options) { const pathParts = filePath.split('/'); // Check for specific zones for (const zone of [...(options.zones ?? []), ...options.customZones]) { if (filePath.startsWith(zone)) { return zone; } } // Default zone determination if (pathParts.includes('apps')) { const appsIndex = pathParts.indexOf('apps'); return pathParts.slice(0, appsIndex + 2).join('/'); } if (pathParts.includes('packages') && options.includePackages) { const packagesIndex = pathParts.indexOf('packages'); return pathParts.slice(0, packagesIndex + 2).join('/'); } // Return root zone return '.'; } /** * Get file statistics * @param options Scan options * @returns File scan statistics */ async getStatistics(options) { const allFiles = await this.scanDirectory(this.rootDir, options); const gitIgnorePatterns = await this.loadGitignorePatterns(); return { files: allFiles, totalFiles: allFiles.length, skippedFiles: 0, // Could be calculated if needed ignoredPatterns: gitIgnorePatterns.map((p) => p.pattern), }; } /** * Get files that are staged for commit * @returns Array of file paths that are staged for commit */ async getFilesInCommit() { try { // Execute git command to get staged files (files added to the index) const { exec } = await import('child_process'); return new Promise((resolve) => { exec('git diff --name-only --cached', { cwd: this.rootDir }, (error, stdout) => { if (error) { this.logger.warn(`Failed to get staged files: ${error.message}`); resolve([]); return; } const files = stdout .trim() .split('\n') .filter(Boolean) .map((file) => path.join(this.rootDir, file)); this.logger.debug(`Found ${files.length} files staged for commit`); resolve(files); }); }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.warn(`Failed to get staged files - git command failed: ${errorMessage}`); return []; } } /** * Get files that are changed in recent commits (for CI/CD pipelines) * This method uses multiple git strategies to find changed files in pipeline environments * @returns Array of file paths that are changed in recent commits */ async getChangedFilesInPipeline() { try { const { exec } = await import('child_process'); const strategies = this.getGitStrategiesForPipeline(); // Try each strategy until one succeeds for (const strategy of strategies) { this.logger.debug(`Trying git strategy: ${strategy.description}`); const files = await new Promise((resolve) => { exec(strategy.command, { cwd: this.rootDir }, (error, stdout) => { if (error) { this.logger.debug(`Strategy failed: ${error.message}`); resolve([]); return; } const files = stdout .trim() .split('\n') .filter(Boolean) .map((file) => path.join(this.rootDir, file)); this.logger.debug(`Strategy found ${files.length} files`); resolve(files); }); }); if (files.length > 0) { this.logger.debug(`Success with strategy: ${strategy.description}`); this.logger.info(`Found ${files.length} files to check`); return files; } } this.logger.warn('All git strategies failed to find changed files'); return []; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.warn(`Failed to get changed files - git command failed: ${errorMessage}`); return []; } } /** * Get combined files: staged files first, then pipeline changed files as fallback * @param forcePipelineMode Force using pipeline mode regardless of environment * @returns Array of file paths that are staged for commit or recently changed */ async getFilesInCommitOrPipeline(forcePipelineMode = false) { // If pipeline mode is forced, use pipeline strategies directly if (forcePipelineMode) { this.logger.debug('Pipeline mode forced, using pipeline strategies'); return await this.getChangedFilesInPipeline(); } // First try to get staged files (current behavior) const stagedFiles = await this.getFilesInCommit(); if (stagedFiles.length > 0) { this.logger.debug('Found staged files, using them'); return stagedFiles; } // If no staged files and we're in CI environment, try pipeline strategies if (this.isRunningInCI()) { this.logger.debug('No staged files found and in CI environment, trying pipeline strategies'); return await this.getChangedFilesInPipeline(); } this.logger.debug('No staged files found and not in CI environment'); return []; } /** * Get git strategies specifically for pipeline environments * @returns Array of git strategies to try in pipeline/CI environments */ getGitStrategiesForPipeline() { const strategies = []; // Strategy 1: Files changed in last commit (most common for CI/CD) strategies.push({ command: 'git diff --name-only HEAD~1 HEAD', description: 'files changed in last commit (CI/CD)', }); // Strategy 2: Files in current commit using diff-tree (robust for CI/CD) strategies.push({ command: 'git diff-tree --no-commit-id --name-only -r HEAD', description: 'files in current commit (diff-tree)', }); // Strategy 3: Files changed compared to main/master branch (for feature branches) const mainBranches = ['main', 'master', 'develop']; for (const branch of mainBranches) { strategies.push({ command: `git diff --name-only ${branch}...HEAD`, description: `files changed compared to ${branch} branch`, }); } // Strategy 4: Files changed in last N commits (fallback for complex scenarios) strategies.push({ command: 'git diff --name-only HEAD~3 HEAD', description: 'files changed in last 3 commits (fallback)', }); // Strategy 5: Files changed compared to origin branches (for remote scenarios) for (const branch of mainBranches) { strategies.push({ command: `git diff --name-only origin/${branch}...HEAD`, description: `files changed compared to origin/${branch}`, }); } return strategies; } /** * Check if running in a CI/CD environment * @returns True if running in CI/CD environment */ isRunningInCI() { const ciEnvironmentVars = [ 'CI', 'CONTINUOUS_INTEGRATION', 'GITLAB_CI', 'GITHUB_ACTIONS', 'AZURE_HTTP_USER_AGENT', 'JENKINS_URL', 'BUILDKITE', 'CIRCLECI', 'TRAVIS', 'TEAMCITY_VERSION', 'BITBUCKET_BUILD_NUMBER', ]; return ciEnvironmentVars.some((envVar) => process.env[envVar]); } } /** * Detect if the current project is a React Native project */ export function isReactNativeProject(filePath) { // Find the root directory by going up until we find package.json let currentDir = path.dirname(filePath); let packageJsonPath = path.join(currentDir, 'package.json'); // Keep going up until we find package.json or reach root while (!fs.existsSync(packageJsonPath) && currentDir !== path.dirname(currentDir)) { currentDir = path.dirname(currentDir); packageJsonPath = path.join(currentDir, 'package.json'); } if (!fs.existsSync(packageJsonPath)) { return false; } try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); // Check for React Native dependencies const isRN = !!(packageJson.dependencies?.['react-native'] ?? packageJson.devDependencies?.['react-native'] ?? packageJson.dependencies?.['@react-native/metro-config'] ?? packageJson.devDependencies?.['@react-native/metro-config'] ?? // Check for Expo (which is also React Native) packageJson.dependencies?.['expo'] ?? packageJson.devDependencies?.['expo']); // Also check for React Native specific files const hasMetroConfig = fs.existsSync(path.join(currentDir, 'metro.config.js')); const hasAndroidDir = fs.existsSync(path.join(currentDir, 'android')); const hasIosDir = fs.existsSync(path.join(currentDir, 'ios')); return isRN || (hasMetroConfig && (hasAndroidDir || hasIosDir)); } catch { return false; } } //# sourceMappingURL=file-scanner.js.map