feat(source): formalize source data with Document and Element
This commit is contained in:
parent
a3f0c0b61d
commit
1fa386581e
18
src/app.ts
18
src/app.ts
|
@ -1,13 +1,15 @@
|
|||
import { createLogger } from 'bunyan';
|
||||
import yargs from 'yargs';
|
||||
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { CONFIG_ARGS_NAME, CONFIG_ARGS_PATH, MODE, parseArgs } from './config/args.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { YamlParser } from './parser/YamlParser.js';
|
||||
import { createRuleSelector, createRuleSources, loadRules, resolveRules, validateConfig } from './rule/index.js';
|
||||
import { RuleVisitor } from './visitor/RuleVisitor.js';
|
||||
import { createRuleSources, loadRules } from './rule/load.js';
|
||||
import { createRuleSelector, resolveRules } from './rule/resolve.js';
|
||||
import { validateConfig } from './rule/validate.js';
|
||||
import { readSource, writeSource } from './source.js';
|
||||
import { VERSION_INFO } from './version.js';
|
||||
import { RuleVisitor } from './visitor/RuleVisitor.js';
|
||||
import { VisitorContext } from './visitor/VisitorContext.js';
|
||||
|
||||
const ARGS_START = 2;
|
||||
|
@ -63,7 +65,11 @@ export async function main(argv: Array<string>): Promise<number> {
|
|||
|
||||
// load source
|
||||
const parser = new YamlParser();
|
||||
const source = await readSource(args.source);
|
||||
const sourceData = await readSource(args.source);
|
||||
const source = {
|
||||
data: sourceData,
|
||||
path: args.source,
|
||||
};
|
||||
const docs = parser.parse(source);
|
||||
|
||||
const visitor = new RuleVisitor({
|
||||
|
@ -71,7 +77,9 @@ export async function main(argv: Array<string>): Promise<number> {
|
|||
});
|
||||
|
||||
for (const root of docs) {
|
||||
await visitor.visit(ctx, root);
|
||||
for (const rule of activeRules) {
|
||||
await visitor.visitAll(ctx, rule, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.errors.length === 0) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import yargs, { Options } from 'yargs';
|
||||
|
||||
import { RuleSelector, RuleSources } from '../rule/index.js';
|
||||
import { RuleSources } from '../rule/load.js';
|
||||
import { RuleSelector } from '../rule/resolve.js';
|
||||
import { VERSION_INFO } from '../version.js';
|
||||
|
||||
export enum MODE {
|
||||
|
|
|
@ -68,7 +68,10 @@ export async function loadConfig(name: string, ...extras: Array<string>): Promis
|
|||
const data = await readConfig(p);
|
||||
if (doesExist(data)) {
|
||||
const parser = new YamlParser();
|
||||
const [head] = parser.parse(data);
|
||||
const [head] = parser.parse({
|
||||
data,
|
||||
path: p,
|
||||
});
|
||||
|
||||
/* eslint-disable-next-line sonarjs/prefer-immediate-return,@typescript-eslint/no-explicit-any */
|
||||
return head as any; // TODO: validate config
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import { Source } from '../source';
|
||||
|
||||
export interface Loader {
|
||||
load(path: string): Source;
|
||||
}
|
||||
|
||||
class FileLoader { }
|
||||
class FetchLoader { }
|
||||
class ImportLoader { }
|
||||
class StreamLoader { }
|
|
@ -2,11 +2,10 @@ import { createInclude, createSchema } from '@apextoaster/js-yaml-schema';
|
|||
import { existsSync, readFileSync, realpathSync } from 'fs';
|
||||
import { DEFAULT_SCHEMA, dump, loadAll, Schema } from 'js-yaml';
|
||||
import { join } from 'path';
|
||||
import { Document, Source } from '../source.js';
|
||||
|
||||
import { Parser } from './index.js';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export class YamlParser implements Parser {
|
||||
protected schema: Schema;
|
||||
|
||||
|
@ -37,9 +36,12 @@ export class YamlParser implements Parser {
|
|||
return docs.join('\n---\n\n');
|
||||
}
|
||||
|
||||
public parse(body: string): Array<unknown> {
|
||||
const docs: Array<unknown> = [];
|
||||
loadAll(body, (doc: unknown) => docs.push(doc), {
|
||||
public parse(source: Source): Array<Document> {
|
||||
const docs: Array<Document> = [];
|
||||
loadAll(source.data, (data: unknown) => docs.push({
|
||||
data,
|
||||
source,
|
||||
}), {
|
||||
schema: this.schema,
|
||||
});
|
||||
return docs;
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
import { Document, Source } from '../source';
|
||||
|
||||
export interface Parser {
|
||||
dump(...data: Array<unknown>): string;
|
||||
parse(body: string): Array<unknown>;
|
||||
/**
|
||||
* @todo should dump deal with Sources as well? does it care about paths?
|
||||
*/
|
||||
dump(...data: Array<Document>): string;
|
||||
parse(data: Source): Array<Document>;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
interface RuleResult {}
|
||||
import { RuleResult } from '../rule';
|
||||
|
||||
interface Reporter {
|
||||
export interface Reporter {
|
||||
report(results: Array<RuleResult>): Promise<void>;
|
||||
}
|
||||
|
||||
class SummaryReporter { }
|
||||
class TableReporter { }
|
||||
class YamlReporter { }
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"foo": {}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
foo: {}
|
|
@ -3,9 +3,8 @@ import { ErrorObject, ValidateFunction } from 'ajv';
|
|||
import lodash from 'lodash';
|
||||
import { LogLevel } from 'noicejs';
|
||||
|
||||
import { Visitor, VisitorError, VisitorResult } from '../visitor/index.js';
|
||||
import { VisitorContext } from '../visitor/VisitorContext.js';
|
||||
import { Rule, RuleData } from './index.js';
|
||||
import { Rule, RuleData, RuleError, RuleResult } from './index.js';
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/unbound-method */
|
||||
const { cloneDeep, defaultTo } = lodash;
|
||||
|
@ -14,7 +13,7 @@ const { cloneDeep, defaultTo } = lodash;
|
|||
|
||||
const DEFAULT_FILTER = () => true;
|
||||
|
||||
export class SchemaRule implements Rule, RuleData, Visitor {
|
||||
export class SchemaRule implements Rule, RuleData {
|
||||
public readonly check: ValidateFunction;
|
||||
public readonly desc: string;
|
||||
public readonly filter?: ValidateFunction;
|
||||
|
@ -47,13 +46,13 @@ export class SchemaRule implements Rule, RuleData, Visitor {
|
|||
return items;
|
||||
}
|
||||
|
||||
public async visit(ctx: VisitorContext, node: any): Promise<VisitorResult> {
|
||||
public async visit(ctx: VisitorContext, node: any): Promise<RuleResult> {
|
||||
ctx.logger.debug({ item: node, rule: this }, 'visiting node');
|
||||
|
||||
const check = ctx.compile(this.check);
|
||||
const filter = this.compileFilter(ctx);
|
||||
const errors: Array<VisitorError> = [];
|
||||
const result: VisitorResult = {
|
||||
const errors: Array<RuleError> = [];
|
||||
const result: RuleResult = {
|
||||
changes: [],
|
||||
errors,
|
||||
};
|
||||
|
@ -79,7 +78,7 @@ export class SchemaRule implements Rule, RuleData, Visitor {
|
|||
}
|
||||
}
|
||||
|
||||
export function friendlyError(ctx: VisitorContext, err: ErrorObject): VisitorError {
|
||||
export function friendlyError(ctx: VisitorContext, err: ErrorObject): RuleError {
|
||||
return {
|
||||
data: {
|
||||
err,
|
||||
|
|
|
@ -1,25 +1,10 @@
|
|||
import { doesExist, ensureArray, NotFoundError } from '@apextoaster/js-utils';
|
||||
import { ValidateFunction } from 'ajv';
|
||||
import { readFileSync } from 'fs';
|
||||
import lodash from 'lodash';
|
||||
import minimatch from 'minimatch';
|
||||
import { Diff } from 'deep-diff';
|
||||
import { LogLevel } from 'noicejs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { dirName } from '../config/index.js';
|
||||
import { YamlParser } from '../parser/YamlParser.js';
|
||||
import { listFiles, readSource } from '../source.js';
|
||||
import { VisitorResult } from '../visitor/index.js';
|
||||
import { Document, Element } from '../source.js';
|
||||
import { VisitorContext } from '../visitor/VisitorContext.js';
|
||||
import { SchemaRule } from './SchemaRule.js';
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/unbound-method */
|
||||
const { intersection } = lodash;
|
||||
const { Minimatch } = minimatch;
|
||||
|
||||
// import ruleSchemaData from '../../rules/salty-dog.yml';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export interface RuleData {
|
||||
// metadata
|
||||
desc: string;
|
||||
|
@ -27,13 +12,13 @@ export interface RuleData {
|
|||
name: string;
|
||||
tags: Array<string>;
|
||||
// data
|
||||
check: any;
|
||||
filter?: any;
|
||||
check: unknown;
|
||||
filter?: unknown;
|
||||
select: string;
|
||||
}
|
||||
/* tslint:enable:no-any */
|
||||
|
||||
export type Validator = ValidateFunction;
|
||||
|
||||
export interface Rule {
|
||||
check: Validator;
|
||||
desc?: string;
|
||||
|
@ -43,246 +28,28 @@ export interface Rule {
|
|||
select: string;
|
||||
tags: Array<string>;
|
||||
|
||||
pick(ctx: VisitorContext, root: any): Promise<Array<any>>;
|
||||
visit(ctx: VisitorContext, item: any): Promise<VisitorResult>;
|
||||
pick(ctx: VisitorContext, root: Document): Promise<Array<Element>>;
|
||||
visit(ctx: VisitorContext, item: Element): Promise<RuleResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule selector derived from arguments.
|
||||
*
|
||||
* The `excludeFoo`/`includeFoo`/ names match yargs output structure.
|
||||
*/
|
||||
export interface RuleSelector {
|
||||
excludeLevel: Array<LogLevel>;
|
||||
excludeName: Array<string>;
|
||||
excludeTag: Array<string>;
|
||||
includeLevel: Array<LogLevel>;
|
||||
includeName: Array<string>;
|
||||
includeTag: Array<string>;
|
||||
export interface RuleChange {
|
||||
data: Element;
|
||||
diff: Diff<unknown, unknown>;
|
||||
rule: Rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule sources derived from arguments.
|
||||
*
|
||||
* The `ruleFoo` names match yargs output structure.
|
||||
*/
|
||||
export interface RuleSources {
|
||||
ruleFile: Array<string>;
|
||||
ruleModule: Array<string>;
|
||||
rulePath: Array<string>;
|
||||
export interface RuleError {
|
||||
data: Element;
|
||||
level: LogLevel;
|
||||
msg: string;
|
||||
rule: Rule;
|
||||
}
|
||||
|
||||
export interface RuleSourceData {
|
||||
definitions?: Record<string, any>;
|
||||
name: string;
|
||||
rules: Array<RuleData>;
|
||||
export interface RuleResult {
|
||||
changes: ReadonlyArray<RuleChange>;
|
||||
errors: ReadonlyArray<RuleError>;
|
||||
}
|
||||
|
||||
export interface RuleSourceModule {
|
||||
definitions?: Record<string, any>;
|
||||
name: string;
|
||||
rules: Array<Rule | RuleData>;
|
||||
}
|
||||
|
||||
export function createRuleSelector(options: Partial<RuleSelector>): RuleSelector {
|
||||
return {
|
||||
excludeLevel: ensureArray(options.excludeLevel),
|
||||
excludeName: ensureArray(options.excludeName),
|
||||
excludeTag: ensureArray(options.excludeTag),
|
||||
includeLevel: ensureArray(options.includeLevel),
|
||||
includeName: ensureArray(options.includeName),
|
||||
includeTag: ensureArray(options.includeTag),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuleSources(options: Partial<RuleSources>): RuleSources {
|
||||
return {
|
||||
ruleFile: ensureArray(options.ruleFile),
|
||||
ruleModule: ensureArray(options.ruleModule),
|
||||
rulePath: ensureArray(options.rulePath),
|
||||
};
|
||||
}
|
||||
|
||||
export function isPOJSO(val: any): val is RuleData {
|
||||
export function isPOJSO(val: object): val is RuleData {
|
||||
return Reflect.getPrototypeOf(val) === Reflect.getPrototypeOf({});
|
||||
}
|
||||
|
||||
export async function loadRuleSource(data: RuleSourceModule, ctx: VisitorContext): Promise<Array<Rule>> {
|
||||
if (doesExist(data.definitions)) {
|
||||
ctx.addSchema(data.name, data.definitions);
|
||||
}
|
||||
|
||||
return data.rules.map((it: Rule | RuleData) => {
|
||||
if (isPOJSO(it)) {
|
||||
return new SchemaRule(it);
|
||||
} else {
|
||||
return it;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadRuleFiles(paths: Array<string>, ctx: VisitorContext): Promise<Array<Rule>> {
|
||||
const parser = new YamlParser();
|
||||
const rules = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const contents = await readSource(path);
|
||||
|
||||
const docs = parser.parse(contents) as Array<RuleSourceData>;
|
||||
|
||||
for (const data of docs) {
|
||||
if (!validateRules(ctx, data)) {
|
||||
ctx.logger.error({
|
||||
file: data,
|
||||
path,
|
||||
}, 'error loading rule file');
|
||||
continue;
|
||||
}
|
||||
|
||||
rules.push(...await loadRuleSource(data, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
export async function loadRulePaths(paths: Array<string>, ctx: VisitorContext): Promise<Array<Rule>> {
|
||||
const match = new Minimatch('**/*.+(json|yaml|yml)', {
|
||||
nocase: true,
|
||||
});
|
||||
const rules = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const allFiles = await listFiles(path);
|
||||
// skip files that start with `.`, limit to json and yaml/yml
|
||||
const files = allFiles
|
||||
.filter((name) => name[0] !== '.')
|
||||
.filter((name) => match.match(name));
|
||||
|
||||
ctx.logger.debug({ files }, 'path matched rule files');
|
||||
|
||||
const pathRules = await loadRuleFiles(files, ctx);
|
||||
rules.push(...pathRules);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
type LoadBack = (path: string) => Promise<unknown>;
|
||||
|
||||
export async function importRuleModule(path: string, load?: LoadBack) {
|
||||
if (doesExist(load)) {
|
||||
return load(path);
|
||||
} else {
|
||||
return import(path);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRuleModules(modules: Array<string>, ctx: VisitorContext, load?: LoadBack): Promise<Array<Rule>> {
|
||||
const rules = [];
|
||||
|
||||
for (const name of modules) {
|
||||
try {
|
||||
const data: RuleSourceModule = await importRuleModule(name, load);
|
||||
if (!validateRules(ctx, data)) {
|
||||
ctx.logger.error({
|
||||
module: name,
|
||||
}, 'error loading rule module');
|
||||
continue;
|
||||
}
|
||||
|
||||
rules.push(...await loadRuleSource(data, ctx));
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
ctx.logger.error(err, 'error loading rule module');
|
||||
} else {
|
||||
ctx.logger.error({ err }, 'unknown error type loading rule module');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
export async function loadRules(sources: RuleSources, ctx: VisitorContext): Promise<Array<Rule>> {
|
||||
return [
|
||||
...await loadRuleFiles(sources.ruleFile, ctx),
|
||||
...await loadRulePaths(sources.rulePath, ctx),
|
||||
...await loadRuleModules(sources.ruleModule, ctx),
|
||||
];
|
||||
}
|
||||
|
||||
export async function resolveRules(rules: Array<Rule>, selector: RuleSelector): Promise<Array<Rule>> {
|
||||
const activeRules = new Set<Rule>();
|
||||
|
||||
for (const r of rules) {
|
||||
let active = false;
|
||||
|
||||
const includedTags = intersection(selector.includeTag, r.tags);
|
||||
active = active || selector.includeLevel.includes(r.level);
|
||||
active = active || selector.includeName.includes(r.name);
|
||||
active = active || includedTags.length > 0;
|
||||
|
||||
active = active && !selector.excludeLevel.includes(r.level);
|
||||
active = active && !selector.excludeName.includes(r.name);
|
||||
const excludedTags = intersection(selector.excludeTag, r.tags);
|
||||
active = active && (excludedTags.length === 0);
|
||||
|
||||
if (active) {
|
||||
activeRules.add(r);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(activeRules);
|
||||
}
|
||||
|
||||
export function getSchemaPath(): string {
|
||||
if (doesExist(process.env.SALTY_DOG_SCHEMA)) {
|
||||
return process.env.SALTY_DOG_SCHEMA;
|
||||
} else {
|
||||
return join(dirName(), 'rules', 'salty-dog.yml');
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSchema(): any {
|
||||
const path = getSchemaPath();
|
||||
const data = readFileSync(path, { encoding: 'utf-8' });
|
||||
|
||||
if (doesExist(data)) {
|
||||
const parser = new YamlParser();
|
||||
const [schema] = parser.parse(data);
|
||||
return schema;
|
||||
}
|
||||
|
||||
throw new NotFoundError('loaded empty schema');
|
||||
}
|
||||
|
||||
export function validateRules(ctx: VisitorContext, root: any): boolean {
|
||||
const { definitions, name } = loadSchema();
|
||||
|
||||
const validCtx = new VisitorContext(ctx);
|
||||
validCtx.addSchema(name, definitions);
|
||||
const ruleSchema = validCtx.compile(definitions.source);
|
||||
|
||||
if (ruleSchema(root) === true) {
|
||||
return true;
|
||||
} else {
|
||||
ctx.logger.error({ errors: ruleSchema.errors }, 'error validating rules');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfig(ctx: VisitorContext, root: any): boolean {
|
||||
const { definitions, name } = loadSchema();
|
||||
|
||||
const validCtx = new VisitorContext(ctx);
|
||||
validCtx.addSchema(name, definitions);
|
||||
const ruleSchema = validCtx.compile(definitions.config);
|
||||
|
||||
if (ruleSchema(root) === true) {
|
||||
return true;
|
||||
} else {
|
||||
ctx.logger.error({ errors: ruleSchema.errors }, 'error validating rules');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
import { doesExist, ensureArray } from '@apextoaster/js-utils';
|
||||
import minimatch from 'minimatch';
|
||||
|
||||
import { isPOJSO, Rule, RuleData } from '.';
|
||||
import { YamlParser } from '../parser/YamlParser';
|
||||
import { listFiles, readSource } from '../source';
|
||||
import { VisitorContext } from '../visitor/VisitorContext';
|
||||
import { SchemaRule } from './SchemaRule';
|
||||
import { validateRules } from './validate';
|
||||
|
||||
const { Minimatch } = minimatch;
|
||||
|
||||
export function createRuleSources(options: Partial<RuleSources>): RuleSources {
|
||||
return {
|
||||
ruleFile: ensureArray(options.ruleFile),
|
||||
ruleModule: ensureArray(options.ruleModule),
|
||||
rulePath: ensureArray(options.rulePath),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule sources derived from arguments.
|
||||
*
|
||||
* The `ruleFoo` names match yargs output structure.
|
||||
*/
|
||||
export interface RuleSources {
|
||||
ruleFile: Array<string>;
|
||||
ruleModule: Array<string>;
|
||||
rulePath: Array<string>;
|
||||
}
|
||||
|
||||
export interface RuleSourceData {
|
||||
definitions?: Record<string, unknown>;
|
||||
name: string;
|
||||
rules: Array<RuleData>;
|
||||
}
|
||||
|
||||
export interface RuleSourceModule {
|
||||
definitions?: Record<string, unknown>;
|
||||
name: string;
|
||||
rules: Array<Rule | RuleData>;
|
||||
}
|
||||
|
||||
|
||||
export async function loadRuleSource(data: RuleSourceModule, ctx: VisitorContext): Promise<Array<Rule>> {
|
||||
if (doesExist(data.definitions)) {
|
||||
ctx.addSchema(data.name, data.definitions);
|
||||
}
|
||||
|
||||
return data.rules.map((it: Rule | RuleData) => {
|
||||
if (isPOJSO(it)) {
|
||||
return new SchemaRule(it);
|
||||
} else {
|
||||
return it;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadRuleFiles(paths: Array<string>, ctx: VisitorContext): Promise<Array<Rule>> {
|
||||
const parser = new YamlParser();
|
||||
const rules = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const contents = await readSource(path);
|
||||
const source = {
|
||||
data: contents,
|
||||
path,
|
||||
};
|
||||
|
||||
const docs = parser.parse(source);
|
||||
|
||||
for (const doc of docs) {
|
||||
const data = doc.data as RuleSourceData;
|
||||
if (!validateRules(ctx, data)) {
|
||||
ctx.logger.error({
|
||||
file: data,
|
||||
path,
|
||||
}, 'error loading rule file');
|
||||
continue;
|
||||
}
|
||||
|
||||
rules.push(...await loadRuleSource(data, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
export async function loadRulePaths(paths: Array<string>, ctx: VisitorContext): Promise<Array<Rule>> {
|
||||
const match = new Minimatch('**/*.+(json|yaml|yml)', {
|
||||
nocase: true,
|
||||
});
|
||||
const rules = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const allFiles = await listFiles(path);
|
||||
// skip files that start with `.`, limit to json and yaml/yml
|
||||
const files = allFiles
|
||||
.filter((name) => name[0] !== '.')
|
||||
.filter((name) => match.match(name));
|
||||
|
||||
ctx.logger.debug({ files }, 'path matched rule files');
|
||||
|
||||
const pathRules = await loadRuleFiles(files, ctx);
|
||||
rules.push(...pathRules);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
type LoadBack = (path: string) => Promise<unknown>;
|
||||
|
||||
export async function importRuleModule(path: string, load?: LoadBack) {
|
||||
if (doesExist(load)) {
|
||||
return load(path);
|
||||
} else {
|
||||
return import(path);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRuleModules(modules: Array<string>, ctx: VisitorContext, load?: LoadBack): Promise<Array<Rule>> {
|
||||
const rules = [];
|
||||
|
||||
for (const name of modules) {
|
||||
try {
|
||||
const data: RuleSourceModule = await importRuleModule(name, load);
|
||||
if (!validateRules(ctx, data)) {
|
||||
ctx.logger.error({
|
||||
module: name,
|
||||
}, 'error loading rule module');
|
||||
continue;
|
||||
}
|
||||
|
||||
rules.push(...await loadRuleSource(data, ctx));
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
ctx.logger.error(err, 'error loading rule module');
|
||||
} else {
|
||||
ctx.logger.error({ err }, 'unknown error type loading rule module');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
export async function loadRules(sources: RuleSources, ctx: VisitorContext): Promise<Array<Rule>> {
|
||||
return [
|
||||
...await loadRuleFiles(sources.ruleFile, ctx),
|
||||
...await loadRulePaths(sources.rulePath, ctx),
|
||||
...await loadRuleModules(sources.ruleModule, ctx),
|
||||
];
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { ensureArray } from '@apextoaster/js-utils';
|
||||
import lodash from 'lodash';
|
||||
import { LogLevel } from 'noicejs';
|
||||
import { Rule } from '.';
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/unbound-method */
|
||||
const { intersection } = lodash;
|
||||
|
||||
/**
|
||||
* Rule selector derived from arguments.
|
||||
*
|
||||
* The `excludeFoo`/`includeFoo`/ names match yargs output structure.
|
||||
*/
|
||||
export interface RuleSelector {
|
||||
excludeLevel: Array<LogLevel>;
|
||||
excludeName: Array<string>;
|
||||
excludeTag: Array<string>;
|
||||
includeLevel: Array<LogLevel>;
|
||||
includeName: Array<string>;
|
||||
includeTag: Array<string>;
|
||||
}
|
||||
|
||||
export function createRuleSelector(options: Partial<RuleSelector>): RuleSelector {
|
||||
return {
|
||||
excludeLevel: ensureArray(options.excludeLevel),
|
||||
excludeName: ensureArray(options.excludeName),
|
||||
excludeTag: ensureArray(options.excludeTag),
|
||||
includeLevel: ensureArray(options.includeLevel),
|
||||
includeName: ensureArray(options.includeName),
|
||||
includeTag: ensureArray(options.includeTag),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveRules(rules: Array<Rule>, selector: RuleSelector): Promise<Array<Rule>> {
|
||||
const activeRules = new Set<Rule>();
|
||||
|
||||
for (const r of rules) {
|
||||
let active = false;
|
||||
|
||||
const includedTags = intersection(selector.includeTag, r.tags);
|
||||
active = active || selector.includeLevel.includes(r.level);
|
||||
active = active || selector.includeName.includes(r.name);
|
||||
active = active || includedTags.length > 0;
|
||||
|
||||
active = active && !selector.excludeLevel.includes(r.level);
|
||||
active = active && !selector.excludeName.includes(r.name);
|
||||
const excludedTags = intersection(selector.excludeTag, r.tags);
|
||||
active = active && (excludedTags.length === 0);
|
||||
|
||||
if (active) {
|
||||
activeRules.add(r);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(activeRules);
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { doesExist, mustExist, NotFoundError } from '@apextoaster/js-utils';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { dirName } from '../config';
|
||||
import { YamlParser } from '../parser/YamlParser';
|
||||
import { VisitorContext } from '../visitor/VisitorContext';
|
||||
import { RuleSourceData } from './load';
|
||||
|
||||
export function loadSchema(): RuleSourceData {
|
||||
const path = join(dirName(), 'rules', 'salty-dog.yml');
|
||||
const data = readFileSync(path, { encoding: 'utf-8' });
|
||||
|
||||
if (doesExist(data)) {
|
||||
const parser = new YamlParser();
|
||||
const [schema] = parser.parse({
|
||||
data,
|
||||
path,
|
||||
});
|
||||
return mustExist(schema.data) as RuleSourceData;
|
||||
}
|
||||
|
||||
throw new NotFoundError('loaded empty schema');
|
||||
}
|
||||
|
||||
export function validateRules(ctx: VisitorContext, root: unknown): boolean {
|
||||
const { definitions: defs, name } = loadSchema();
|
||||
const definitions = mustExist(defs);
|
||||
|
||||
const validCtx = new VisitorContext(ctx);
|
||||
validCtx.addSchema(name, definitions);
|
||||
const ruleSchema = validCtx.compile(mustExist(definitions.source));
|
||||
|
||||
if (ruleSchema(root) === true) {
|
||||
return true;
|
||||
} else {
|
||||
ctx.logger.error({ errors: ruleSchema.errors }, 'error validating rules');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfig(ctx: VisitorContext, root: unknown): boolean {
|
||||
const { definitions: defs, name } = loadSchema();
|
||||
const definitions = mustExist(defs);
|
||||
|
||||
const validCtx = new VisitorContext(ctx);
|
||||
validCtx.addSchema(name, definitions);
|
||||
const ruleSchema = validCtx.compile(mustExist(definitions.config));
|
||||
|
||||
if (ruleSchema(root) === true) {
|
||||
return true;
|
||||
} else {
|
||||
ctx.logger.error({ errors: ruleSchema.errors }, 'error validating rules');
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -9,6 +9,44 @@ export const FILE_ENCODING = 'utf-8';
|
|||
|
||||
export type Filesystem = Pick<typeof promises, 'readdir' | 'readFile' | 'writeFile'>;
|
||||
|
||||
export interface Source {
|
||||
data: string;
|
||||
|
||||
/**
|
||||
* Path from which this source was loaded.
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
/**
|
||||
* Document data.
|
||||
*/
|
||||
data: unknown;
|
||||
|
||||
/**
|
||||
* Original source.
|
||||
*/
|
||||
source: Source;
|
||||
}
|
||||
|
||||
export interface Element {
|
||||
/**
|
||||
* Element data.
|
||||
*/
|
||||
data: unknown;
|
||||
|
||||
/**
|
||||
* Containing document, *not* the immediate parent.
|
||||
*/
|
||||
document: Document;
|
||||
|
||||
/**
|
||||
* Position within a set of selected elements.
|
||||
*/
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for tests to override the fs fns.
|
||||
*/
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { hasItems } from '@apextoaster/js-utils';
|
||||
import deepDiff from 'deep-diff';
|
||||
import EventEmitter from 'events';
|
||||
import lodash from 'lodash';
|
||||
|
||||
import { Rule } from '../rule/index.js';
|
||||
import { Rule, RuleResult } from '../rule/index.js';
|
||||
import { Document, Element } from '../source.js';
|
||||
import { Visitor } from './index.js';
|
||||
import { VisitorContext } from './VisitorContext.js';
|
||||
|
||||
|
@ -16,58 +18,54 @@ export interface RuleVisitorOptions {
|
|||
rules: ReadonlyArray<Rule>;
|
||||
}
|
||||
|
||||
export class RuleVisitor implements RuleVisitorOptions, Visitor {
|
||||
export class RuleVisitor extends EventEmitter implements RuleVisitorOptions, Visitor {
|
||||
public readonly rules: ReadonlyArray<Rule>;
|
||||
|
||||
constructor(options: RuleVisitorOptions) {
|
||||
super();
|
||||
|
||||
this.rules = Array.from(options.rules);
|
||||
}
|
||||
|
||||
public async pick(ctx: VisitorContext, root: any): Promise<Array<any>> {
|
||||
return []; // TODO: why is this part of visitor rather than rule?
|
||||
public async pick(ctx: VisitorContext, rule: Rule, root: Document): Promise<Array<Element>> {
|
||||
return rule.pick(ctx, root);
|
||||
}
|
||||
|
||||
public async visit(ctx: VisitorContext, root: any): Promise<VisitorContext> {
|
||||
for (const rule of this.rules) {
|
||||
const items = await rule.pick(ctx, root);
|
||||
let itemIndex = 0;
|
||||
for (const item of items) {
|
||||
ctx.visitData = {
|
||||
itemIndex,
|
||||
rule,
|
||||
};
|
||||
public async visit(ctx: VisitorContext, rule: Rule, elem: Element): Promise<RuleResult> {
|
||||
const results = {
|
||||
changes: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
await this.visitItem(ctx, item, itemIndex, rule);
|
||||
itemIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public async visitItem(ctx: VisitorContext, item: any, itemIndex: number, rule: Rule): Promise<void> {
|
||||
const itemResult = cloneDeep(item);
|
||||
const ruleResult = await rule.visit(ctx, itemResult);
|
||||
const elemResult = cloneDeep(elem);
|
||||
const ruleResult = await rule.visit(ctx, elemResult);
|
||||
|
||||
if (hasItems(ruleResult.errors)) {
|
||||
ctx.logger.warn({ count: ruleResult.errors.length, rule }, 'rule failed');
|
||||
ctx.mergeResult(ruleResult, ctx.visitData);
|
||||
return;
|
||||
return results;
|
||||
}
|
||||
|
||||
const itemDiff = diff(item, itemResult);
|
||||
const itemDiff = diff(elem, elemResult);
|
||||
if (hasItems(itemDiff)) {
|
||||
ctx.logger.info({
|
||||
diff: itemDiff,
|
||||
item,
|
||||
item: elem,
|
||||
rule: rule.name,
|
||||
}, 'rule passed with modifications');
|
||||
|
||||
if (ctx.schemaOptions.mutate) {
|
||||
applyDiff(item, itemResult);
|
||||
applyDiff(elem, elemResult);
|
||||
}
|
||||
} else {
|
||||
ctx.logger.info({ rule: rule.name }, 'rule passed');
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async visitAll(ctx: VisitorContext, rule: Rule, doc: Document): Promise<Array<RuleResult>> {
|
||||
const elems = await this.pick(ctx, rule, doc);
|
||||
return Promise.all(elems.map((e) => this.visit(ctx, rule, e)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import Ajv, { ValidateFunction } from 'ajv';
|
|||
import { JSONPath } from 'jsonpath-plus';
|
||||
import { Logger } from 'noicejs';
|
||||
|
||||
import { VisitorError, VisitorResult } from './index.js';
|
||||
import { RuleChange, RuleError, RuleResult } from '../rule';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
|
@ -18,20 +18,20 @@ export interface VisitorContextOptions {
|
|||
schemaOptions: RuleOptions;
|
||||
}
|
||||
|
||||
export class VisitorContext implements VisitorContextOptions, VisitorResult {
|
||||
export class VisitorContext implements VisitorContextOptions, RuleResult {
|
||||
public readonly logger: Logger;
|
||||
public readonly schemaOptions: RuleOptions;
|
||||
|
||||
protected readonly ajv: Ajv;
|
||||
protected readonly changeBuffer: Array<any>;
|
||||
protected readonly errorBuffer: Array<VisitorError>;
|
||||
protected readonly changeBuffer: Array<RuleChange>;
|
||||
protected readonly errorBuffer: Array<RuleError>;
|
||||
protected data: any;
|
||||
|
||||
public get changes(): ReadonlyArray<any> {
|
||||
public get changes(): ReadonlyArray<RuleChange> {
|
||||
return this.changeBuffer;
|
||||
}
|
||||
|
||||
public get errors(): ReadonlyArray<VisitorError> {
|
||||
public get errors(): ReadonlyArray<RuleError> {
|
||||
return this.errorBuffer;
|
||||
}
|
||||
|
||||
|
@ -65,7 +65,7 @@ export class VisitorContext implements VisitorContextOptions, VisitorResult {
|
|||
return this.ajv.compile(schema);
|
||||
}
|
||||
|
||||
public mergeResult(other: VisitorResult, data: any = {}): this {
|
||||
public mergeResult(other: RuleResult, data: any = {}): this {
|
||||
this.changeBuffer.push(...other.changes);
|
||||
this.errorBuffer.push(...other.errors.map((err) => ({
|
||||
...err,
|
||||
|
|
|
@ -1,32 +1,28 @@
|
|||
import { Diff } from 'deep-diff';
|
||||
import { LogLevel } from 'noicejs';
|
||||
import EventEmitter from 'events';
|
||||
|
||||
import { VisitorContext } from './VisitorContext.js';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Rule, RuleChange, RuleError, RuleResult } from '../rule/index.js';
|
||||
import { Document, Element } from '../source.js';
|
||||
|
||||
/**
|
||||
* This is a runtime error, not an exception.
|
||||
* @todo what does this need to contain? accumulated errors?
|
||||
* yes: since visit is called for each rule, this is what collects
|
||||
* results across all of the rules
|
||||
*/
|
||||
export interface VisitorError {
|
||||
data: any;
|
||||
level: LogLevel;
|
||||
msg: string;
|
||||
export interface Context {
|
||||
changes: ReadonlyArray<RuleChange>;
|
||||
errors: ReadonlyArray<RuleError>;
|
||||
}
|
||||
|
||||
export interface VisitorResult {
|
||||
changes: ReadonlyArray<Diff<any, any>>;
|
||||
errors: ReadonlyArray<VisitorError>;
|
||||
}
|
||||
|
||||
export interface Visitor<TResult extends VisitorResult = VisitorResult> {
|
||||
export interface Visitor extends EventEmitter {
|
||||
/**
|
||||
* Select nodes eligible to be visited.
|
||||
*/
|
||||
pick(ctx: VisitorContext, root: any): Promise<Array<any>>;
|
||||
pick(ctx: Context, rule: Rule, doc: Document): Promise<Array<Element>>;
|
||||
|
||||
/**
|
||||
* Visit a node.
|
||||
*/
|
||||
visit(ctx: VisitorContext, node: any): Promise<TResult>;
|
||||
visit(ctx: Context, rule: Rule, elem: Element): Promise<RuleResult>;
|
||||
|
||||
visitAll(ctx: Context, rule: Rule, doc: Document): Promise<Array<RuleResult>>;
|
||||
}
|
||||
|
|
|
@ -15,11 +15,14 @@ describe('yaml parser', () => {
|
|||
describe('parse documents', () => {
|
||||
it('should parse multiple documents', async () => {
|
||||
const parser = new YamlParser();
|
||||
const data = parser.parse(`
|
||||
const data = parser.parse({
|
||||
data: `
|
||||
foo: {}
|
||||
---
|
||||
bar: {}
|
||||
`);
|
||||
`,
|
||||
path: '',
|
||||
});
|
||||
|
||||
expect(Array.isArray(data)).to.equal(true);
|
||||
const EXPECTED_DOCS = 2;
|
||||
|
|
|
@ -4,7 +4,7 @@ import { LogLevel, NullLogger } from 'noicejs';
|
|||
import { spy, stub } from 'sinon';
|
||||
|
||||
import { dirName } from '../../src/config/index.js';
|
||||
import { loadRuleFiles, loadRuleModules, loadRulePaths, loadRuleSource } from '../../src/rule/index.js';
|
||||
import { loadRuleFiles, loadRuleModules, loadRulePaths, loadRuleSource } from '../../src/rule/load.js';
|
||||
import { SchemaRule } from '../../src/rule/SchemaRule.js';
|
||||
import { Filesystem, setFs } from '../../src/source.js';
|
||||
import { VisitorContext } from '../../src/visitor/VisitorContext.js';
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { expect } from 'chai';
|
||||
import { ConsoleLogger, LogLevel, NullLogger } from 'noicejs';
|
||||
|
||||
import { createRuleSelector, createRuleSources, resolveRules, validateRules } from '../../src/rule/index.js';
|
||||
import { createRuleSources } from '../../src/rule/load.js';
|
||||
import { createRuleSelector, resolveRules } from '../../src/rule/resolve.js';
|
||||
import { SchemaRule } from '../../src/rule/SchemaRule.js';
|
||||
import { validateRules } from '../../src/rule/validate.js';
|
||||
import { VisitorContext } from '../../src/visitor/VisitorContext.js';
|
||||
|
||||
const TEST_RULES = [new SchemaRule({
|
||||
|
|
|
@ -36,7 +36,7 @@ describe('rule visitor', async () => {
|
|||
const visitor = new RuleVisitor({
|
||||
rules: [rule],
|
||||
});
|
||||
await visitor.visit(ctx, {});
|
||||
await visitor.visit(ctx, rule, {});
|
||||
|
||||
mockRule.verify();
|
||||
expect(ctx.errors.length).to.equal(0);
|
||||
|
@ -74,7 +74,7 @@ describe('rule visitor', async () => {
|
|||
const visitor = new RuleVisitor({
|
||||
rules: [rule],
|
||||
});
|
||||
await visitor.visit(ctx, {});
|
||||
await visitor.visit(ctx, rule, {});
|
||||
|
||||
mockRule.verify();
|
||||
expect(ctx.errors.length).to.equal(0);
|
||||
|
@ -110,7 +110,7 @@ describe('rule visitor', async () => {
|
|||
const visitor = new RuleVisitor({
|
||||
rules: [rule],
|
||||
});
|
||||
await visitor.visit(ctx, data);
|
||||
await visitor.visit(ctx, rule, data);
|
||||
|
||||
expect(pickSpy).to.have.callCount(1).and.to.have.been.calledWithExactly(ctx, data);
|
||||
expect(visitStub).to.have.callCount(data.foo.length);
|
||||
|
@ -149,7 +149,7 @@ describe('rule visitor', async () => {
|
|||
const visitor = new RuleVisitor({
|
||||
rules: [rule],
|
||||
});
|
||||
await visitor.visit(ctx, data);
|
||||
await visitor.visit(ctx, rule, data);
|
||||
|
||||
const EXPECTED_VISITS = 3;
|
||||
expect(visitStub).to.have.callCount(EXPECTED_VISITS);
|
||||
|
@ -169,6 +169,6 @@ describe('rule visitor', async () => {
|
|||
rules: [],
|
||||
});
|
||||
|
||||
return expect(visitor.pick(ctx, {})).to.eventually.deep.equal([]);
|
||||
return expect(visitor.pick(ctx, rule, {})).to.eventually.deep.equal([]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,15 +16,38 @@ describe('visitor context', () => {
|
|||
|
||||
const nextCtx = firstCtx.mergeResult({
|
||||
changes: [{
|
||||
kind: 'N',
|
||||
rhs: {},
|
||||
data: {
|
||||
data: {},
|
||||
document: {
|
||||
data: {},
|
||||
source: {
|
||||
path: '',
|
||||
},
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
diff: {
|
||||
kind: 'N',
|
||||
rhs: {},
|
||||
},
|
||||
rule: new NoopRule(),
|
||||
}],
|
||||
errors: [{
|
||||
data: {
|
||||
foo: 2,
|
||||
data: {
|
||||
foo: 2,
|
||||
},
|
||||
document: {
|
||||
data: {},
|
||||
source: {
|
||||
path: '',
|
||||
},
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
level: LogLevel.Info,
|
||||
msg: 'uh oh',
|
||||
rule: new NoopRule(),
|
||||
}],
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue