UNPKG

frontend-standards-checker

Version:

A comprehensive frontend standards validation tool with TypeScript support

520 lines • 21.4 kB
import fs from 'fs'; import path from 'path'; import { getGitLastAuthor } from '../helpers/index.js'; /** * The `Reporter` class is responsible for generating, formatting, and saving frontend standards validation reports. * It processes validation errors, warnings, and info suggestions, and produces detailed and summary reports * in both human-readable and JSON formats. The class also manages log directories, file metadata, and * optionally includes collaborator information from Git history. * * @remarks * - Excludes Jest (test/spec) files from violation counts. * - Supports exporting reports in both text and JSON formats. * - Copies a viewer HTML file to the logs directory for enhanced report viewing. * * @example * ```typescript * const reporter = new Reporter(rootDir, outputPath, logger); * await reporter.generate(zoneErrors, projectInfo, config); * ``` * * @see IReporter */ export class Reporter { /** * Determine if a file is a Jest (test/spec) */ isJestFile(filePath) { const lowerPath = filePath.toLowerCase(); return (/\.(test|spec)\.[jt]sx?$/.test(lowerPath) || /__tests__/.test(lowerPath) || lowerPath.includes('jest')); } rootDir; outputPath; logDir; logger; includeCollaborators = true; _originalZoneErrors = {}; getFileMeta(filePath) { let modDate = 'No date'; let lastAuthor = 'Unknown'; try { const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.rootDir, filePath); if (fs.existsSync(absPath)) { const stats = fs.statSync(absPath); modDate = stats.mtime ? stats.mtime.toLocaleString('es-ES', { timeZone: 'America/Bogota' }) : modDate; if (this.includeCollaborators) { lastAuthor = getGitLastAuthor(absPath, this.rootDir); } else { lastAuthor = 'Deactivated by user'; } } } catch { } return { modDate, lastAuthor }; } constructor(rootDir, outputPath, logger) { this.rootDir = rootDir; // Restore logDir to dated subfolder for original behavior const now = new Date(); const pad = (n) => n.toString().padStart(2, '0'); const folderName = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; this.logDir = path.join(rootDir, 'logs-standards-validations', folderName); this.outputPath = outputPath ?? path.join(this.logDir, 'frontend-standards.log'); this.logger = logger; // Show in console the generated path // eslint-disable-next-line no-console console.log(`šŸ“ Log folder for this run: ${this.logDir}`); } /** * Generate and save validation report */ async generate(zoneErrors, projectInfo, config) { // Store original errors for detailed reporting this.setOriginalZoneErrors(zoneErrors); const reportData = this.processErrors(zoneErrors); const reportContent = await this.formatReport(reportData, projectInfo, config); await this.saveReport(reportContent); return { logFile: this.outputPath, totalErrors: reportData.totalErrors, totalWarnings: reportData.totalWarnings, totalInfos: reportData.totalInfos, totalZones: Object.keys(zoneErrors).length, zoneErrors, summary: reportData.summary, warningSummary: reportData.warningSummary, infoSummary: reportData.infoSummary, }; } /** * Process errors and generate statistics */ processErrors(zoneErrors) { const errorsByRule = {}; const warningsByRule = {}; const infosByRule = {}; const oksByZone = {}; const errorsByZone = {}; const warningsByZone = {}; const infosByZone = {}; const totalCheckedByZone = {}; let totalErrors = 0; let totalWarnings = 0; let totalInfos = 0; for (const [zone, errors] of Object.entries(zoneErrors)) { errorsByZone[zone] = 0; warningsByZone[zone] = 0; infosByZone[zone] = 0; oksByZone[zone] = []; totalCheckedByZone[zone] = 0; for (const error of errors) { // Exclude Jest files if (this.isJestFile(error.filePath)) { continue; } totalCheckedByZone[zone]++; if (error.message.startsWith('āœ…')) { oksByZone[zone].push(error.message.replace('āœ… ', '')); } else if (error.message.startsWith('Present:')) { oksByZone[zone].push(error.message.replace('Present:', '').trim()); } else if (error.severity === 'error' && !error.message.startsWith('āœ…') && !error.message.startsWith('Present:')) { // Only count errors that would be shown in detailed violations errorsByZone[zone]++; totalErrors++; errorsByRule[error.rule] = (errorsByRule[error.rule] ?? 0) + 1; } else if (error.severity === 'warning') { warningsByZone[zone]++; totalWarnings++; warningsByRule[error.rule] = (warningsByRule[error.rule] ?? 0) + 1; } else if (error.severity === 'info') { infosByZone[zone]++; totalInfos++; infosByRule[error.rule] = (infosByRule[error.rule] ?? 0) + 1; } } } return { totalErrors, totalWarnings, totalInfos, errorsByRule, warningsByRule, infosByRule, errorsByZone, warningsByZone, infosByZone, oksByZone, totalCheckedByZone, summary: this.generateSummary(errorsByRule, totalErrors), warningSummary: this.generateSummary(warningsByRule, totalWarnings), infoSummary: this.generateSummary(infosByRule, totalInfos), }; } /** * Generate error summary */ generateSummary(errorsByRule, totalErrors) { if (totalErrors === 0) { return []; } return Object.entries(errorsByRule) .sort(([, a], [, b]) => b - a) .slice(0, 15) .map(([rule, count]) => ({ rule, count, percentage: ((count / totalErrors) * 100).toFixed(1), })); } /** * Format the complete report */ async formatReport(reportData, projectInfo, _config) { const lines = []; this.addReportHeader(lines, projectInfo); const validationsPassed = reportData.totalErrors === 0; const hasWarnings = reportData.totalWarnings > 0; const hasInfos = reportData.totalInfos > 0; if (validationsPassed && !hasWarnings && !hasInfos) { lines.push('āœ… ALL VALIDATIONS PASSED!'); lines.push(''); lines.push('Congratulations! Your project complies with all defined frontend standards.'); return lines.join('\n'); } if (validationsPassed) { if (hasWarnings && hasInfos) { lines.push('āš ļø No errors found, but there are warnings and suggestions.\n'); } else if (hasWarnings) { lines.push('🟔 No errors found, but there are warnings.\n'); } else if (hasInfos) { lines.push('ā„¹ļø No errors found, but there are suggestions.\n'); } } this.addSummarySection(lines, reportData); this.addZoneResultsSection(lines, reportData); this.addDetailedErrorsSection(lines); this.addDetailedWarningsSection(lines); this.addDetailedInfosSection(lines); this.addStatisticsSection(lines, reportData); this.addRecommendationsSection(lines); return lines.join('\n'); } /** * Add report header section */ addReportHeader(lines, projectInfo) { lines.push('='.repeat(80)); lines.push('FRONTEND STANDARDS VALIDATION REPORT'); lines.push('='.repeat(80)); lines.push(`Generated: ${new Date().toLocaleString('es-ES', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'America/Bogota', })}`); lines.push(`Project: ${path.basename(this.rootDir)}`); lines.push(`Project Type: ${projectInfo.type}`); lines.push(`Monorepo: ${projectInfo.isMonorepo ? 'Yes' : 'No'}`); lines.push(''); lines.push('šŸ’” TIP: Use Cmd+Click (Mac) or Ctrl+Click (Windows/Linux) on file paths to open them directly in your editor'); lines.push(''); } /** * Add summary section */ addSummarySection(lines, reportData) { lines.push(`SUMMARY: ${reportData.totalErrors} violations found across ${Object.keys(reportData.errorsByZone).length} zones`); if (reportData.totalWarnings > 0) { lines.push(`Additional warnings: ${reportData.totalWarnings} warnings found`); } if (reportData.totalInfos > 0) { lines.push(`Additional suggestions: ${reportData.totalInfos} info items found`); } lines.push(''); } /** * Add zone results section */ addZoneResultsSection(lines, reportData) { lines.push('-'.repeat(16)); lines.push('RESULTS BY ZONE:'); lines.push('-'.repeat(16)); for (const [zone, errors] of Object.entries(reportData.errorsByZone)) { const warnings = reportData.warningsByZone[zone] ?? 0; const infos = reportData.infosByZone[zone] ?? 0; lines.push(`\nšŸ“‚ Zone: ${zone}`); lines.push(` Errors: ${errors}`); lines.push(` Warnings: ${warnings}`); if (infos > 0) { lines.push(` Info suggestions: ${infos}`); } lines.push(` Status: ${errors === 0 ? 'āœ… PASSED' : 'āŒ FAILED'}`); lines.push('-'.repeat(40)); } } /** * Add detailed errors section */ addDetailedErrorsSection(lines) { lines.push('\n'); lines.push('-'.repeat(20)); lines.push('DETAILED VIOLATIONS:'); lines.push('-'.repeat(20)); const zoneErrors = this.getOriginalZoneErrors(); for (const [zone, errors] of Object.entries(zoneErrors)) { const actualErrors = errors.filter((e) => !e.message.startsWith('āœ…') && e.severity === 'error' && !e.message.startsWith('Present:') && !this.isJestFile(e.filePath)); if (actualErrors.length > 0) { lines.push(`šŸ“‚ Zone: ${zone}`); for (const error of actualErrors) { // Print absolute path for VS Code link compatibility in subfolder const absPath = path.isAbsolute(error.filePath) ? error.filePath : path.resolve(this.rootDir, error.filePath); const fileLocation = error.line ? `${absPath}:${error.line}` : absPath; const meta = this.getFileMeta(error.filePath); lines.push(`\n šŸ“„ ${fileLocation}`); lines.push(` Rule: ${error.rule}`); lines.push(` Issue: ${error.message}`); lines.push(` Last modification: ${meta.modDate}`); lines.push(` Last collaborator: ${meta.lastAuthor}`); lines.push(' ' + '-'.repeat(50)); } } } } /** * Add detailed warnings section */ addDetailedWarningsSection(lines) { lines.push('\n'); lines.push('-'.repeat(18)); lines.push('DETAILED WARNINGS:'); lines.push('-'.repeat(18)); const zoneErrors = this.getOriginalZoneErrors(); for (const [zone, errors] of Object.entries(zoneErrors)) { const actualWarnings = errors.filter((e) => !e.message.startsWith('āœ…') && e.severity === 'warning' && !e.message.startsWith('Present:') && !this.isJestFile(e.filePath)); if (actualWarnings.length > 0) { lines.push(`šŸ“‚ Zone: ${zone}`); for (const warning of actualWarnings) { const absPath = path.isAbsolute(warning.filePath) ? warning.filePath : path.resolve(this.rootDir, warning.filePath); const fileLocation = warning.line ? `${absPath}:${warning.line}` : absPath; const meta = this.getFileMeta(warning.filePath); lines.push(`\n šŸ“„ ${fileLocation}`); lines.push(` Rule: ${warning.rule}`); lines.push(` Issue: ${warning.message}`); lines.push(` Last modification: ${meta.modDate}`); lines.push(` Last collaborator: ${meta.lastAuthor}`); lines.push(' ' + '-'.repeat(50)); } } } } /** * Add detailed info suggestions section */ addDetailedInfosSection(lines) { lines.push('-'.repeat(26)); lines.push('DETAILED INFO SUGGESTIONS:'); lines.push('-'.repeat(26)); const zoneErrors = this.getOriginalZoneErrors(); for (const [zone, errors] of Object.entries(zoneErrors)) { const actualInfos = errors.filter((e) => !e.message.startsWith('āœ…') && e.severity === 'info' && !e.message.startsWith('Present:') && !this.isJestFile(e.filePath)); if (actualInfos.length > 0) { lines.push(`šŸ“‚ Zone: ${zone}`); for (const info of actualInfos) { const absPath = path.isAbsolute(info.filePath) ? info.filePath : path.resolve(this.rootDir, info.filePath); const fileLocation = info.line ? `${absPath}:${info.line}` : absPath; const meta = this.getFileMeta(info.filePath); lines.push(`\n šŸ“„ ${fileLocation}`); lines.push(` Rule: ${info.rule}`); lines.push(` Issue: ${info.message}`); lines.push(` Last modification: ${meta.modDate}`); lines.push(` Last collaborator: ${meta.lastAuthor}`); lines.push(' ' + '-'.repeat(50)); } } } } /** * Add statistics section */ addStatisticsSection(lines, reportData) { if (reportData.summary.length > 0) { lines.push('\n'); lines.push('-'.repeat(17)); lines.push('ERROR STATISTICS:'); lines.push('-'.repeat(17)); for (const stat of reportData.summary) { lines.push(`• ${stat.rule}: ${stat.count} occurrences (${stat.percentage}%)`); } lines.push(`\nTotal violations: ${reportData.totalErrors}`); } if (reportData.warningSummary.length > 0) { lines.push('\n'); lines.push('-'.repeat(19)); lines.push('WARNING STATISTICS:'); lines.push('-'.repeat(19)); for (const stat of reportData.warningSummary) { lines.push(`• ${stat.rule}: ${stat.count} occurrences (${stat.percentage}%)`); } lines.push(`\nTotal warnings: ${reportData.totalWarnings}`); } if (reportData.infoSummary.length > 0) { lines.push('\n'); lines.push('-'.repeat(28)); lines.push('INFO SUGGESTIONS STATISTICS:'); lines.push('-'.repeat(28)); for (const stat of reportData.infoSummary) { lines.push(`• ${stat.rule}: ${stat.count} occurrences (${stat.percentage}%)`); } lines.push(`\nTotal info suggestions: ${reportData.totalInfos}`); } } /** * Add recommendations section */ addRecommendationsSection(lines) { lines.push('\n'); lines.push('-'.repeat(16)); lines.push('RECOMMENDATIONS:'); lines.push('-'.repeat(16)); lines.push('• Focus on the most frequent violation types shown above'); lines.push('• Review and update your development practices'); lines.push('• Consider setting up pre-commit hooks to catch violations early'); lines.push('• Regular team training on coding standards can help reduce violations'); } /** * Save report to file */ async saveReport(content) { try { // Create logs folder if does not exist if (!fs.existsSync(this.logDir)) { fs.mkdirSync(this.logDir, { recursive: true }); } // Get last modification date let modDate = 'No date'; try { if (fs.existsSync(this.outputPath)) { const stats = fs.statSync(this.outputPath); modDate = stats.mtime ? stats.mtime.toLocaleString('es-ES', { timeZone: 'America/Bogota', }) : modDate; } } catch { } // Add info to log content (at the end) const logWithMeta = `${content}\n\n---\nLast modification: ${modDate}`; fs.writeFileSync(this.outputPath, logWithMeta, 'utf8'); this.logger.debug(`Report saved to: ${this.outputPath}`); // Copy the viewer HTML to the logs folder const possibleViewerPaths = [ path.join(this.rootDir, 'frontend-standards-log-viewer.html'), path.join(this.rootDir, 'src', 'frontend-standards-log-viewer.html'), path.join(this.rootDir, 'bin', 'frontend-standards-log-viewer.html'), path.join(this.rootDir, 'frontend-standards-full', 'bin', 'frontend-standards-log-viewer.html'), path.join(this.rootDir, 'node_modules', 'frontend-standards-checker', 'bin', 'frontend-standards-log-viewer.html'), path.join(this.rootDir, 'node_modules', '@dcefront', 'frontend-standards-checker', 'bin', 'frontend-standards-log-viewer.html'), ]; let viewerSrc = ''; for (const p of possibleViewerPaths) { if (fs.existsSync(p)) { viewerSrc = p; break; } } const viewerDest = path.join(this.logDir, 'frontend-standards-log-viewer.html'); if (viewerSrc) { fs.copyFileSync(viewerSrc, viewerDest); this.logger.debug(`Viewer copied to: ${viewerDest}`); } else { this.logger.warn('Viewer HTML not found in any known location, not copied'); } } catch (error) { this.logger.error(`Failed to save report: ${error.message}`); throw error; } } /** * Get original zone errors */ getOriginalZoneErrors() { return this._originalZoneErrors; } /** * Set original zone errors for detailed reporting */ setOriginalZoneErrors(zoneErrors) { this._originalZoneErrors = zoneErrors; } /** * Generate a quick summary for console output */ generateQuickSummary(reportData) { if (reportData.totalErrors === 0) { return 'āœ… All validations passed!'; } const topIssues = reportData.summary.slice(0, 3); const issuesList = topIssues .map((issue) => `${issue.rule} (${issue.count})`) .join(', '); return `āŒ ${reportData.totalErrors} violations found. Top issues: ${issuesList}`; } /** * Export report in JSON format */ async exportJson(reportData, outputPath = null) { const jsonPath = outputPath ?? this.outputPath.replace('.log', '.json'); try { const jsonContent = JSON.stringify(reportData, null, 2); fs.writeFileSync(jsonPath, jsonContent, 'utf8'); this.logger.debug(`JSON report exported to: ${jsonPath}`); return jsonPath; } catch (error) { this.logger.error(`Failed to export JSON report: ${error.message}`); throw error; } } } //# sourceMappingURL=reporter.js.map