From 4755096ad6e4f21dd4df5f924aa3f10b306a5d00 Mon Sep 17 00:00:00 2001 From: Nikita Skovoroda Date: Fri, 28 Jul 2023 18:30:20 +0300 Subject: [PATCH] feat: linter mode --- README.md | 1 + doc/Linter.md | 25 +++++++++++++++++++++++++ doc/Options.md | 6 +++++- index.d.ts | 11 +++++++++++ src/compile.js | 44 +++++++++++++++++++++++++++++++++----------- src/index.js | 8 +++++++- 6 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 doc/Linter.md diff --git a/README.md b/README.md index 89c5002..71c6433 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Supports [draft-04/06/07/2019-09/2020-12](doc/Specification-support.md) and the [`discriminator` OpenAPI keyword](./doc/Discriminator-support.md). * Can assign defaults and/or remove additional properties when schema allows to do that safely. Throws at build time if those options are used with schemas that don't allow to do that safely. +* Can use used as a [schema linter](./doc/Linter.md). ## Installation diff --git a/doc/Linter.md b/doc/Linter.md new file mode 100644 index 0000000..693d407 --- /dev/null +++ b/doc/Linter.md @@ -0,0 +1,25 @@ +# Linter mode + +`@exodus/schemasafe` can also function in a linter mode, collecting all schema +errors instead of throwing on the first one. + +Usage: + +```cjs +const { lint } = require('@exodus/schemasafe') +const fs = require('fs') +const path = require('path') + +const dir = 'schemas/json' +const files = fs.readdirSync(dir).sort().map(x => path.join(dir, x)) +const schemas = files.map(x => [x, JSON.parse(fs.readFileSync(x, 'utf-8'))]) + +for (const [name, schema] of schemas) { + const errors = lint(schema)//, lint(schema, { mode: 'strong' }) + for (const e of errors) { + console.log(`${name}: ${e.message}`) + } +} +``` + +Other [options](./Options.md) are similar to `parser()` and `validator()` modes. diff --git a/doc/Options.md b/doc/Options.md index 9f6e217..ea80b23 100644 --- a/doc/Options.md +++ b/doc/Options.md @@ -27,7 +27,11 @@ keyword and raise an error at compile time unless `allowUnusedKeywords` option is enabled. * `dryRun` — `false` by default.\ - Don't produce a validator, just verify the schema. + Don't produce a validator, just verify the schema and throw on first error. + + * `lint` — `false` by default.\ + Don't produce a validator, just verify the schema and collect all errors. + Same as [Linter mode](./Linter.md). * `$schemaDefault` — `null` by default.\ Can not be used if `requireSchema` is on. diff --git a/index.d.ts b/index.d.ts index 51c94b4..212c770 100644 --- a/index.d.ts +++ b/index.d.ts @@ -107,6 +107,7 @@ interface ValidatorOptions { allErrors?: boolean contentValidation?: boolean dryRun?: boolean + lint?: boolean allowUnusedKeywords?: boolean allowUnreachable?: boolean requireSchema?: boolean @@ -137,18 +138,28 @@ interface Parse { toJSON(): Schema } +interface LintError { + message: string + keywordLocation: string + schema: Schema +} + declare const validator: (schema: Schema, options?: ValidatorOptions) => Validate declare const parser: (schema: Schema, options?: ValidatorOptions) => Parse +declare const lint: (schema: Schema, options?: ValidatorOptions) => LintError[] + export { validator, parser, + lint, Validate, ValidationError, ValidatorOptions, ParseResult, Parse, + LintError, Json, Schema, } diff --git a/src/compile.js b/src/compile.js index 95aa813..a73cd5a 100644 --- a/src/compile.js +++ b/src/compile.js @@ -77,6 +77,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { allErrors = false, contentValidation, dryRun, // unused, just for rest siblings + lint: lintOnly = false, allowUnusedKeywords = opts.mode === 'lax', allowUnreachable = opts.mode === 'lax', requireSchema = opts.mode === 'strong', @@ -215,9 +216,21 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { } const errorIf = (condition, errorArgs) => fun.if(condition, () => error(errorArgs)) + if (lintOnly && !scope.lintErrors) scope.lintErrors = [] // we can do this as we don't build functions in lint-only mode const fail = (msg, value) => { const comment = value !== undefined ? ` ${JSON.stringify(value)}` : '' - throw new Error(`${msg}${comment} at ${joinPath(basePathRoot, toPointer(schemaPath))}`) + const keywordLocation = joinPath(basePathRoot, toPointer(schemaPath)) + const message = `${msg}${comment} at ${keywordLocation}` + if (lintOnly) return scope.lintErrors.push({ message, keywordLocation, schema }) // don't fail if we are just collecting all errors + throw new Error(message) + } + const patternTestSafe = (pat, key) => { + try { + return patternTest(pat, key) + } catch (e) { + fail(e.message) + return format('false') // for lint-only mode + } } const enforce = (ok, ...args) => ok || fail(...args) const laxMode = (ok, ...args) => enforce(mode === 'lax' || ok, ...args) @@ -271,8 +284,12 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { // opt-out on null is explicit in both places here, don't set default consume(prop, ...ruleTypes) if (handler !== null) { - const condition = handler(node[prop]) - if (condition !== null) errorIf(condition, { path: [prop], ...errorArgs }) + try { + const condition = handler(node[prop]) + if (condition !== null) errorIf(condition, { path: [prop], ...errorArgs }) + } catch (e) { + fail(e.message) // for lint-only mode + } } return true } @@ -507,7 +524,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { const additionalCondition = (key, properties, patternProperties) => safeand( ...properties.map((p) => format('%s !== %j', key, p)), - ...patternProperties.map((p) => safenot(patternTest(p, key))) + ...patternProperties.map((p) => safenot(patternTestSafe(p, key))) ) const lintRequired = (properties, patterns) => { const regexps = patterns.map((p) => new RegExp(p, 'u')) @@ -586,7 +603,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { handle('pattern', ['string'], (pattern) => { enforceRegex(pattern) evaluateDelta({ fullstring: true }) - return noopRegExps.has(pattern) ? null : safenot(patternTest(pattern, name)) + return noopRegExps.has(pattern) ? null : safenot(patternTestSafe(pattern, name)) }) enforce(node.contentSchema !== false, 'contentSchema cannot be set to false') @@ -806,7 +823,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { forObjectKeys(current, (sub, key) => { for (const p of Object.keys(patternProperties)) { enforceRegex(p, node.propertyNames || {}) - fun.if(patternTest(p, key), () => { + fun.if(patternTestSafe(p, key), () => { rule(sub, patternProperties[p], subPath('patternProperties', p)) }) } @@ -1159,7 +1176,10 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { handle('$ref', ['string'], ($ref) => { const resolved = resolveReference(root, schemas, $ref, basePath()) const [sub, subRoot, path] = resolved[0] || [] - if (!sub && sub !== false) fail('failed to resolve $ref:', $ref) + if (!sub && sub !== false) { + fail('failed to resolve $ref:', $ref) + if (lintOnly) return null // failures are just collected in linter mode and don't throw, this makes a ref noop + } if (sub.type) { // This could be done better, but for now we check only the direct type in the $ref const type = Array.isArray(sub.type) ? sub.type : [sub.type] @@ -1305,10 +1325,12 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { fun.write('}') - validate = fun.makeFunction(scope) - validate[evaluatedStatic] = stat - delete scope[funname] // more logical key order - scope[funname] = validate + if (!lintOnly) { + validate = fun.makeFunction(scope) + validate[evaluatedStatic] = stat + delete scope[funname] // more logical key order + scope[funname] = validate + } return funname } diff --git a/src/index.js b/src/index.js index 2ebd3de..71433dd 100644 --- a/src/index.js +++ b/src/index.js @@ -32,6 +32,7 @@ const validator = ( const options = { mode, ...opts, schemas: buildSchemas(schemas, arg), isJSON: willJSON } const { scope, refs } = compile(arg, options) // only a single ref if (opts.dryRun) return + if (opts.lint) return scope.lintErrors const fun = genfun() if (parse) { scope.parseWrap = opts.includeErrors ? parseWithErrors : parseWithoutErrors @@ -89,4 +90,9 @@ const parser = function(schema, { parse = true, ...opts } = {}) { return validator(schema, { parse, ...opts }) } -module.exports = { validator, parser } +const lint = function(schema, { lint: lintOption = true, ...opts } = {}) { + if (!lintOption) throw new Error('can not disable lint option in lint()') + return validator(schema, { lint: lintOption, ...opts }) +} + +module.exports = { validator, parser, lint }