Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: emit visitor events #163

Closed
wants to merge 9 commits into from
4 changes: 2 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { loadConfig } from './config';
import { CONFIG_ARGS_NAME, CONFIG_ARGS_PATH, MODE, parseArgs } from './config/args';
import { YamlParser } from './parser/YamlParser';
import { createRuleSelector, createRuleSources, loadRules, resolveRules, validateConfig } from './rule';
import { RuleVisitor } from './rule/RuleVisitor';
import { RuleVisitor, RuleVisitorData, RuleVisitorError } from './rule/RuleVisitor';
import { readSource, writeSource } from './source';
import { VERSION_INFO } from './version';
import { VisitorContext } from './visitor/VisitorContext';
Expand All @@ -29,7 +29,7 @@ export async function main(argv: Array<string>): Promise<number> {
logger.info({ args, mode }, 'main arguments');

// load rules
const ctx = new VisitorContext({
const ctx = new VisitorContext<RuleVisitorData, RuleVisitorError>({
logger,
schemaOptions: {
coerce: args.coerce,
Expand Down
117 changes: 90 additions & 27 deletions src/rule/RuleVisitor.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,132 @@
import { applyDiff, diff } from 'deep-diff';
import { applyDiff, Diff, diff } from 'deep-diff';
import { EventEmitter } from 'events';
import { cloneDeep } from 'lodash';

import { Rule } from '.';
import { hasItems } from '../utils';
import { Visitor } from '../visitor';
import { Visitor, VisitorResult } from '../visitor';
import { VisitorContext } from '../visitor/VisitorContext';

/* eslint-disable @typescript-eslint/no-explicit-any */

export interface RuleVisitorData {
item: unknown;
itemIndex: number;
rule: Rule;
ruleIndex: number;
}

export interface RuleVisitorError extends RuleVisitorData {
key: string;
path: string;
}

export interface RuleVisitorOptions {
rules: ReadonlyArray<Rule>;
}

export class RuleVisitor implements RuleVisitorOptions, Visitor {
export enum RuleVisitorEvents {
ITEM_DIFF = 'item-diff',
ITEM_ERROR = 'item-error',
ITEM_PASS = 'item-pass',
ITEM_VISIT = 'item-visit',
RULE_ERROR = 'rule-error',
RULE_PASS = 'rule-pass',
RULE_VISIT = 'rule-visit',
}

export type RuleVisitorContext = VisitorContext<RuleVisitorData, RuleVisitorError>;
export type RuleVisitorResult = VisitorResult<RuleVisitorError>;

export class RuleVisitor extends EventEmitter implements RuleVisitorOptions, Visitor<RuleVisitorData, RuleVisitorError> {
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>> {
public async pick(ctx: RuleVisitorContext, root: any): Promise<Array<any>> {
return []; // TODO: why is this part of visitor rather than rule?
}

public async visit(ctx: VisitorContext, root: any): Promise<VisitorContext> {
public async visit(ctx: RuleVisitorContext, root: any): Promise<RuleVisitorResult> {
let ruleIndex = 0;
for (const rule of this.rules) {
const items = await rule.pick(ctx, root);
const ruleData = {
rule,
};
this.emit(RuleVisitorEvents.RULE_VISIT, ruleData);

let itemIndex = 0;
let ruleErrors = 0;
const items = await rule.pick(ctx, root);
for (const item of items) {
ctx.visitData = {
const result = await this.visitItem(ctx, {
item,
itemIndex,
rule,
};
ruleIndex,
});
ctx.mergeResult(result);

await this.visitItem(ctx, item, itemIndex, rule);
ruleErrors += result.errors.length;
itemIndex += 1;
}

if (ruleErrors > 0) {
ctx.logger.warn({ rule: rule.name }, 'rule failed');
this.emit(RuleVisitorEvents.RULE_ERROR, ruleData);
} else {
ctx.logger.info({ rule: rule.name }, 'rule passed');
this.emit(RuleVisitorEvents.RULE_PASS, ruleData);
}

ruleIndex += 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);
public async visitItem(ctx: RuleVisitorContext, data: RuleVisitorData): Promise<RuleVisitorResult> {
ctx.visitData = data;

const itemResult = cloneDeep(data.item);
const ruleResult = await data.rule.visit(ctx, itemResult);

if (hasItems(ruleResult.errors)) {
ctx.logger.warn({ count: ruleResult.errors.length, rule }, 'rule failed');
ctx.mergeResult(ruleResult, ctx.visitData);
return;
const errorData = {
...data,
count: ruleResult.errors.length,
};
ctx.logger.warn(errorData, 'item failed');
this.emit(RuleVisitorEvents.ITEM_ERROR, errorData);

return ruleResult;
}

const itemDiff = diff(item, itemResult);
const itemDiff = diff(data.item, itemResult);
if (hasItems(itemDiff)) {
ctx.logger.info({
diff: itemDiff,
item,
rule: rule.name,
}, 'rule passed with modifications');

if (ctx.schemaOptions.mutate) {
applyDiff(item, itemResult);
}
} else {
ctx.logger.info({ rule: rule.name }, 'rule passed');
await this.visitDiff(ctx, data, itemDiff, itemResult);
}

ctx.logger.debug(data, 'item passed');
this.emit(RuleVisitorEvents.ITEM_PASS, data);

return ruleResult;
}

public async visitDiff(ctx: RuleVisitorContext, data: RuleVisitorData, itemDiff: Array<Diff<any, any>>, result: any): Promise<void> {
const diffData = {
...data,
diff: itemDiff,
};
ctx.logger.info(diffData, 'item could pass rule with changes');
this.emit(RuleVisitorEvents.ITEM_DIFF, diffData);

if (ctx.schemaOptions.mutate) {
applyDiff(data.item, result);
}
}
}
12 changes: 6 additions & 6 deletions src/rule/SchemaRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { LogLevel } from 'noicejs';
import { Rule, RuleData } from '.';
import { doesExist, hasItems } from '../utils';
import { Visitor, VisitorError, VisitorResult } from '../visitor';
import { VisitorContext } from '../visitor/VisitorContext';
import { RuleVisitorContext } from './RuleVisitor';

/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/strict-boolean-expressions */

Expand Down Expand Up @@ -34,7 +34,7 @@ export class SchemaRule implements Rule, RuleData, Visitor {
}
}

public async pick(ctx: VisitorContext, root: any): Promise<Array<any>> {
public async pick(ctx: RuleVisitorContext, root: any): Promise<Array<any>> {
const items = ctx.pick(this.select, root);

if (items.length === 0) {
Expand All @@ -44,7 +44,7 @@ export class SchemaRule implements Rule, RuleData, Visitor {
return items;
}

public async visit(ctx: VisitorContext, node: any): Promise<VisitorResult> {
public async visit(ctx: RuleVisitorContext, node: any): Promise<VisitorResult> {
ctx.logger.debug({ item: node, rule: this }, 'visiting node');

const check = ctx.compile(this.check);
Expand All @@ -67,7 +67,7 @@ export class SchemaRule implements Rule, RuleData, Visitor {
return result;
}

protected compileFilter(ctx: VisitorContext): ValidateFunction {
protected compileFilter(ctx: RuleVisitorContext): ValidateFunction {
if (isNil(this.filter)) {
return DEFAULT_FILTER;
} else {
Expand All @@ -76,7 +76,7 @@ export class SchemaRule implements Rule, RuleData, Visitor {
}
}

export function friendlyError(ctx: VisitorContext, err: ErrorObject): VisitorError {
export function friendlyError(ctx: RuleVisitorContext, err: ErrorObject): VisitorError {
return {
data: {
err,
Expand All @@ -86,7 +86,7 @@ export function friendlyError(ctx: VisitorContext, err: ErrorObject): VisitorErr
};
}

export function friendlyErrorMessage(ctx: VisitorContext, err: ErrorObject): string {
export function friendlyErrorMessage(ctx: RuleVisitorContext, err: ErrorObject): string {
const msg = [err.dataPath];
if (isNil(err.message)) {
msg.push(err.keyword);
Expand Down
43 changes: 24 additions & 19 deletions src/rule/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { readFile } from '../source';
import { doesExist, ensureArray } from '../utils';
import { VisitorResult } from '../visitor';
import { VisitorContext } from '../visitor/VisitorContext';
import { RuleVisitorContext } from './RuleVisitor';
import { SchemaRule } from './SchemaRule';

/* eslint-disable @typescript-eslint/no-explicit-any */
Expand All @@ -27,6 +28,7 @@ export interface RuleData {
/* tslint:enable:no-any */

export type Validator = ValidateFunction;

export interface Rule {
check: Validator;
desc?: string;
Expand All @@ -36,8 +38,8 @@ 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: RuleVisitorContext, root: any): Promise<Array<any>>;
visit(ctx: RuleVisitorContext, item: any): Promise<VisitorResult>;
}

/**
Expand Down Expand Up @@ -100,7 +102,7 @@ export function isPOJSO(val: any): val is RuleData {
return Reflect.getPrototypeOf(val) === Reflect.getPrototypeOf({});
}

export async function loadRuleSource(data: RuleSourceModule, ctx: VisitorContext): Promise<Array<Rule>> {
export async function loadRuleSource(data: RuleSourceModule, ctx: RuleVisitorContext): Promise<Array<Rule>> {
if (doesExist(data.definitions)) {
ctx.addSchema(data.name, data.definitions);
}
Expand All @@ -114,7 +116,7 @@ export async function loadRuleSource(data: RuleSourceModule, ctx: VisitorContext
});
}

export async function loadRuleFiles(paths: Array<string>, ctx: VisitorContext): Promise<Array<Rule>> {
export async function loadRuleFiles(paths: Array<string>, ctx: RuleVisitorContext): Promise<Array<Rule>> {
const parser = new YamlParser();
const rules = [];

Expand All @@ -141,7 +143,7 @@ export async function loadRuleFiles(paths: Array<string>, ctx: VisitorContext):
return rules;
}

export async function loadRulePaths(paths: Array<string>, ctx: VisitorContext): Promise<Array<Rule>> {
export async function loadRulePaths(paths: Array<string>, ctx: RuleVisitorContext): Promise<Array<Rule>> {
const match = new Minimatch('**/*.+(json|yaml|yml)', {
nocase: true,
});
Expand All @@ -163,7 +165,7 @@ export async function loadRulePaths(paths: Array<string>, ctx: VisitorContext):
return rules;
}

export async function loadRuleModules(modules: Array<string>, ctx: VisitorContext, r = require): Promise<Array<Rule>> {
export async function loadRuleModules(modules: Array<string>, ctx: RuleVisitorContext, r = require): Promise<Array<Rule>> {
const rules = [];

for (const name of modules) {
Expand All @@ -186,7 +188,7 @@ export async function loadRuleModules(modules: Array<string>, ctx: VisitorContex
return rules;
}

export async function loadRules(sources: RuleSources, ctx: VisitorContext): Promise<Array<Rule>> {
export async function loadRules(sources: RuleSources, ctx: RuleVisitorContext): Promise<Array<Rule>> {
return [
...await loadRuleFiles(sources.ruleFile, ctx),
...await loadRulePaths(sources.rulePath, ctx),
Expand Down Expand Up @@ -218,32 +220,35 @@ export async function resolveRules(rules: Array<Rule>, selector: RuleSelector):
return Array.from(activeRules);
}

export function validateRules(ctx: VisitorContext, root: any): boolean {
export function compileValidators(ctx: RuleVisitorContext) {
const { definitions, name } = ruleSchemaData as any;

const validCtx = new VisitorContext(ctx);
validCtx.addSchema(name, definitions);
const ruleSchema = validCtx.compile(definitions.source);
const configSchema = validCtx.compile(definitions.config);
const sourceSchema = validCtx.compile(definitions.source);

return { configSchema, sourceSchema };
}

export function validateRules(ctx: RuleVisitorContext, root: any): boolean {
const { sourceSchema } = compileValidators(ctx);

if (ruleSchema(root) === true) {
if (sourceSchema(root) === true) {
return true;
} else {
ctx.logger.error({ errors: ruleSchema.errors }, 'error validating rules');
ctx.logger.error({ errors: sourceSchema.errors }, 'error validating rules');
return false;
}
}

export function validateConfig(ctx: VisitorContext, root: any): boolean {
const { definitions, name } = ruleSchemaData as any;

const validCtx = new VisitorContext(ctx);
validCtx.addSchema(name, definitions);
const ruleSchema = validCtx.compile(definitions.config);
export function validateConfig(ctx: RuleVisitorContext, root: any): boolean {
const { configSchema } = compileValidators(ctx);

if (ruleSchema(root) === true) {
if (configSchema(root) === true) {
return true;
} else {
ctx.logger.error({ errors: ruleSchema.errors }, 'error validating rules');
ctx.logger.error({ errors: configSchema.errors }, 'error validating config');
return false;
}
}
9 changes: 9 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { isNil } from 'lodash';

import { NotFoundError } from '../error/NotFoundError';

export function doesExist<T>(val: T | null | undefined): val is T {
return !isNil(val);
}

export function mustExist<T>(val: T | null | undefined): T {
if (isNil(val)) {
throw new NotFoundError();
}
return val;
}

/**
* Test if a value is an array with some items (length > 0).
*
Expand Down
Loading