1
0
Fork 0

feat(visitor): improve error messages (#20)

This commit is contained in:
ssube 2019-09-10 20:08:57 -05:00 committed by Sean Sube
parent 27e21a7ca8
commit c9c1a58407
13 changed files with 147 additions and 91 deletions

View File

@ -42,7 +42,7 @@ RELEASE_OPTS ?= --commit-all
export NODE_VERSION := $(shell node -v) export NODE_VERSION := $(shell node -v)
export RUNNER_VERSION := $(CI_RUNNER_VERSION) export RUNNER_VERSION := $(CI_RUNNER_VERSION)
all: build all: build test
@echo Success! @echo Success!
clean: ## clean up everything added by the default target clean: ## clean up everything added by the default target

View File

@ -80,6 +80,7 @@ const bundle = {
'node_modules/noicejs/out/main-bundle.js': [ 'node_modules/noicejs/out/main-bundle.js': [
'BaseError', 'BaseError',
'ConsoleLogger', 'ConsoleLogger',
'logWithLevel',
], ],
'node_modules/js-yaml/index.js': [ 'node_modules/js-yaml/index.js': [
'DEFAULT_SAFE_SCHEMA', 'DEFAULT_SAFE_SCHEMA',

View File

@ -6,7 +6,7 @@ import { YamlParser } from './parser/YamlParser';
import { loadRules, resolveRules, visitRules } from './rule'; import { loadRules, resolveRules, visitRules } from './rule';
import { loadSource, writeSource } from './source'; import { loadSource, writeSource } from './source';
import { VERSION_INFO } from './version'; import { VERSION_INFO } from './version';
import { VisitorContext } from './visitor/context'; import { VisitorContext } from './visitor/VisitorContext';
enum MODES { enum MODES {
check = 'check', check = 'check',
@ -34,13 +34,15 @@ export async function main(argv: Array<string>): Promise<number> {
} }
const ctx = new VisitorContext({ const ctx = new VisitorContext({
coerce: args.coerce, innerOptions: {
defaults: args.defaults, coerce: args.coerce,
defaults: args.defaults,
mutate: mode === 'fix',
},
logger, logger,
mutate: mode === 'fix',
}); });
const rules = await loadRules(args.rules, ctx.ajv); const rules = await loadRules(args.rules, ctx);
const activeRules = await resolveRules(rules, args as any); const activeRules = await resolveRules(rules, args as any);
if (mode === 'list') { if (mode === 'list') {
@ -50,7 +52,7 @@ export async function main(argv: Array<string>): Promise<number> {
const parser = new YamlParser(); const parser = new YamlParser();
const source = await loadSource(args.source); const source = await loadSource(args.source);
let docs = parser.parse(source); const docs = parser.parse(source);
for (const data of docs) { for (const data of docs) {
await visitRules(ctx, activeRules, data); await visitRules(ctx, activeRules, data);

View File

@ -1,13 +1,16 @@
import { ValidateFunction } from 'ajv';
import { applyDiff, diff } from 'deep-diff'; import { applyDiff, diff } from 'deep-diff';
import { JSONPath } from 'jsonpath-plus'; import { JSONPath } from 'jsonpath-plus';
import { cloneDeep, intersection, isNil } from 'lodash'; import { cloneDeep, Dictionary, intersection, isNil } from 'lodash';
import { LogLevel } from 'noicejs'; import { LogLevel } from 'noicejs';
import { YamlParser } from './parser/YamlParser'; import { YamlParser } from './parser/YamlParser';
import { readFileSync } from './source'; import { readFileSync } from './source';
import { friendlyError } from './utils/ajv';
import { Visitor } from './visitor'; import { Visitor } from './visitor';
import { VisitorContext } from './visitor/context'; import { VisitorContext } from './visitor/VisitorContext';
import { VisitorResult } from './visitor/result'; import { VisitorError } from './visitor/VisitorError';
import { VisitorResult } from './visitor/VisitorResult';
export interface RuleData { export interface RuleData {
// metadata // metadata
@ -31,7 +34,7 @@ export interface RuleSelector {
} }
export interface RuleSource { export interface RuleSource {
definitions?: Array<any>; definitions?: Dictionary<any>;
name: string; name: string;
rules: Array<RuleData>; rules: Array<RuleData>;
} }
@ -55,7 +58,7 @@ export function makeSelector(options: Partial<RuleSelector>) {
}; };
} }
export async function loadRules(paths: Array<string>, ajv: any): Promise<Array<Rule>> { export async function loadRules(paths: Array<string>, ctx: VisitorContext): Promise<Array<Rule>> {
const parser = new YamlParser(); const parser = new YamlParser();
const rules = []; const rules = [];
@ -68,10 +71,7 @@ export async function loadRules(paths: Array<string>, ajv: any): Promise<Array<R
for (const data of docs) { for (const data of docs) {
if (!isNil(data.definitions)) { if (!isNil(data.definitions)) {
ajv.addSchema({ ctx.addSchema(data.name, data.definitions);
'$id': data.name,
definitions: data.definitions,
});
} }
rules.push(...data.rules.map((data: any) => new Rule(data))); rules.push(...data.rules.map((data: any) => new Rule(data)));
@ -134,7 +134,7 @@ export async function visitRules(ctx: VisitorContext, rules: Array<Rule>, data:
rule: rule.name, rule: rule.name,
}, 'rule passed with modifications'); }, 'rule passed with modifications');
if (ctx.mutate) { if (ctx.innerOptions.mutate) {
applyDiff(item, itemCopy); applyDiff(item, itemCopy);
} }
} else { } else {
@ -191,25 +191,20 @@ export class Rule implements RuleData, Visitor<RuleResult> {
public async visit(ctx: VisitorContext, node: any): Promise<RuleResult> { public async visit(ctx: VisitorContext, node: any): Promise<RuleResult> {
ctx.logger.debug({ item: node, rule: this }, 'visiting node'); ctx.logger.debug({ item: node, rule: this }, 'visiting node');
const check = ctx.ajv.compile(this.check); const check = ctx.compile(this.check);
const filter = this.compileFilter(ctx); const filter = this.compileFilter(ctx);
const errors: Array<VisitorError> = [];
const result: RuleResult = { const result: RuleResult = {
changes: [], changes: [],
errors: [], errors,
rule: this, rule: this,
}; };
if (filter(node)) { if (filter(node)) {
ctx.logger.debug({ item: node }, 'checking item'); ctx.logger.debug({ item: node }, 'checking item');
if (!check(node)) { if (!check(node) && check.errors && check.errors.length) {
const errors = Array.from(check.errors); const errors = Array.from(check.errors);
ctx.logger.warn({ ctx.error(...errors.map(friendlyError));
errors,
name: this.name,
item: node,
rule: this,
}, 'rule failed on item');
result.errors.push(...errors);
} }
} else { } else {
ctx.logger.debug({ errors: filter.errors, item: node }, 'skipping item'); ctx.logger.debug({ errors: filter.errors, item: node }, 'skipping item');
@ -218,11 +213,11 @@ export class Rule implements RuleData, Visitor<RuleResult> {
return result; return result;
} }
protected compileFilter(ctx: VisitorContext): any { protected compileFilter(ctx: VisitorContext): ValidateFunction {
if (isNil(this.filter)) { if (isNil(this.filter)) {
return () => true; return () => true;
} else { } else {
return ctx.ajv.compile(this.filter); return ctx.compile(this.filter);
} }
} }
} }

11
src/utils/ajv/index.ts Normal file
View File

@ -0,0 +1,11 @@
import { ErrorObject } from 'ajv';
import { VisitorError } from '../../visitor/VisitorError';
export function friendlyError(err: ErrorObject): VisitorError {
return {
data: {},
level: 'error',
msg: err.message || err.keyword,
};
}

View File

@ -0,0 +1,76 @@
import Ajv from 'ajv';
import { Logger, logWithLevel } from 'noicejs';
import { VisitorError } from './VisitorError';
import { VisitorResult } from './VisitorResult';
export interface RuleOptions {
coerce: boolean;
defaults: boolean;
mutate: boolean;
}
export interface VisitorContextOptions {
logger: Logger;
innerOptions: RuleOptions;
}
export class VisitorContext implements VisitorContextOptions, VisitorResult {
public readonly logger: Logger;
public readonly innerOptions: RuleOptions;
protected readonly ajv: Ajv.Ajv;
protected readonly _changes: Array<any>;
protected readonly _errors: Array<VisitorError>;
public get changes(): ReadonlyArray<any> {
return this._changes;
}
public get errors(): ReadonlyArray<VisitorError> {
return this._errors;
}
constructor(options: VisitorContextOptions) {
this._changes = [];
this._errors = [];
this.ajv = new Ajv({
coerceTypes: options.innerOptions.coerce,
useDefaults: options.innerOptions.defaults,
});
this.logger = options.logger;
this.innerOptions = options.innerOptions;
}
public error(...errors: Array<VisitorError>) {
for (const err of errors) {
logWithLevel(this.logger, err.level, err.data, err.msg);
}
this._errors.push(...errors);
}
public mergeResult(other: VisitorResult): this {
this._changes.push(...other.changes);
this._errors.push(...other.errors);
return this;
}
public compile(schema: any): Ajv.ValidateFunction {
return this.ajv.compile(schema);
}
public addSchema(name: string, schema: any): void {
this.logger.debug('adding ajv schema', {
name,
schema,
});
this.ajv.addSchema({
'$id': name,
definitions: schema,
});
}
}

View File

@ -0,0 +1,10 @@
import { LogLevel } from 'noicejs';
/**
* This is an runtime error, not an exception.
*/
export interface VisitorError {
data: any;
level: LogLevel;
msg: string;
}

View File

@ -0,0 +1,4 @@
export interface VisitorResult {
changes: ReadonlyArray<any>;
errors: ReadonlyArray<any>;
}

View File

@ -1,45 +0,0 @@
import * as Ajv from 'ajv';
import { Logger } from 'noicejs';
import { VisitorResult } from './result';
export interface VisitorContextOptions {
coerce: boolean;
defaults: boolean;
logger: Logger;
mutate: boolean;
}
export class VisitorContext implements VisitorContextOptions, VisitorResult {
public readonly ajv: any;
public readonly changes: Array<any>;
public readonly coerce: boolean;
public readonly defaults: boolean;
public readonly errors: Array<any>;
public readonly logger: Logger;
public readonly mutate: boolean;
constructor(options: VisitorContextOptions) {
this.ajv = new ((Ajv as any).default)({
coerceTypes: options.coerce,
useDefaults: options.defaults,
});
this.changes = [];
this.coerce = options.coerce;
this.defaults = options.defaults;
this.errors = [];
this.logger = options.logger;
this.mutate = options.mutate;
}
public error(options: any, msg: string) {
this.logger.error(options, msg);
this.errors.push(options || msg);
}
public mergeResult(other: VisitorResult): this {
this.changes.push(...other.changes);
this.errors.push(...other.errors);
return this;
}
}

View File

@ -1,5 +1,5 @@
import { VisitorContext } from './context'; import { VisitorContext } from './VisitorContext';
import { VisitorResult } from './result'; import { VisitorResult } from './VisitorResult';
export interface Visitor<TResult extends VisitorResult> { export interface Visitor<TResult extends VisitorResult> {
/** /**

View File

@ -1,4 +0,0 @@
export interface VisitorResult {
changes: Array<any>;
errors: Array<any>;
}

View File

@ -3,7 +3,7 @@ import { ConsoleLogger } from 'noicejs';
import { mock } from 'sinon'; import { mock } from 'sinon';
import { makeSelector, resolveRules, Rule, visitRules } from '../src/rule'; import { makeSelector, resolveRules, Rule, visitRules } from '../src/rule';
import { VisitorContext } from '../src/visitor/context'; import { VisitorContext } from '../src/visitor/VisitorContext';
const TEST_RULES = [new Rule({ const TEST_RULES = [new Rule({
name: 'foo', name: 'foo',
@ -102,10 +102,12 @@ describe('rule resolver', () => {
describe('rule visitor', () => { describe('rule visitor', () => {
it('should only call visit for selected items', async () => { it('should only call visit for selected items', async () => {
const ctx = new VisitorContext({ const ctx = new VisitorContext({
coerce: false,
defaults: false,
logger: new ConsoleLogger(), logger: new ConsoleLogger(),
mutate: false, innerOptions: {
coerce: false,
defaults: false,
mutate: false,
}
}); });
const data = {}; const data = {};
const rule = new Rule({ const rule = new Rule({
@ -132,10 +134,12 @@ describe('rule visitor', () => {
it('should call visit for each selected item', async () => { it('should call visit for each selected item', async () => {
const ctx = new VisitorContext({ const ctx = new VisitorContext({
coerce: false, innerOptions: {
defaults: false, coerce: false,
defaults: false,
mutate: false,
},
logger: new ConsoleLogger(), logger: new ConsoleLogger(),
mutate: false,
}); });
const data = {}; const data = {};
const rule = new Rule({ const rule = new Rule({

View File

@ -1,15 +1,17 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { ConsoleLogger } from 'noicejs'; import { ConsoleLogger } from 'noicejs';
import { VisitorContext } from '../../src/visitor/context'; import { VisitorContext } from '../../src/visitor/VisitorContext';
describe('visitor context', () => { describe('visitor context', () => {
it('should merge results', () => { it('should merge results', () => {
const firstCtx = new VisitorContext({ const firstCtx = new VisitorContext({
coerce: false, innerOptions: {
defaults: false, coerce: false,
defaults: false,
mutate: false,
},
logger: new ConsoleLogger(), logger: new ConsoleLogger(),
mutate: false,
}); });
const nextCtx = firstCtx.mergeResult({ const nextCtx = firstCtx.mergeResult({