DogmaDragon c414e47c20
Add plugin schema validator workflow (#505)
* Remove code formatting workflows and related configuration files

* Remove formatting instructions from README

* Add plugin validator

* Add workflow

* Limit workflow to plugins/themes directories
2025-02-21 02:48:53 +02:00

163 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const safeRequire = (name) => {
try {
return require(name);
} catch (error) {
if (error && error.code === 'MODULE_NOT_FOUND') {
console.log(`Error: Cannot find module '${name}', have you installed the dependencies?`);
process.exit(1);
}
throw error;
}
};
const Ajv = safeRequire('ajv').default;
const betterAjvErrors = safeRequire('better-ajv-errors').default;
const chalk = safeRequire('chalk');
const YAML = safeRequire('yaml');
const addFormats = safeRequire('ajv-formats');
// https://www.peterbe.com/plog/nodejs-fs-walk-or-glob-or-fast-glob
function walk(directory, ext, filepaths = []) {
const files = fs.readdirSync(directory);
for (const filename of files) {
const filepath = path.join(directory, filename);
if (fs.statSync(filepath).isDirectory()) {
walk(filepath, ext, filepaths);
} else if (path.extname(filename) === ext && !filename.includes('config')) {
filepaths.push(filepath);
}
}
return filepaths;
}
// https://stackoverflow.com/a/53833620
const isSorted = arr => arr.every((v,i,a) => !i || a[i-1] <= v);
class Validator {
constructor(flags) {
this.allowDeprecations = flags.includes('-d');
this.stopOnError = !flags.includes('-a');
this.sortedURLs = flags.includes('-s');
this.verbose = flags.includes('-v');
const schemaPath = path.resolve(__dirname, './plugin.schema.json');
this.schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
this.ajv = new Ajv({
// allErrors: true,
allowUnionTypes: true, // Use allowUnionTypes instead of ignoreKeywordsWithRef
strict: true,
allowMatchingProperties: true, // Allow properties that match a pattern
});
addFormats(this.ajv);
}
run(files) {
let plugins;
if (files && Array.isArray(files) && files.length > 0) {
plugins = files.map(file => path.resolve(file));
} else {
const pluginsDir = path.resolve(__dirname, '../plugins');
const themesDir = path.resolve(__dirname, '../themes');
plugins = walk(pluginsDir, '.yml').concat(walk(themesDir, '.yml'));
}
let result = true;
const validate = this.ajv.compile(this.schema);
for (const file of plugins) {
const relPath = path.relative(process.cwd(), file);
let contents, data;
try {
contents = fs.readFileSync(file, 'utf8');
data = YAML.parse(contents);
} catch (error) {
console.error(`${chalk.red(chalk.bold('ERROR'))} in: ${relPath}:`);
error.stack = null;
console.error(error);
result = result && false;
if (this.stopOnError) break;
else continue;
}
let valid = validate(data);
// Output validation errors
if (!valid) {
const output = betterAjvErrors(this.schema, data, validate.errors, { indent: 2 });
console.log(output);
// Detailed error checks
validate.errors.forEach(err => {
switch (err.keyword) {
case 'required':
console.error(`${chalk.red('Missing Required Property:')} ${err.params.missingProperty}`);
break;
case 'type':
console.error(`${chalk.red('Type Mismatch:')} ${err.dataPath} should be ${err.params.type}`);
break;
case 'pattern':
console.error(`${chalk.red('Pattern Mismatch:')} ${err.dataPath} should match pattern ${err.params.pattern}`);
break;
case 'enum':
console.error(`${chalk.red('Enum Violation:')} ${err.dataPath} should be one of ${err.params.allowedValues.join(', ')}`);
break;
case 'additionalProperties':
console.error(`${chalk.red('Additional Properties:')} ${err.params.additionalProperty} is not allowed`);
break;
case '$ref':
console.error(`${chalk.red('Invalid Reference:')} ${err.dataPath} ${err.message}`);
break;
case 'items':
console.error(`${chalk.red('Array Item Type Mismatch:')} ${err.dataPath} ${err.message}`);
break;
case 'format':
console.error(`${chalk.red('Invalid Format:')} ${err.dataPath} should match format ${err.params.format}`);
break;
default:
console.error(`${chalk.red('Validation Error:')} ${err.dataPath} ${err.message}`);
}
});
}
if (this.verbose || !valid) {
const validColor = valid ? chalk.green : chalk.red;
console.log(`${relPath} Valid: ${validColor(valid)}`);
}
result = result && valid;
if (!valid && this.stopOnError) break;
}
if (!this.verbose && result) {
console.log(chalk.green('Validation passed!'));
}
return result;
}
}
function main(flags, files) {
const args = process.argv.slice(2)
flags = (flags === undefined) ? args.filter(arg => arg.startsWith('-')) : flags;
files = (files === undefined) ? args.filter(arg => !arg.startsWith('-')) : files;
const validator = new Validator(flags);
const result = validator.run(files);
if (flags.includes('--ci')) {
process.exit(result ? 0 : 1);
}
}
if (require.main === module) {
main();
}
module.exports = main;
module.exports.Validator = Validator;