UNPKG

@jirutka/ajv-cli

Version:

CLI for Ajv JSON Schema Validator with human-friendly error messages

548 lines 21.8 kB
// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2023 Gabriel McAdams <https://github.com/ghmcadams> import { deepClone as clone } from '../../utils.js'; import pluralize from './pluralize.js'; import { ofErrorKeywords, parentKeywordsOk, areErrorsEqual, getCommonPrefix, getDataReferencePath, getOfChoiceIndex, getPropertyMatchScore, getSchemaType, getType, getValueMatchScore, } from './util.js'; /** * Get usable, human readable, simple error messages from ajv errors. * @param {VerboseErrorObject[]} errors - The errors created as a result of calling ajv.validate(). * @param {TextOptions=} options - Configuration options to help give the best result. * @param {string} [options.dataVar='data'] - The text to use for the root of the data variable. * @param {string} [options.separator=', '] - The text to use for the separator between errors. * @return {string} All errors, delimited by the specified separator */ export function getSimpleErrorText(errors, options = {}) { const { separator = ', ', } = options; return getSimpleErrors(errors, options).map(({ message }) => message).join(separator); } /** * Get usable, human readable, simple error messages from ajv errors. * @param {VerboseErrorObject[]} errors - The errors created as a result of calling ajv.validate(). * @param {Options=} options - Configuration options to help give the best result. * @param {string} [options.dataVar='data'] - The text to use for the root of the data variable. * @return {SimpleError[]} An array of simple errors. */ export function getSimpleErrors(errors, options = {}) { if (!errors || errors.length === 0) { return []; } const { dataVar = 'data', } = options; const processedErrors = processErrors(errors); const simpleErrors = processedErrors.map((error) => { const path = getDataReferencePath(dataVar, error.instancePath); const errorMessage = getMessageForError(error); const message = `${path} must ${errorMessage}`; const { instancePath, schemaPath, schema, parentSchema, data, } = error; return { message, instancePath, schemaPath, schema, parentSchema, data, }; }); return simpleErrors; } ; function processErrors(errors) { const clonedErrors = clone(errors); const ofHierarchy = getOfHierarchy(clonedErrors); const processedErrors = filterByChosenPath(ofHierarchy); return processedErrors; } // TODO: add a comment for what an OF hierarchy is function getOfHierarchy(errors) { const ofs = errors.filter((err) => isOfError(err)); if (ofs.length === 0) { return errors; } replaceSchemaRefs(errors); const ret = []; // Loop through non OF errors const nonOfErrors = errors.filter((err) => !isOfError(err)); for (const error of nonOfErrors) { const schemaOfs = getSchemaOfs(error); let ref = ret; for (const schemaPath of schemaOfs) { // already stored const storedErrors = ref.flat(); // Fixed https://github.com/ghmcadams/vscode-lintlens/pull/78 const refError = storedErrors.find(item => item?.schemaPath === schemaPath && item.instancePath === error.instancePath); if (refError && isOfError(refError)) { // TODO: bug: refError may not be an OF (which means it may not have choices) // maybe I thought that non-OF-errors with the same schemaPath could not co-exist? ref = refError.choices; continue; } // not yet stored, add it // Fixed https://github.com/ghmcadams/vscode-lintlens/pull/78 const ofError = ofs.find(item => item.schemaPath === schemaPath && item.instancePath === error.instancePath); if (ofError) { ofError.choices = []; if (ref === ret) { ref.push(ofError); } else { const choiceIndex = getOfChoiceIndex(ref, ofError.schemaPath); const choice = (ref[choiceIndex] ?? []); choice.push(ofError); ref[choiceIndex] = choice; } ref = ofError.choices; continue; } // OF not found anywhere (not supposed to happen) break; } // TODO: verify that this comment is correct (and maybe improve it) // Add current error to the return stack if (ref === ret) { ref.push(error); } else { const choiceIndex = getOfChoiceIndex(ref, error.schemaPath); const choice = (ref[choiceIndex] ?? []); choice.push(error); ref[choiceIndex] = choice; } } return ret; } function getSchemaOfs(error) { // get the list of OF schema paths for this error const schemaOfs = []; const schemaParts = error.schemaPath.split('/'); schemaParts.pop(); // Ignore the last element (current error) schemaParts.forEach((part, index) => { if (ofErrorKeywords.includes(part)) { schemaOfs.push(schemaParts.slice(0, index + 1).join('/')); } }); return schemaOfs; } function replaceSchemaRefs(errors) { errors.forEach(error => { if (!Array.isArray(error.schema)) { return; } const schema = error.schema; schema.forEach((item, index) => { const schemaPathReplacement = `${error.schemaPath}/${index}`; if (getType(item) === 'object' && item.hasOwnProperty('$ref') && item.$ref != undefined) { const ref = item.$ref; errors.forEach(otherError => { if (otherError.instancePath === error.instancePath) { otherError.schemaPath = otherError.schemaPath.replace(ref, schemaPathReplacement); if (otherError.schemaPath.slice(schemaPathReplacement.length + 1).includes('/')) { schema[index] = otherError.parentSchema; } } }); } }); }); } function filterByChosenPath(ofHierarchy) { const ret = []; for (const error of ofHierarchy) { if (isOfError(error)) { const chosenPaths = getChosenPaths(error); const { choices, ...ofError } = error; if (chosenPaths.length === 1) { ret.push(...filterByChosenPath(choices[chosenPaths[0]] ?? [])); continue; } if (chosenPaths.length > 1) { const ofPaths = chosenPaths.map(pathIndex => filterByChosenPath(choices[pathIndex] ?? [])); const distinctErrors = getDistinctAcrossOfErrors({ ...ofError, ofPaths }); ret.push(...distinctErrors); } } else { ret.push(error); } } const lowestErrors = findLowestErrors(ret); return lowestErrors; } function findLowestErrors(errors) { const tracker = new Map(); errors.forEach(error => { const existing = tracker.get(error.instancePath); if (existing) { existing.push(error); return; } // If ancestor exists, replace with current const check = error.instancePath.split('/').slice(0, -1); while (check.length > 0) { const checkKey = check.join('/'); if (tracker.has(checkKey)) { tracker.delete(checkKey); tracker.set(error.instancePath, [error]); return; } check.pop(); } // If there are no deeper ones, insert current const foundLonger = Array.from(tracker.keys()).some(key => key.startsWith(`${error.instancePath}/`)); if (!foundLonger) { tracker.set(error.instancePath, [error]); return; } }); const lowestErrors = Array.from(tracker.values()).flat(); // add ancestors if they are in the allow list for (const lowError of lowestErrors.filter(({ instancePath }) => instancePath !== '')) { const lastSegment = lowError.instancePath.substring(lowError.instancePath.lastIndexOf('/') + 1); const ancestorErrors = errors.filter(error => { // already there if (lowestErrors.includes(error)) { return false; } // same instancePath if (error.instancePath === lowError.instancePath) { return false; } // not ancestor if (!lowError.instancePath.startsWith(error.instancePath)) { return false; } // not in the same schemaPath (of choice) const sharedPath = getCommonPrefix(lowError.schemaPath, error.schemaPath); if (sharedPath.endsWith('Of/')) { return false; } // not valid parent keyword if (!parentKeywordsOk.includes(error.keyword)) { return false; } // refers to the same property if (error.keyword === 'additionalProperties' && error.params.additionalProperty === lastSegment) { return false; } return true; }); lowestErrors.push(...ancestorErrors); } lowestErrors.sort((a, b) => a.instancePath.localeCompare(b.instancePath)); // remove type error when enum one exists const enumError = lowestErrors.find(({ keyword }) => keyword === 'enum'); if (enumError) { return lowestErrors.filter(error => error.schemaPath !== enumError.schemaPath.replace(/\/enum$/, '/type')); } return lowestErrors; } function getChosenPaths(error) { const { choices, data, schema } = error; const choiceIndices = choices.reduce((ret, item, index) => { if (item !== undefined) { ret.push(index); } return ret; }, []); if (choiceIndices.length === 1) { return choiceIndices; } if (schema === undefined) { return choiceIndices; } const allIndices = schema.map((_, index) => index); const typeofSchema = getType(schema); const typeofData = getType(data); if (typeofSchema !== 'array' || typeofData === 'undefined') { // unable to determine a path if I cannot inspect data or schema return allIndices; } const indicesTypeMatch = choiceIndices.reduce((ret, index) => { const entry = schema[index] ?? {}; if (entry.hasOwnProperty('enum')) { const score = getValueMatchScore(data, entry.enum); if (score > 0.7) { ret.push(index); } return ret; } if (entry.hasOwnProperty('const')) { const score = getValueMatchScore(data, entry.const); if (score > 0.7) { ret.push(index); } return ret; } const typesofEntry = getSchemaType(entry); if (typesofEntry.includes(typeofData)) { ret.push(index); } return ret; }, []); if (indicesTypeMatch.length === 0) { // Nothing matches type of data (not sure why) return allIndices; } if (indicesTypeMatch.length === 1) { // only one matches the type of data, must be the one return indicesTypeMatch; } if (typeofData === 'object') { return getChosenPathsWhenObject(error, indicesTypeMatch); } if (typeofData === 'array') { return getChosenPathsWhenArray(error, indicesTypeMatch); } return indicesTypeMatch; } function getChosenPathsWhenObject(error, indices) { if (!indices || indices.length === 0) { return indices; } const { data, schema } = error; const indicesWithScore = indices.map(index => { const entry = schema[index]; const matchScore = getPropertyMatchScore(data, entry); return [index, matchScore]; }); indicesWithScore.sort(([, a], [, b]) => (b ?? 0) - (a ?? 0)); const firstWithScore = indicesWithScore[0] ?? []; // none have matches if (firstWithScore === undefined || firstWithScore[1] === 0) { return indices; } // return an array with all that have the most const ret = indicesWithScore .filter(entry => entry[1] === firstWithScore[1]) .map(entry => entry[0]); return ret; } function getChosenPathsWhenArray(error, indices) { if (!indices || indices.length === 0) { return indices; } const { data, schema } = error; // minItems and maxItems matches data item count const dataItemCount = data.length; const indicesfromArray = indices.reduce((ret, index) => { const entry = schema[index]; const minItems = entry?.minItems ?? 0; const maxItems = entry?.maxItems ?? Number.MAX_SAFE_INTEGER; if (dataItemCount >= minItems && dataItemCount <= maxItems) { ret.push(index); } return ret; }, []); if (indicesfromArray.length === 0) { // Nothing matches (not sure why) return indices; } if (indicesfromArray.length === 1) { // only one matches, most likely the one return indicesfromArray; } // more than one match found - check details of each schema part const indicesfromDetails = indicesfromArray.reduce((ret, index) => { const entry = schema[index] ?? {}; if (!entry.items) { return ret; } // TODO: look more into items: boolean (what does it mean for items = true) if (entry.items === true) { return ret; } let items = entry.items; let isTuple = true; if (!Array.isArray(items)) { isTuple = false; if (items.hasOwnProperty('anyOf') && items.anyOf !== undefined) { items = items.anyOf; } else if (items.hasOwnProperty('oneOf') && items.oneOf !== undefined) { items = items.oneOf; } else { items = [items]; } } const itemsArray = items; data.forEach((dataItem, dataIndex) => { // if it is a tuple, then the length must match // allowing less than for in progress scenarios if (isTuple && data.length > itemsArray.length) { return; } const dataItemType = getType(dataItem); const itemsForThisIndex = (isTuple ? [itemsArray[dataIndex]] : itemsArray); const anyMatch = itemsForThisIndex.some(item => { const itemTypes = getSchemaType(item); if (!itemTypes.includes(dataItemType)) { return false; } // if it matches any of the enum values, return true if (item.hasOwnProperty('enum') && item.enum !== undefined) { if (item.enum.includes(dataItem)) { return true; } } // if it matches the const value, return true if (item.hasOwnProperty('const') && item.const !== undefined) { if (item.const === dataItem) { return true; } } if (dataItemType === 'object') { // TODO: static score is less meaningful - need to compare with others const matchScore = getPropertyMatchScore(dataItem, item); if (matchScore > 0.5) { return true; } } return false; }); if (anyMatch) { ret.push(index); } }); return ret; }, []); if (indicesfromDetails.length === 0) { // Nothing matches (not sure why) return indices; } if (indicesfromDetails.length === 1) { // only one matches, most likely the one return indicesfromDetails; } return indicesfromDetails; } function getDistinctAcrossOfErrors(ofError) { const ret = []; for (let i = 0; i < ofError.ofPaths.length - 1; i++) { const path = ofError.ofPaths[i]; if (path !== undefined) { const pathErrors = path.filter(error => { const hasMatch = ofError.ofPaths.slice(i + 1).some(errors => { return errors.some(otherError => areErrorsEqual(error, otherError)); }); return !hasMatch; }); if (pathErrors.length > 0) { ret.push(pathErrors); } } } ret.push(ofError.ofPaths[ofError.ofPaths.length - 1]); if (ret.length < 2) { return ret.flat(); } return [{ ...ofError, ofPaths: ret, }]; } function getMessageForError(error, omitBe = false) { switch (error.keyword) { case 'anyOf': case 'oneOf': return getMessageForOf(error); case 'maxItems': case 'additionalItems': case 'unevaluatedItems': return `not have more than ${error.params.limit} ${pluralize('item', error.params.limit)}`; case 'minItems': return `have at least ${error.params.limit} ${pluralize('item', error.params.limit)}`; case 'maxProperties': return `not have more than ${error.params.limit} ${pluralize('property', error.params.limit)}`; case 'minProperties': return `have at least ${error.params.limit} ${pluralize('property', error.params.limit)}`; case 'additionalProperties': return `not include property '${error.params.additionalProperty}'`; case 'unevaluatedProperties': return 'not have unevaluated properties'; case 'required': return `include property '${error.params.missingProperty}'`; case 'pattern': return `match pattern '${error.params.pattern}'`; case 'format': return `match format ${error.params.format}`; case 'maximum': case 'minimum': case 'exclusiveMaximum': case 'exclusiveMinimum': return `${omitBe ? '' : 'be '}${error.params.comparison} ${error.params.limit}`; case 'multipleOf': return `${omitBe ? '' : 'be '}a multiple of ${error.params.multipleOf}`; case 'type': { const type = error.params.type; let typesString = Array.isArray(type) ? type.join(' or ') : type; if (typesString === 'array') { const arrayOfType = error.parentSchema?.items?.type; if (arrayOfType) { typesString = `${arrayOfType}[]`; } } return `${omitBe ? '' : 'be '}${typesString}${error.params.nullable ? ' or null' : ''}`; } case 'enum': { const allowedList = error.params.allowedValues .map((value) => (typeof value === 'string' || value instanceof String) ? `'${value}'` : value) .join(', '); const beWhat = error.params.allowedValues.length === 1 ? allowedList : `one of the following: ${allowedList}`; return `${omitBe ? '' : 'be '}${beWhat}`; } case 'const': return `${omitBe ? '' : 'be '} '${error.params.allowedValue}'`; case 'minLength': { if (error.params.limit === 1) { return `not be empty`; } return `${omitBe ? '' : 'be '}at least ${error.params.limit} characters`; } case 'maxLength': return `${omitBe ? '' : 'be '}no more than ${error.params.limit} ${pluralize('character', error.params.limit)}`; case 'uniqueItems': { const { i, j } = error.params; return `have all unique items (items ${Math.min(i, j)} and ${Math.max(i, j)} are identical)`; } case 'propertyNames': return `${omitBe ? '' : 'be '}a valid property name`; case 'contains': { return error.params.maxContains === undefined ? `contain at least ${error.params.minContains} valid ${pluralize('item', error.params.minContains)}` : `contain between ${error.params.minContains} and ${error.params.maxContains} valid ${pluralize('item', error.params.minContains)}`; } case 'dependencies': return `have ${error.params.deps} when ${error.params.property} is included`; default: return 'match schema'; } } function getMessageForOf(ofError) { try { const messageParts = []; // TODO: handle differing instancePath's let seenBe = false; // TODO: why am I checking ofPaths is undefined? ofError.ofPaths?.forEach(path => { const pathMessageParts = []; path.forEach(error => { const subMessage = getMessageForError(error, seenBe); if (subMessage.startsWith('be')) { seenBe = true; } pathMessageParts.push(subMessage); }); messageParts.push(pathMessageParts.join(' and ')); }); return messageParts.join(' or '); } catch (err) { return 'match one of the schema options'; } } function isOfError(error) { return ofErrorKeywords.includes(error.keyword); } //# sourceMappingURL=index.js.map