Skip to content

Commit

Permalink
feat: linter mode (experimental) (#160)
Browse files Browse the repository at this point in the history
  • Loading branch information
ChALkeR authored Jul 28, 2023
1 parent b5450e8 commit 896e433
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 20 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions doc/Linter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Linter mode

**[Experimental]**

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

**Warning:** Exact output messages/details are experimental and might change in
non-major versions.
6 changes: 5 additions & 1 deletion doc/Options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ interface ValidatorOptions {
allErrors?: boolean
contentValidation?: boolean
dryRun?: boolean
lint?: boolean
allowUnusedKeywords?: boolean
allowUnreachable?: boolean
requireSchema?: boolean
Expand Down Expand Up @@ -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,
}
59 changes: 41 additions & 18 deletions src/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -271,8 +284,13 @@ 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) {
if (lintOnly && !e.message.startsWith('[opt] ')) fail(e.message) // for lint-only mode, but not processing special re-run errors
throw e
}
}
return true
}
Expand Down Expand Up @@ -507,7 +525,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'))
Expand Down Expand Up @@ -586,7 +604,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')
Expand Down Expand Up @@ -806,7 +824,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))
})
}
Expand Down Expand Up @@ -1088,7 +1106,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => {
if (node.unevaluatedItems === false) consume('unevaluatedItems', 'boolean')
} else if (node.unevaluatedItems || node.unevaluatedItems === false) {
if (isDynamic(stat).items) {
if (!opts[optDynamic]) throw new Error('Dynamic unevaluated tracing is not enabled')
if (!opts[optDynamic]) throw new Error('[opt] Dynamic unevaluated tracing not enabled')
const limit = format('Math.max(%d, ...%s)', stat.items, dyn.items)
const extra = (i) => format('%s.includes(%s)', dyn.item, i)
additionalItems('unevaluatedItems', limit, getMeta().containsEvaluates ? extra : null)
Expand All @@ -1105,7 +1123,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => {
} else if (node.unevaluatedProperties || node.unevaluatedProperties === false) {
const notStatic = (key) => additionalCondition(key, stat.properties, stat.patterns)
if (isDynamic(stat).properties) {
if (!opts[optDynamic]) throw new Error('Dynamic unevaluated tracing is not enabled')
if (!opts[optDynamic]) throw new Error('[opt] Dynamic unevaluated tracing not enabled')
scope.propertyIn = functions.propertyIn
const notDynamic = (key) => format('!propertyIn(%s, %s)', key, dyn.props)
const condition = (key) => safeand(notStatic(key), notDynamic(key))
Expand Down Expand Up @@ -1159,7 +1177,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]
Expand All @@ -1177,7 +1198,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => {
if (node.$ref) return // ref overrides any sibling keywords for older schemas
}
handle('$recursiveRef', ['string'], ($recursiveRef) => {
if (!opts[optRecAnchors]) throw new Error('Recursive anchors are not enabled')
if (!opts[optRecAnchors]) throw new Error('[opt] Recursive anchors are not enabled')
enforce($recursiveRef === '#', 'Behavior of $recursiveRef is defined only for "#"')
// Resolve to recheck that recursive ref is enabled
const resolved = resolveReference(root, schemas, '#', basePath())
Expand All @@ -1189,7 +1210,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => {
return applyRef(nrec, { path: ['$recursiveRef'] })
})
handle('$dynamicRef', ['string'], ($dynamicRef) => {
if (!opts[optDynAnchors]) throw new Error('Dynamic anchors are not enabled')
if (!opts[optDynAnchors]) throw new Error('[opt] Dynamic anchors are not enabled')
laxMode(/^[^#]*#[a-zA-Z0-9_-]+$/.test($dynamicRef), 'Unsupported $dynamicRef format')
const dynamicTail = $dynamicRef.replace(/^[^#]+/, '')
const resolved = resolveReference(root, schemas, $dynamicRef, basePath())
Expand Down Expand Up @@ -1305,10 +1326,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
}

Expand All @@ -1321,12 +1344,12 @@ const compile = (schemas, opts) => {
} catch (e) {
// For performance, we try to build the schema without dynamic tracing first, then re-run with
// it enabled if needed. Enabling it without need can give up to about 40% performance drop.
if (!opts[optDynamic] && e.message === 'Dynamic unevaluated tracing is not enabled')
if (!opts[optDynamic] && e.message === '[opt] Dynamic unevaluated tracing not enabled')
return compile(schemas, { ...opts, [optDynamic]: true })
// Also enable dynamic and recursive refs only if needed
if (!opts[optDynAnchors] && e.message === 'Dynamic anchors are not enabled')
if (!opts[optDynAnchors] && e.message === '[opt] Dynamic anchors are not enabled')
return compile(schemas, { ...opts, [optDynAnchors]: true })
if (!opts[optRecAnchors] && e.message === 'Recursive anchors are not enabled')
if (!opts[optRecAnchors] && e.message === '[opt] Recursive anchors are not enabled')
return compile(schemas, { ...opts, [optRecAnchors]: true })
throw e
}
Expand Down
8 changes: 7 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }

0 comments on commit 896e433

Please sign in to comment.