frontend-standards-checker
Version:
A comprehensive frontend standards validation tool with TypeScript support
447 lines • 17.7 kB
JavaScript
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