blob: 4317a52be5440e55a044ec6cae0d48b40a9942d0 [file] [log] [blame] [edit]
/* This file is a part of @mdn/browser-compat-data
* See LICENSE file for more information. */
import fs from 'node:fs/promises';
import { Stats } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import esMain from 'es-main';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import chalk from 'chalk-template';
import { BrowserName } from '../types/types.js';
import { DataType } from '../types/index.js';
import dataFolders from '../scripts/lib/data-folders.js';
import extend from '../scripts/lib/extend.js';
import pluralize from '../scripts/lib/pluralize.js';
import { walk } from '../utils/index.js';
import * as linterModules from './linter/index.js';
import {
Linters,
LinterMessage,
LinterMessageLevel,
LinterPath,
} from './utils.js';
const dirname = fileURLToPath(new URL('.', import.meta.url));
const linters = new Linters(Object.values(linterModules));
/**
* Normalize and categorize file path
* @param file The file path
* @returns The normalized and categorized file path
*/
const normalizeAndCategorizeFilePath = (file: string): LinterPath => {
const filePath: LinterPath = {
full: path.relative(process.cwd(), file),
category: '',
};
if (path.sep === '\\') {
// Normalize file paths for Windows users
filePath.full = filePath.full.replace(/\\/g, '/');
}
if (filePath.full.includes('/')) {
filePath.category = filePath.full.split('/')[0];
}
return filePath;
};
/**
* Recursively load
* @param files The files to test
* @returns The data from the loaded files
*/
const loadAndCheckFiles = async (...files: string[]): Promise<DataType> => {
const data = {};
for (let file of files) {
if (file.indexOf(dirname) !== 0) {
file = path.resolve(dirname, '..', file);
}
let fsStats: Stats;
try {
fsStats = await fs.stat(file);
} catch (e) {
console.warn(chalk`{yellow File {bold ${file}} doesn't exist!}`);
continue;
}
if (fsStats.isFile() && path.extname(file) === '.json') {
const filePath = normalizeAndCategorizeFilePath(file);
try {
const rawFileData = (await fs.readFile(file, 'utf-8')).trim();
const fileData = JSON.parse(rawFileData);
await linters.runScope('file', {
data: fileData,
rawdata: rawFileData,
path: filePath,
});
extend(data, fileData);
} catch (e) {
linters.messages['File'].push({
level: 'error',
title: 'File Read Error',
path: filePath.full,
message: e as string,
});
}
}
if (fsStats.isDirectory()) {
const dircontents = await fs.readdir(file);
const subFiles = dircontents.map((subfile) => path.join(file, subfile));
extend(data, await loadAndCheckFiles(...subFiles));
}
}
return data;
};
/**
* Test for any errors in specified file(s) and/or folder(s), or all of BCD
* @param files The file(s) and/or folder(s) to test. Leave undefined for everything.
* @returns Whether there were any errors
*/
const main = async (files = dataFolders) => {
let hasErrors = false;
console.log(chalk`{cyan Loading and checking files...}`);
const data = await loadAndCheckFiles(...files);
console.log(chalk`{cyan Testing browser data...}`);
for (const browser in data?.browsers) {
await linters.runScope('browser', {
data: data.browsers[browser],
rawdata: '',
path: {
full: `browsers.${browser}`,
category: 'browsers',
browser: browser as BrowserName,
},
});
}
console.log(chalk`{cyan Testing feature data...}`);
const walker = walk(undefined, data);
for (const feature of walker) {
await linters.runScope('feature', {
data: feature.compat,
rawdata: '',
path: {
full: feature.path,
category: feature.path.split('.')[0],
},
});
}
console.log(chalk`{cyan Testing all features together...}`);
await linters.runScope('tree', {
data,
rawdata: '',
path: {
full: '',
category: '',
},
});
for (const [linter, messages] of Object.entries(linters.messages)) {
if (!messages.length) {
continue;
}
const messagesByLevel: Record<LinterMessageLevel, LinterMessage[]> = {
error: [],
warning: [],
};
for (const message of messages) {
messagesByLevel[message.level].push(message);
}
if (messagesByLevel.error.length) {
hasErrors = true;
}
const errorCounts = Object.entries(messagesByLevel)
.map(([k, v]) => pluralize(k, v.length, true))
.join(', ');
console.error(
chalk`{${
messagesByLevel.error.length ? 'red' : 'yellow'
} ${linter} - {bold ${pluralize(
'problem',
messages.length,
true,
)}} (${errorCounts}):}`,
);
for (const message of messages) {
console.error(
chalk`{${message.level === 'error' ? 'red' : 'yellow'} ✖ ${
message.path
} - ${message.level[0].toUpperCase() + message.level.substring(1)} → ${
message.message
}}`,
);
if (message.fixable) {
console.error(
chalk`{blue ◆ Tip: Run {bold npm run fix} to fix this problem automatically}`,
);
}
if (message.tip) {
console.error(chalk`{blue ◆ Tip: ${message.tip}}`);
}
}
}
// Find all unnecessary linting exceptions
for (const linter of linters.linters) {
if (linter.exceptions) {
const missingExceptions = linters.missingExpectedFailures[linter.name];
for (const exception in missingExceptions) {
if (missingExceptions[exception]) {
hasErrors = true;
console.error(
chalk`{red ✖ ${linter.name} - Unnecessary exception → ${exception}}`,
);
}
}
}
}
if (!hasErrors) {
console.log(chalk`{green All data {bold passed} linting!}`);
if (linters.linters.some((linter) => linter.exceptions)) {
console.log(
chalk`{yellow Linters have some exceptions, please help us remove them!}`,
);
for (const linter of linters.linters) {
if (linter.exceptions) {
console.log(
chalk`{yellow ${linter.name} has ${pluralize(
'exception',
linter.exceptions.length,
true,
)}}`,
);
for (const exception of linter.exceptions) {
console.log(chalk`{yellow - ${exception}}`);
}
}
}
}
}
return hasErrors;
};
if (esMain(import.meta)) {
const { argv } = yargs(hideBin(process.argv)).command(
'$0 [files..]',
false,
(yargs) =>
yargs.positional('files...', {
description: 'The files to lint (leave blank to test everything)',
type: 'string',
}),
);
process.exit((await main((argv as any).files)) ? 1 : 0);
}
export default main;