UNPKG

@report-toolkit/inspector

Version:

See docs at [https://ibm.github.io/report-toolkit](https://ibm.github.io/report-toolkit)

370 lines (333 loc) 11.1 kB
import { _, constants, createDebugger, error, observable, symbols } from '@report-toolkit/common'; import {AJV} from './ajv.js'; import {createMessage} from './message.js'; import {createRuleConfig} from './rule-config.js'; const {WARNING, SEVERITIES} = constants; const { RTKERR_INVALID_RULE_CONFIG, RTKERR_INVALID_RULE_DEFINITION, RTKERR_INVALID_SCHEMA, RTkError } = error; const { catchError, concat, concatMap, defer, filter, from, fromAny, map, mergeMap, of, tap, throwError } = observable; const {kRuleId, kRuleInspect, kRuleMeta} = symbols; const debug = createDebugger('inspector', 'rule'); /** * Map of Rules to config schema validation functions * @type {WeakMap<Rule,Function>} */ const validatorMap = new WeakMap(); /** * @type {import('ajv').Ajv} */ let ajv; /** * Operator that catches an Error emitted from a handler function and emits a * partial `Message` containing the error message, original error, and severity. * If the `Error` object has a valid `severity` prop, this severity is used; * otherwise `WARNING` is used. * @todo WARNING may not be the right default. * @returns * {import('rxjs').MonoTypeOperatorFunction<import('./message').RawMessage>} */ const catchHandlerError = () => observable => observable.pipe( catchError(error => of({ message: _.isError(error) ? error.message : String(error), error, severity: _.isError(error) && _.has('severity', error) && _.has(/** @type {any} */ (error).severity, SEVERITIES) ? /** @type {any} */ (error).severity : WARNING }) ) ); /** * A Rule which can be matched against a Context */ export class Rule { /** * Applies defaults, assigns some metadata. * @param {RuleDefinition} ruleDef */ constructor(ruleDef) { ruleDef = Rule.applyDefaults(ruleDef); if (!_.isFunction(ruleDef.inspect)) { throw RTkError.create( RTKERR_INVALID_RULE_DEFINITION, `Definition for rule "${ruleDef.id}" must export an "inspect" function` ); } Object.assign(this, { [kRuleId]: ruleDef.id, [kRuleInspect]: ruleDef.inspect, [kRuleMeta]: ruleDef.meta }); } /** * @type {string} */ get id() { // @ts-ignore return this[kRuleId]; } get description() { return _.get('docs.description', this.meta); } get url() { return _.get('docs.url', this.meta); } get schema() { return _.get('schema', this.meta); } /** * @type {object} * @todo update with schema for 'meta' prop */ get meta() { // @ts-ignore return this[kRuleMeta]; } /** * Lazily-created function which validates the schema itself when first * referenced, creates a config-validation function, caches it, then asserts * any user-supplied config is valid using said function. * @throws If user-supplied config is invalid. */ get validate() { if (validatorMap.has(this)) { debug(`returning cached validator for rule ${this.id}`); return validatorMap.get(this); } const schema = this.schema; if (!schema) { return _.identity; } debug(`found schema for rule ${this.id}`, schema); ajv = ajv || AJV(); const validate = ajv.compile(schema); if (ajv.errors) { throw RTkError.create( RTKERR_INVALID_SCHEMA, `Schema for rule ${this.id} is invalid: ${ajv.errorsText()}` ); } validatorMap.set( this, /** @param {object} config */ config => { debug(`validating ${this.id} with config`, config); validate(config); if (validate.errors) { const errors = ajv.errorsText(validate.errors, { dataVar: 'config' }); throw RTkError.create( RTKERR_INVALID_RULE_CONFIG, `Invalid configuration for rule "${this.id}": ${errors}`, {url: this.url} ); } return config; } ); return validatorMap.get(this); } /** * Calls the `inspect()` function of a Rule impl, which will return one or * more "handler" functions. Note `inspect()` might return a `Promise` which * resolves to the "handler" functions. * @param {Object} [config] Optional rule-specific config * @returns {Promise<Object|Function>} */ async handlers(config = {}) { // @ts-ignore return this[kRuleInspect].call(null, config); } /** * Given a stream of Report objects and an optional configuration, execute the * `inspect()` function of the rule definition, which should return a "next" * handler function, or an object having handler function props `next` and * `complete`. * 1. Normalize the result of the `inspect()` so we can make assumptions about * the shape of the returned value. * 2. For each `Report` (`report`), run the `next` handler as if it returned * a `Promise`. This handler is passed the `report`, and any `Error`s * thrown are trapped. The handler may return a string ("message"), a * partial `Message` object, `Array` thereof, or a `Promise` resolving to * any of that stuff, or just `undefined` in the case of "nothing to * mention" * 3. Return values are correlated with the filepath of the report. Note that * `Report` objects may not *have* a filepath if they were not loaded from * file. * 4. Once all `Report`s have passed through the `next` handler, call the * `complete` handler. It receives no `report`, and can be used in tandem * with `next` to perform aggregation. Supports the same return values as * `next` * 5. Finally, filter out empty/falsy partial `Message`s (e.g., those without * actual `string` `message` props), and normalize the `Message` by adding * relevant metadata (`Rule` ID, user-supplied config used, default * severity, etc.) * @param {{reports: import('rxjs').Observable<import('@report-toolkit/common/src/report').Report>, config?: object}} opts * @returns {import('rxjs').Observable<import('./message').Message>} */ inspect({reports, config = {}}) { return from(this.handlers(config)).pipe( Rule.normalizeHandler(), mergeMap(handler => { /** * smite Zalgo by normalizing the return values to Promises * @param {import('@report-toolkit/common/src/report').Report} report */ const next = async report => handler.next(report); const complete = async () => handler.complete(); // the intent here is to provide the user filepath information // whenever possible. in the case of Rules using the "complete" // handler, they may--but not always--be generating a message based // on the aggregate of several reports. in that case, we cannot // cross-reference a single filepath. however, if a Rule _throws_ // when inspecting a filepath, we can consider the aggregate to contain // one less file, because the Rule cannot process the file further. // ultimately, if the count of non-error-throwing filepaths is equal to // one (1), we only have a single report file ("aggregated" or not), // and can then cross-reference it when providing output to the user. /** @type {string[]} */ const nonThrowingReportFilepaths = []; const id = this.id; return concat( reports.pipe( tap(report => { if (report.filepath) { nonThrowingReportFilepaths.push(report.filepath); } }), concatMap(report => fromAny(next(report)).pipe( catchError(err => { nonThrowingReportFilepaths.pop(); return throwError(err); }), catchHandlerError(), map(message => createMessage(message, { config, filepath: report.filepath, id }) ) ) ) ), defer(() => fromAny(complete())).pipe( catchHandlerError(), map(message => { const isAggregate = nonThrowingReportFilepaths.length > 1; // this will be ignored by Message if the above is true. const filepath = nonThrowingReportFilepaths.shift(); return createMessage(message, { config, filepath, id, isAggregate }); }) ) ); }), filter(message => message.isNonEmpty()) ); } /** * Applies defaults to a rule definition during `Rule` construction. * @param {Partial<RuleDefinition>} ruleDef - Raw rule definition * @returns {RuleDefinition} */ static applyDefaults(ruleDef) { return _.defaultsDeep( { meta: {docs: {}} }, ruleDef ); } /** * Operator. Given a "handler" (returned by the rule definition's `inspect` * function), normalize it into an object (since it may be just a function) * @returns {import('rxjs').OperatorFunction<RuleHandler,RuleHandlerObject>} */ static normalizeHandler() { return ruleHandler$ => ruleHandler$.pipe( map(ruleHandler => _.isFunction(ruleHandler) ? {complete: _.noop, next: ruleHandler} : {complete: ruleHandler.complete || _.noop, next: ruleHandler.next} ) ); } /** * Creates a `Rule` from a user-defined (or builtin) `RuleDefinition`, which * is the exports of a rule definition file. * @param {RuleDefinition} ruleDefinition - Rule definition * @returns {Rule} New rule */ static create(ruleDefinition) { return new Rule(ruleDefinition); } /** * Given a {@link Config}, get associated rule config and create a `RuleConfig`. */ toRuleConfig(config) { return createRuleConfig(_.get(this.id, _.get('rules', config)), this); } } export const createRule = Rule.create; /** * @typedef {Object} RuleDefinition * @property {object} meta - (schema for `meta` prop) * @property {RuleDefinitionInspectFunction} inspect - Async function which receives `Context` object * and optional configuration * @property {string} id - Unique rule ID */ /** * @typedef {Object} RuleDefinitionMeta */ /** * @typedef {string} RuleDefinitionId */ /** * @typedef {(config?: object) => Promise<RuleHandler>|RuleHandler} RuleDefinitionInspectFunction */ /** * @typedef {RuleHandlerFunction|RuleHandlerObject} RuleHandler */ /** * @typedef {Object} RuleHandlerObject * @property {RuleHandlerFunction} next * @property {(() => Promise<import('./message').RawMessage>|import('./message').RawMessage|void)?} complete */ /** * @typedef {(report: import('@report-toolkit/common').Report) => Promise<import('./message').RawMessage>|import('./message').RawMessage|void|import('./message').RawMessage[]} RuleHandlerFunction */