1
0
Fork 0

feat(source): formalize source data with Document and Element

This commit is contained in:
Sean Sube 2022-02-13 10:27:28 -06:00
parent a3f0c0b61d
commit 1fa386581e
23 changed files with 465 additions and 345 deletions

View File

@ -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) {

View File

@ -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 {

View File

@ -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

10
src/loader/index.ts Normal file
View File

@ -0,0 +1,10 @@
import { Source } from '../source';
export interface Loader {
load(path: string): Source;
}
class FileLoader { }
class FetchLoader { }
class ImportLoader { }
class StreamLoader { }

View File

@ -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;

View File

@ -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>;
}

View File

@ -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 { }

View File

@ -1,3 +0,0 @@
{
"foo": {}
}

View File

@ -1 +0,0 @@
foo: {}

View File

@ -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,

View File

@ -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;
}
}

153
src/rule/load.ts Normal file
View File

@ -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),
];
}

56
src/rule/resolve.ts Normal file
View File

@ -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);
}

56
src/rule/validate.ts Normal file
View File

@ -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;
}
}

View File

@ -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.
*/

View File

@ -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)));
}
}

View File

@ -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,

View File

@ -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>>;
}

View File

@ -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;

View File

@ -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';

View File

@ -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({

View File

@ -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([]);
});
});

View File

@ -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(),
}],
});