frontend-standards-checker
Version:
A comprehensive frontend standards validation tool with TypeScript support
520 lines ⢠21.4 kB
JavaScript
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