From 23e3f049017fceb9b891713742259d5e8836e0c1 Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Mon, 5 Oct 2026 11:48:24 -0700 Subject: [PATCH 1/2] Pass a context to error handlers instead of the AST Error handlers, getErrors, and getSuccesses now take an ErrorHandlerContext instead of the AST. The context has the AST and gives a place for information about how the instance was validated, which needs to reach handlers through nested descriptions, including ones built by third-party applicator handlers. describeEach and describeScope take the context too because they describe subschemas. Helpers that only read the schema still take the AST. Co-Authored-By: Claude Opus 5.5 --- src/error-handlers/anyOf.js | 12 ++++----- src/error-handlers/contains.js | 20 +++++++------- src/error-handlers/dependentSchemas.js | 17 ++++++------ src/error-handlers/draft-04/dependencies.js | 10 +++---- src/error-handlers/eachProperty.js | 18 ++++++------- src/error-handlers/format.js | 8 +++--- src/error-handlers/ifThenElse.js | 8 +++--- src/error-handlers/items.js | 6 ++--- src/error-handlers/maxItems.js | 8 +++--- src/error-handlers/maxLength.js | 8 +++--- src/error-handlers/maxProperties.js | 8 +++--- src/error-handlers/maximum.js | 20 +++++++------- src/error-handlers/minItems.js | 8 +++--- src/error-handlers/minLength.js | 8 +++--- src/error-handlers/minProperties.js | 8 +++--- src/error-handlers/minimum.js | 20 +++++++------- src/error-handlers/multipleOf.js | 8 +++--- src/error-handlers/not.js | 10 +++---- src/error-handlers/oneOf.js | 29 ++++++++++----------- src/error-handlers/pattern.js | 8 +++--- src/error-handlers/prefixItems.js | 8 +++--- src/error-handlers/properties.js | 8 +++--- src/error-handlers/required.js | 16 ++++++------ src/error-handlers/typeConstEnum.js | 16 ++++++------ src/error-handlers/uniqueItems.js | 4 +-- src/index.d.ts | 15 ++++++++--- src/json-schema-errors.js | 26 +++++++++--------- src/output-format.js | 2 +- 28 files changed, 171 insertions(+), 166 deletions(-) diff --git a/src/error-handlers/anyOf.js b/src/error-handlers/anyOf.js index 10534d7..9f97a7a 100644 --- a/src/error-handlers/anyOf.js +++ b/src/error-handlers/anyOf.js @@ -8,7 +8,7 @@ import { allTrue, getErrors, getSuccesses, someTrue } from "../json-schema-error /** @type ErrorHandler */ const anyOfErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; @@ -53,13 +53,13 @@ const anyOfErrorHandler = { } // The alternative passed all the filters - alternatives.push(getErrors(alternative, instance, localization, ast)); + alternatives.push(getErrors(alternative, instance, localization, context)); } // If all alternatives were filtered out, default to returning all of them if (alternatives.length === 0) { for (const alternative of anyOf) { - alternatives.push(getErrors(alternative, instance, localization, ast)); + alternatives.push(getErrors(alternative, instance, localization, context)); } } @@ -78,7 +78,7 @@ const anyOfErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -91,7 +91,7 @@ const anyOfErrorHandler = { // alternative fail could make another one pass, so all of them need to // be described even if some of them fail now. const alternativeOptions = alternatives.map((alternative) => { - return getSuccesses(alternative, instance, localization, ast); + return getSuccesses(alternative, instance, localization, context); }); // An alternative that can't be described means we can't say how to make it fail @@ -112,7 +112,7 @@ const anyOfErrorHandler = { // even if we know which ones match, because these descriptions tell the // user what would need to change to make 'anyOf' fail. const descriptions = alternatives.map((alternative) => { - return getSuccesses(alternative, instance, localization, ast); + return getSuccesses(alternative, instance, localization, context); }); if (descriptions.some((description) => description.length === 0)) { diff --git a/src/error-handlers/contains.js b/src/error-handlers/contains.js index 1b62998..f40221e 100644 --- a/src/error-handlers/contains.js +++ b/src/error-handlers/contains.js @@ -13,7 +13,7 @@ const keywordUris = [ /** @type ErrorHandler */ const containsErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; @@ -27,20 +27,20 @@ const containsErrorHandler = { /** @type string[] */ const schemaLocations = [schemaLocation]; - const contains = /** @type ContainsAst */ (getCompiledKeywordValue(ast, schemaLocation)); + const contains = /** @type ContainsAst */ (getCompiledKeywordValue(context.ast, schemaLocation)); /** @type ContainsRange */ const range = {}; if (typeof contains !== "string") { if (contains.minContains !== 1) { range.minContains = contains.minContains; - const minContainsLocation = getSiblingKeywordLocation(ast, schemaLocation, "https://json-schema.org/keyword/minContains"); + const minContainsLocation = getSiblingKeywordLocation(context.ast, schemaLocation, "https://json-schema.org/keyword/minContains"); schemaLocations.push(minContainsLocation); } if (contains.maxContains !== Number.MAX_SAFE_INTEGER) { range.maxContains = contains.maxContains; - const maxContainsLocation = getSiblingKeywordLocation(ast, schemaLocation, "https://json-schema.org/keyword/maxContains"); + const maxContainsLocation = getSiblingKeywordLocation(context.ast, schemaLocation, "https://json-schema.org/keyword/maxContains"); schemaLocations.push(maxContainsLocation); // Too many items matched. Report on each matching item how it @@ -53,7 +53,7 @@ const containsErrorHandler = { }); if (matches.length > contains.maxContains) { const descriptions = matches.map(({ itemOutput }) => { - return getSuccesses(itemOutput, instance, localization, ast); + return getSuccesses(itemOutput, instance, localization, context); }); // If any match can't be described, the errors would be misleading @@ -84,7 +84,7 @@ const containsErrorHandler = { // Describe what an item would need to be like using an item that doesn't exist const containsLocation = typeof contains === "string" ? contains : contains.contains; const item = getPlaceholder(instance, String(Instance.length(instance))); - const description = describeEach(containsLocation, item, instance, localization, ast); + const description = describeEach(containsLocation, item, instance, localization, context); if (description.length > 0) { errors.push({ @@ -93,7 +93,7 @@ const containsErrorHandler = { instanceLocation: Instance.uri(instance), schemaLocations: schemaLocations }); - } else if (allowsAnyValue(containsLocation, ast)) { + } else if (allowsAnyValue(containsLocation, context.ast)) { // Any item matches, so there aren't enough items errors.push({ message: localization.getMinItemsErrorMessage(range.minContains ?? 1), @@ -113,7 +113,7 @@ const containsErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -121,7 +121,7 @@ const containsErrorHandler = { for (const schemaLocation in normalizedOutput[keywordUri]) { const itemOutputs = normalizedOutput[keywordUri][schemaLocation].outputs ?? []; - const contains = /** @type ContainsAst | string */ (getCompiledKeywordValue(ast, schemaLocation)); + const contains = /** @type ContainsAst | string */ (getCompiledKeywordValue(context.ast, schemaLocation)); const minContains = typeof contains === "string" ? 1 : contains.minContains; const maxContains = typeof contains === "string" || contains.maxContains === Number.MAX_SAFE_INTEGER ? Infinity @@ -132,7 +132,7 @@ const containsErrorHandler = { const notMatching = itemOutputs.filter((itemOutput) => !isPassing(itemOutput)); /** @type (itemOutput: NormalizedOutput, localization: Localization) => ErrorObject[] */ - const describe = (itemOutput, localization) => getSuccesses(itemOutput, instance, localization, ast); + const describe = (itemOutput, localization) => getSuccesses(itemOutput, instance, localization, context); if (localization.isNegated) { // 'contains' fails if too few items match or too many items match diff --git a/src/error-handlers/dependentSchemas.js b/src/error-handlers/dependentSchemas.js index 8ad9a09..3b52721 100644 --- a/src/error-handlers/dependentSchemas.js +++ b/src/error-handlers/dependentSchemas.js @@ -7,9 +7,8 @@ import { } from "../json-schema-errors.js"; /** - * @import { AST } from "@hyperjump/json-schema/experimental" * @import { JsonNode } from "@hyperjump/json-schema/instance/experimental" - * @import { ErrorHandler, ErrorObject, Localization, NormalizedOutput } from "../index.d.ts" + * @import { ErrorHandler, ErrorHandlerContext, ErrorObject, Localization, NormalizedOutput } from "../index.d.ts" */ /** @type ErrorHandler */ @@ -17,14 +16,14 @@ const dependentSchemasErrorHandler = { // Failures in dependent schemas are merged into the parent schema's results, // so they're reported by the handlers for the keywords that failed - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/dependentSchemas"]) { - const dependencies = /** @type [string, string][] */ (getCompiledKeywordValue(ast, schemaLocation)); + const dependencies = /** @type [string, string][] */ (getCompiledKeywordValue(context.ast, schemaLocation)); const outputs = normalizedOutput["https://json-schema.org/keyword/dependentSchemas"][schemaLocation].outputs ?? []; - successes.push(...describeSchemaDependencies(dependencies, outputs, instance, localization, ast)); + successes.push(...describeSchemaDependencies(dependencies, outputs, instance, localization, context)); } return successes; @@ -36,9 +35,9 @@ const dependentSchemasErrorHandler = { * dependency's property. The outputs are for the dependencies whose property is * present, in the order they appear in the schema. * - * @type (dependencies: [string, string][], outputs: NormalizedOutput[], instance: JsonNode, localization: Localization, ast: AST) => ErrorObject[] + * @type (dependencies: [string, string][], outputs: NormalizedOutput[], instance: JsonNode, localization: Localization, context: ErrorHandlerContext) => ErrorObject[] */ -export const describeSchemaDependencies = (dependencies, outputs, instance, localization, ast) => { +export const describeSchemaDependencies = (dependencies, outputs, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -48,7 +47,7 @@ export const describeSchemaDependencies = (dependencies, outputs, instance, loca for (const [propertyName, dependencyLocation] of dependencies) { const isPresent = isObject && Instance.has(propertyName, instance); const output = (isPresent ? outputs[outputIndex++] : undefined) - ?? evaluateRequirements(dependencyLocation, instance, ast); + ?? evaluateRequirements(dependencyLocation, instance, context.ast); // The dependency only applies if the property is present successes.push(...describeConditional({ @@ -57,7 +56,7 @@ export const describeSchemaDependencies = (dependencies, outputs, instance, loca instanceLocation: Instance.uri(instance), schemaLocations: [dependencyLocation] }], - then: (localization) => getSuccesses(output, instance, localization, ast) + then: (localization) => getSuccesses(output, instance, localization, context) }, instance, dependencyLocation, localization)); } diff --git a/src/error-handlers/draft-04/dependencies.js b/src/error-handlers/draft-04/dependencies.js index cd2b317..2abfb50 100644 --- a/src/error-handlers/draft-04/dependencies.js +++ b/src/error-handlers/draft-04/dependencies.js @@ -7,7 +7,7 @@ import { describeSchemaDependencies } from "../dependentSchemas.js"; /** @type ErrorHandler */ const dependenciesErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; @@ -18,7 +18,7 @@ const dependenciesErrorHandler = { const dependentSchemaOutputs = normalizedErrors["https://json-schema.org/keyword/draft-04/dependencies"][schemaLocation].outputs ?? []; for (const dependentSchemaOutput of dependentSchemaOutputs) { - const dependentSchemaErrors = getErrors(dependentSchemaOutput, instance, localization, ast); + const dependentSchemaErrors = getErrors(dependentSchemaOutput, instance, localization, context); errors.push(...dependentSchemaErrors); } } @@ -26,16 +26,16 @@ const dependenciesErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/draft-04/dependencies"]) { // Array-form dependencies are handled with 'required' - const dependencies = /** @type [string, string | string[]][] */ (getCompiledKeywordValue(ast, schemaLocation)); + const dependencies = /** @type [string, string | string[]][] */ (getCompiledKeywordValue(context.ast, schemaLocation)); const schemaDependencies = /** @type [string, string][] */ (dependencies.filter(([, dependency]) => typeof dependency === "string")); const outputs = normalizedOutput["https://json-schema.org/keyword/draft-04/dependencies"][schemaLocation].outputs ?? []; - successes.push(...describeSchemaDependencies(schemaDependencies, outputs, instance, localization, ast)); + successes.push(...describeSchemaDependencies(schemaDependencies, outputs, instance, localization, context)); } return successes; diff --git a/src/error-handlers/eachProperty.js b/src/error-handlers/eachProperty.js index d5e2173..62a5ab3 100644 --- a/src/error-handlers/eachProperty.js +++ b/src/error-handlers/eachProperty.js @@ -23,7 +23,7 @@ import { * @type ErrorHandler */ const eachPropertyErrorHandler = { - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -37,43 +37,43 @@ const eachPropertyErrorHandler = { const property = getPlaceholder(instance, unusedPropertyName(instance)); for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/patternProperties"]) { - const patternProperties = /** @type [RegExp, string][] */ (getCompiledKeywordValue(ast, schemaLocation)); + const patternProperties = /** @type [RegExp, string][] */ (getCompiledKeywordValue(context.ast, schemaLocation)); for (const [pattern, subschemaLocation] of patternProperties) { successes.push(...describeScope({ subschemaLocation, placeholder: property, each: (localization, count) => localization.getEachMatchingPropertySuccessMessage(pattern.source, count), none: (localization) => localization.getNoMatchingPropertySuccessMessage(pattern.source) - }, instance, schemaLocation, localization, ast)); + }, instance, schemaLocation, localization, context)); } } for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/additionalProperties"]) { - const [, subschemaLocation] = /** @type [RegExp, string] */ (getCompiledKeywordValue(ast, schemaLocation)); + const [, subschemaLocation] = /** @type [RegExp, string] */ (getCompiledKeywordValue(context.ast, schemaLocation)); const properties = Object.keys(/** @type Record */ ( - getSiblingValue(ast, schemaLocation, "https://json-schema.org/keyword/properties") ?? {} + getSiblingValue(context.ast, schemaLocation, "https://json-schema.org/keyword/properties") ?? {} )); const patterns = /** @type [RegExp, string][] */ ( - getSiblingValue(ast, schemaLocation, "https://json-schema.org/keyword/patternProperties") ?? [] + getSiblingValue(context.ast, schemaLocation, "https://json-schema.org/keyword/patternProperties") ?? [] ).map(([pattern]) => pattern.source); successes.push(...describeScope({ subschemaLocation, placeholder: property, each: (localization, count) => localization.getEachAdditionalPropertySuccessMessage(properties, patterns, count), none: (localization) => localization.getNoAdditionalPropertySuccessMessage(properties, patterns) - }, instance, schemaLocation, localization, ast)); + }, instance, schemaLocation, localization, context)); } // Property names are at a different location than property values const propertyName = getPropertyNamePlaceholder(instance, unusedPropertyName(instance)); for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/propertyNames"]) { - const subschemaLocation = /** @type string */ (getCompiledKeywordValue(ast, schemaLocation)); + const subschemaLocation = /** @type string */ (getCompiledKeywordValue(context.ast, schemaLocation)); successes.push(...describeScope({ subschemaLocation, placeholder: propertyName, each: (localization, count) => localization.getEachPropertyNameSuccessMessage(count), none: (localization) => localization.getMaxPropertiesSuccessMessage(0) - }, instance, schemaLocation, localization, ast)); + }, instance, schemaLocation, localization, context)); } return successes; diff --git a/src/error-handlers/format.js b/src/error-handlers/format.js index 44926dc..0e670a1 100644 --- a/src/error-handlers/format.js +++ b/src/error-handlers/format.js @@ -17,7 +17,7 @@ const keywordUris = [ /** @type ErrorHandler */ const formatErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; @@ -27,7 +27,7 @@ const formatErrorHandler = { continue; } - const format = /** @type string */ (getCompiledKeywordValue(ast, schemaLocation)); + const format = /** @type string */ (getCompiledKeywordValue(context.ast, schemaLocation)); errors.push({ message: localization.getFormatErrorMessage(format), @@ -40,13 +40,13 @@ const formatErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { // Whether 'format' is an assertion depends on how the validator is // configured, which we don't know, so the messages say that it only applies // if formats are validated. That includes 'format-assertion' because some // validators can be configured not to validate it either. return keywordUris.flatMap((keywordUri) => { - return describeKeyword(normalizedOutput, keywordUri, instance, ast, (/** @type string */ format) => { + return describeKeyword(normalizedOutput, keywordUri, instance, context.ast, (/** @type string */ format) => { return localization.getFormatSuccessMessage(format); }); }); diff --git a/src/error-handlers/ifThenElse.js b/src/error-handlers/ifThenElse.js index 896c610..22a1cad 100644 --- a/src/error-handlers/ifThenElse.js +++ b/src/error-handlers/ifThenElse.js @@ -17,20 +17,20 @@ const ifThenElseErrorHandler = { // Failures in 'then' and 'else' are merged into the parent schema's results, // so they're reported by the handlers for the keywords that failed - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; for (const ifLocation in normalizedOutput["https://json-schema.org/keyword/if"]) { const ifOutput = normalizedOutput["https://json-schema.org/keyword/if"][ifLocation].outputs?.[0]; - const thenOutput = getSiblingOutput(normalizedOutput, "https://json-schema.org/keyword/then", ifLocation, instance, ast); - const elseOutput = getSiblingOutput(normalizedOutput, "https://json-schema.org/keyword/else", ifLocation, instance, ast); + const thenOutput = getSiblingOutput(normalizedOutput, "https://json-schema.org/keyword/then", ifLocation, instance, context.ast); + const elseOutput = getSiblingOutput(normalizedOutput, "https://json-schema.org/keyword/else", ifLocation, instance, context.ast); if (!ifOutput || (!thenOutput && !elseOutput)) { continue; } /** @type (output: NormalizedOutput) => (localization: Localization) => ErrorObject[] */ - const describe = (output) => (localization) => getSuccesses(output, instance, localization, ast); + const describe = (output) => (localization) => getSuccesses(output, instance, localization, context); // Both branches are described, even if we know which way 'if' went, // because changing the value could change whether 'if' passes diff --git a/src/error-handlers/items.js b/src/error-handlers/items.js index 5ff6184..341440d 100644 --- a/src/error-handlers/items.js +++ b/src/error-handlers/items.js @@ -14,7 +14,7 @@ import { describeScope, getCompiledKeywordValue, getPlaceholder, isPlaceholder } * @type ErrorHandler */ const itemsErrorHandler = { - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -24,7 +24,7 @@ const itemsErrorHandler = { return successes; } - for (const [schemaLocation, startIndex, itemsLocation] of getItemsKeywords(normalizedOutput, ast)) { + for (const [schemaLocation, startIndex, itemsLocation] of getItemsKeywords(normalizedOutput, context.ast)) { // An item that doesn't exist stands in for any item const length = Instance.typeOf(instance) === "array" ? Instance.length(instance) : 0; successes.push(...describeScope({ @@ -32,7 +32,7 @@ const itemsErrorHandler = { placeholder: getPlaceholder(instance, String(Math.max(startIndex, length))), each: (localization, count) => localization.getEachItemSuccessMessage(startIndex, count), none: (localization) => localization.getMaxItemsSuccessMessage(startIndex) - }, instance, schemaLocation, localization, ast)); + }, instance, schemaLocation, localization, context)); } return successes; diff --git a/src/error-handlers/maxItems.js b/src/error-handlers/maxItems.js index f329680..5b530a4 100644 --- a/src/error-handlers/maxItems.js +++ b/src/error-handlers/maxItems.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue } from "../json-schema-errors. /** @type ErrorHandler */ const maxItemsErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; let lowestMaxItems = Infinity; @@ -18,7 +18,7 @@ const maxItemsErrorHandler = { continue; } - const maxItems = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const maxItems = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (maxItems < lowestMaxItems) { lowestMaxItems = maxItems; @@ -37,8 +37,8 @@ const maxItemsErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { - return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/maxItems", instance, ast, (/** @type number */ maxItems) => { + success: (normalizedOutput, instance, localization, context) => { + return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/maxItems", instance, context.ast, (/** @type number */ maxItems) => { return localization.getMaxItemsSuccessMessage(maxItems); }); } diff --git a/src/error-handlers/maxLength.js b/src/error-handlers/maxLength.js index 02cddc4..463910e 100644 --- a/src/error-handlers/maxLength.js +++ b/src/error-handlers/maxLength.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue } from "../json-schema-errors. /** @type ErrorHandler */ const maxLengthErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; let lowestMaxLength = Infinity; @@ -18,7 +18,7 @@ const maxLengthErrorHandler = { continue; } - const maxLength = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const maxLength = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (maxLength < lowestMaxLength) { lowestMaxLength = maxLength; @@ -36,8 +36,8 @@ const maxLengthErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { - return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/maxLength", instance, ast, (/** @type number */ maxLength) => { + success: (normalizedOutput, instance, localization, context) => { + return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/maxLength", instance, context.ast, (/** @type number */ maxLength) => { return localization.getMaxLengthSuccessMessage(maxLength); }); } diff --git a/src/error-handlers/maxProperties.js b/src/error-handlers/maxProperties.js index 6f8335f..b9c48c7 100644 --- a/src/error-handlers/maxProperties.js +++ b/src/error-handlers/maxProperties.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue } from "../json-schema-errors. /** @type ErrorHandler */ const maxPropertiesErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; let lowestMaxProperties = Infinity; @@ -17,7 +17,7 @@ const maxPropertiesErrorHandler = { continue; } - const maxProperties = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const maxProperties = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (maxProperties < lowestMaxProperties) { lowestMaxProperties = maxProperties; @@ -35,8 +35,8 @@ const maxPropertiesErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { - return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/maxProperties", instance, ast, (/** @type number */ maxProperties) => { + success: (normalizedOutput, instance, localization, context) => { + return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/maxProperties", instance, context.ast, (/** @type number */ maxProperties) => { return localization.getMaxPropertiesSuccessMessage(maxProperties); }); } diff --git a/src/error-handlers/maximum.js b/src/error-handlers/maximum.js index 2ac7c01..4a19ab8 100644 --- a/src/error-handlers/maximum.js +++ b/src/error-handlers/maximum.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue, getSiblingKeywordLocation } f /** @type ErrorHandler */ const maximumErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { let lowestMaximum = Infinity; let isExclusive = false; @@ -19,7 +19,7 @@ const maximumErrorHandler = { continue; } - const maximum = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const maximum = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (maximum < lowestMaximum) { lowestMaximum = maximum; schemaLocations = [schemaLocation]; @@ -31,7 +31,7 @@ const maximumErrorHandler = { continue; } - const exclusiveMaximum = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const exclusiveMaximum = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (exclusiveMaximum < lowestMaximum) { lowestMaximum = exclusiveMaximum; isExclusive = true; @@ -44,13 +44,13 @@ const maximumErrorHandler = { continue; } - const [maximum, exclusive] = /** @type [number, boolean] */ (getCompiledKeywordValue(ast, schemaLocation)); + const [maximum, exclusive] = /** @type [number, boolean] */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (maximum < lowestMaximum) { lowestMaximum = maximum; isExclusive = exclusive; schemaLocations = [schemaLocation]; if (exclusive) { - const exclusiveLocation = getSiblingKeywordLocation(ast, schemaLocation, "https://json-schema.org/keyword/draft-04/exclusiveMaximum"); + const exclusiveLocation = getSiblingKeywordLocation(context.ast, schemaLocation, "https://json-schema.org/keyword/draft-04/exclusiveMaximum"); schemaLocations.push(exclusiveLocation); } } @@ -73,23 +73,23 @@ const maximumErrorHandler = { } }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; - successes.push(...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/maximum", instance, ast, (/** @type number */ maximum) => { + successes.push(...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/maximum", instance, context.ast, (/** @type number */ maximum) => { return localization.getMaximumSuccessMessage(maximum); })); - successes.push(...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/exclusiveMaximum", instance, ast, (/** @type number */ exclusiveMaximum) => { + successes.push(...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/exclusiveMaximum", instance, context.ast, (/** @type number */ exclusiveMaximum) => { return localization.getExclusiveMaximumSuccessMessage(exclusiveMaximum); })); // Draft-04 has a boolean 'exclusiveMaximum' keyword that modifies 'maximum' for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/draft-04/maximum"]) { - const [maximum, exclusive] = /** @type [number, boolean] */ (getCompiledKeywordValue(ast, schemaLocation)); + const [maximum, exclusive] = /** @type [number, boolean] */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (exclusive) { - const exclusiveLocation = getSiblingKeywordLocation(ast, schemaLocation, "https://json-schema.org/keyword/draft-04/exclusiveMaximum"); + const exclusiveLocation = getSiblingKeywordLocation(context.ast, schemaLocation, "https://json-schema.org/keyword/draft-04/exclusiveMaximum"); successes.push({ message: localization.getExclusiveMaximumSuccessMessage(maximum), instanceLocation: Instance.uri(instance), diff --git a/src/error-handlers/minItems.js b/src/error-handlers/minItems.js index 9f646c2..3ba7193 100644 --- a/src/error-handlers/minItems.js +++ b/src/error-handlers/minItems.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue } from "../json-schema-errors. /** @type ErrorHandler */ const minItemsErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; let highestMinItem = 0; @@ -18,7 +18,7 @@ const minItemsErrorHandler = { continue; } - const minItems = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const minItems = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (minItems > highestMinItem) { highestMinItem = minItems; @@ -37,8 +37,8 @@ const minItemsErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { - return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/minItems", instance, ast, (/** @type number */ minItems) => { + success: (normalizedOutput, instance, localization, context) => { + return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/minItems", instance, context.ast, (/** @type number */ minItems) => { return localization.getMinItemsSuccessMessage(minItems); }); } diff --git a/src/error-handlers/minLength.js b/src/error-handlers/minLength.js index 88ee4f2..2d1c247 100644 --- a/src/error-handlers/minLength.js +++ b/src/error-handlers/minLength.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue } from "../json-schema-errors. /** @type ErrorHandler */ const minLengthErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; let highestMinLength = -Infinity; @@ -18,7 +18,7 @@ const minLengthErrorHandler = { continue; } - const minLength = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const minLength = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (minLength > highestMinLength) { highestMinLength = minLength; @@ -36,8 +36,8 @@ const minLengthErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { - return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/minLength", instance, ast, (/** @type number */ minLength) => { + success: (normalizedOutput, instance, localization, context) => { + return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/minLength", instance, context.ast, (/** @type number */ minLength) => { return localization.getMinLengthSuccessMessage(minLength); }); } diff --git a/src/error-handlers/minProperties.js b/src/error-handlers/minProperties.js index 9814ebe..6e1bc23 100644 --- a/src/error-handlers/minProperties.js +++ b/src/error-handlers/minProperties.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue } from "../json-schema-errors. /** @type ErrorHandler */ const minPropertiesErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; @@ -19,7 +19,7 @@ const minPropertiesErrorHandler = { continue; } - const minProperties = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const minProperties = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (minProperties > highestMinProperties) { highestMinProperties = minProperties; @@ -38,8 +38,8 @@ const minPropertiesErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { - return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/minProperties", instance, ast, (/** @type number */ minProperties) => { + success: (normalizedOutput, instance, localization, context) => { + return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/minProperties", instance, context.ast, (/** @type number */ minProperties) => { return localization.getMinPropertiesSuccessMessage(minProperties); }); } diff --git a/src/error-handlers/minimum.js b/src/error-handlers/minimum.js index b836f2b..8ec4cab 100644 --- a/src/error-handlers/minimum.js +++ b/src/error-handlers/minimum.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue, getSiblingKeywordLocation } f /** @type ErrorHandler */ const minimumErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { let highestMinimum = -Infinity; let isExclusive = false; /** @type string[] */ @@ -18,7 +18,7 @@ const minimumErrorHandler = { continue; } - const minimum = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const minimum = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (minimum > highestMinimum) { highestMinimum = minimum; @@ -31,7 +31,7 @@ const minimumErrorHandler = { continue; } - const exclusiveMinimum = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const exclusiveMinimum = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (exclusiveMinimum > highestMinimum) { highestMinimum = exclusiveMinimum; @@ -45,13 +45,13 @@ const minimumErrorHandler = { continue; } - const [minimum, exclusive] = /** @type [number, boolean] */ (getCompiledKeywordValue(ast, schemaLocation)); + const [minimum, exclusive] = /** @type [number, boolean] */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (minimum > highestMinimum) { highestMinimum = minimum; isExclusive = exclusive; schemaLocations = [schemaLocation]; if (exclusive) { - const exclusiveLocation = getSiblingKeywordLocation(ast, schemaLocation, "https://json-schema.org/keyword/draft-04/exclusiveMinimum"); + const exclusiveLocation = getSiblingKeywordLocation(context.ast, schemaLocation, "https://json-schema.org/keyword/draft-04/exclusiveMinimum"); schemaLocations.push(exclusiveLocation); } } @@ -74,23 +74,23 @@ const minimumErrorHandler = { } }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; - successes.push(...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/minimum", instance, ast, (/** @type number */ minimum) => { + successes.push(...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/minimum", instance, context.ast, (/** @type number */ minimum) => { return localization.getMinimumSuccessMessage(minimum); })); - successes.push(...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/exclusiveMinimum", instance, ast, (/** @type number */ exclusiveMinimum) => { + successes.push(...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/exclusiveMinimum", instance, context.ast, (/** @type number */ exclusiveMinimum) => { return localization.getExclusiveMinimumSuccessMessage(exclusiveMinimum); })); // Draft-04 has a boolean 'exclusiveMinimum' keyword that modifies 'minimum' for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/draft-04/minimum"]) { - const [minimum, exclusive] = /** @type [number, boolean] */ (getCompiledKeywordValue(ast, schemaLocation)); + const [minimum, exclusive] = /** @type [number, boolean] */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (exclusive) { - const exclusiveLocation = getSiblingKeywordLocation(ast, schemaLocation, "https://json-schema.org/keyword/draft-04/exclusiveMinimum"); + const exclusiveLocation = getSiblingKeywordLocation(context.ast, schemaLocation, "https://json-schema.org/keyword/draft-04/exclusiveMinimum"); successes.push({ message: localization.getExclusiveMinimumSuccessMessage(minimum), instanceLocation: Instance.uri(instance), diff --git a/src/error-handlers/multipleOf.js b/src/error-handlers/multipleOf.js index 561b0ed..53d4a85 100644 --- a/src/error-handlers/multipleOf.js +++ b/src/error-handlers/multipleOf.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue } from "../json-schema-errors. /** @type ErrorHandler */ const multipleOfErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; @@ -22,7 +22,7 @@ const multipleOfErrorHandler = { hasError = true; } - const multipleOf = /** @type number */ (getCompiledKeywordValue(ast, schemaLocation)); + const multipleOf = /** @type number */ (getCompiledKeywordValue(context.ast, schemaLocation)); combinedMultipleOf = combinedMultipleOf === null ? multipleOf : lcm(combinedMultipleOf, multipleOf); schemaLocations.push(schemaLocation); @@ -39,8 +39,8 @@ const multipleOfErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { - return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/multipleOf", instance, ast, (/** @type number */ multipleOf) => { + success: (normalizedOutput, instance, localization, context) => { + return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/multipleOf", instance, context.ast, (/** @type number */ multipleOf) => { return localization.getMultipleOfSuccessMessage(multipleOf); }); } diff --git a/src/error-handlers/not.js b/src/error-handlers/not.js index d8065db..f8da16d 100644 --- a/src/error-handlers/not.js +++ b/src/error-handlers/not.js @@ -7,7 +7,7 @@ import { allTrue, getSuccesses, isAllTrueGroup, limitOptions, someTrue } from ". /** @type ErrorHandler */ const notErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; @@ -21,7 +21,7 @@ const notErrorHandler = { // knows what needs to change. At least one of these needs to be true. const negatedLocalization = localization.negated(); const options = (not.outputs ?? []).flatMap((notOutput) => { - return getSuccesses(notOutput, instance, negatedLocalization, ast); + return getSuccesses(notOutput, instance, negatedLocalization, context); }); if (options.length === 0) { @@ -51,7 +51,7 @@ const notErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -61,13 +61,13 @@ const notErrorHandler = { for (const notOutput of not.outputs ?? []) { if (localization.isNegated) { // 'not' fails if its schema passes, which requires all of its keywords to pass - const requirements = getSuccesses(notOutput, instance, localization.negated(), ast); + const requirements = getSuccesses(notOutput, instance, localization.negated(), context); successes.push(...allTrue(requirements, instance, schemaLocation, localization)); } else { // 'not' passes if at least one of its schema's keywords fails. All of them // are described, even if we know which ones fail, because these // descriptions tell the user what would need to change to make 'not' fail. - const options = getSuccesses(notOutput, instance, localization.negated(), ast); + const options = getSuccesses(notOutput, instance, localization.negated(), context); successes.push(...someTrue(options.map((option) => [option]), instance, schemaLocation, localization)); } } diff --git a/src/error-handlers/oneOf.js b/src/error-handlers/oneOf.js index 0807ebf..5ce7cf0 100644 --- a/src/error-handlers/oneOf.js +++ b/src/error-handlers/oneOf.js @@ -3,14 +3,13 @@ import * as Pact from "@hyperjump/pact"; import { allTrue, allowsAnyValue, countTrue, getCompiledKeywordValue, getErrors, getSuccesses, isPassing, limitItems, limitOptions, someTrue } from "../json-schema-errors.js"; /** - * @import { AST } from "@hyperjump/json-schema/experimental" * @import { JsonNode } from "@hyperjump/json-schema/instance/experimental" - * @import { ErrorHandler, ErrorObject, InstanceOutput, Localization, NormalizedOutput } from "../index.d.ts" + * @import { ErrorHandler, ErrorHandlerContext, ErrorObject, InstanceOutput, Localization, NormalizedOutput } from "../index.d.ts" */ /** @type ErrorHandler */ const oneOfErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; @@ -21,12 +20,12 @@ const oneOfErrorHandler = { } const oneOf = oneOfOutput.outputs ?? []; - const alternativeLocations = /** @type string[] */ (getCompiledKeywordValue(ast, schemaLocation)); + const alternativeLocations = /** @type string[] */ (getCompiledKeywordValue(context.ast, schemaLocation)); const matches = alternativeLocations.flatMap((alternativeLocation, index) => { return isPassing(oneOf[index]) ? [{ alternativeLocation, output: oneOf[index] }] : []; }); if (matches.length > 1) { - errors.push(multipleMatchesError(matches, schemaLocation, instance, localization, ast)); + errors.push(multipleMatchesError(matches, schemaLocation, instance, localization, context)); continue; } @@ -63,7 +62,7 @@ const oneOfErrorHandler = { } // The alternative passed all the filters - const alternativeErrors = getErrors(alternative, instance, localization, ast); + const alternativeErrors = getErrors(alternative, instance, localization, context); if (alternativeErrors.length) { alternatives.push(alternativeErrors); } @@ -72,7 +71,7 @@ const oneOfErrorHandler = { // If all alternatives were filtered out, default to returning all of them if (alternatives.length === 0) { for (const alternative of oneOf) { - const alternativeErrors = getErrors(alternative, instance, localization, ast); + const alternativeErrors = getErrors(alternative, instance, localization, context); if (alternativeErrors.length) { alternatives.push(alternativeErrors); } @@ -98,7 +97,7 @@ const oneOfErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -111,7 +110,7 @@ const oneOfErrorHandler = { // be described even if we know which one matches now. // Make all alternatives fail - const alternativeOptions = alternatives.map((alternative) => getSuccesses(alternative, instance, localization, ast)); + const alternativeOptions = alternatives.map((alternative) => getSuccesses(alternative, instance, localization, context)); if (alternativeOptions.every((alternativeOption) => alternativeOption.length > 0)) { const requirements = alternativeOptions.flatMap((alternativeOption) => { return someTrue(alternativeOption.map((option) => [option]), instance, schemaLocation, localization); @@ -120,7 +119,7 @@ const oneOfErrorHandler = { } // Make at least two alternatives match - const descriptions = alternatives.map((alternative) => getSuccesses(alternative, instance, localization.negated(), ast)); + const descriptions = alternatives.map((alternative) => getSuccesses(alternative, instance, localization.negated(), context)); if (descriptions.every((description) => description.length > 0)) { const requirements = countTrue(descriptions, { min: 2 }, instance, schemaLocation, localization.negated()); successes.push(...allTrue(requirements, instance, schemaLocation, localization)); @@ -129,7 +128,7 @@ const oneOfErrorHandler = { // Passes if exactly one alternative passes. All of them are described, even // if we know which one matches, because these descriptions tell the user // what would need to change to make 'oneOf' fail. - const descriptions = alternatives.map((alternative) => getSuccesses(alternative, instance, localization, ast)); + const descriptions = alternatives.map((alternative) => getSuccesses(alternative, instance, localization, context)); if (descriptions.every((description) => description.length > 0)) { successes.push(...countTrue(descriptions, { min: 1, max: 1 }, instance, schemaLocation, localization)); } @@ -144,12 +143,12 @@ const oneOfErrorHandler = { * More than one alternative passed. Describe how the instance satisfied each * matching alternative so the user knows what needs to change. * - * @type (matches: { alternativeLocation: string, output: NormalizedOutput }[], schemaLocation: string, instance: JsonNode, localization: Localization, ast: AST) => ErrorObject + * @type (matches: { alternativeLocation: string, output: NormalizedOutput }[], schemaLocation: string, instance: JsonNode, localization: Localization, context: ErrorHandlerContext) => ErrorObject */ -const multipleMatchesError = (matches, schemaLocation, instance, localization, ast) => { +const multipleMatchesError = (matches, schemaLocation, instance, localization, context) => { const alternatives = matches.map(({ alternativeLocation, output }) => { - const description = getSuccesses(output, instance, localization, ast); - if (description.length === 0 && allowsAnyValue(alternativeLocation, ast)) { + const description = getSuccesses(output, instance, localization, context); + if (description.length === 0 && allowsAnyValue(alternativeLocation, context.ast)) { return [{ message: localization.getAnyValueMessage(), instanceLocation: Instance.uri(instance), diff --git a/src/error-handlers/pattern.js b/src/error-handlers/pattern.js index 33fedfb..3c69e9e 100644 --- a/src/error-handlers/pattern.js +++ b/src/error-handlers/pattern.js @@ -7,7 +7,7 @@ import { describeKeyword, getCompiledKeywordValue } from "../json-schema-errors. /** @type ErrorHandler */ const patternErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type ErrorObject[] */ const errors = []; @@ -16,7 +16,7 @@ const patternErrorHandler = { continue; } - const compiledPattern = /** @type RegExp */ (getCompiledKeywordValue(ast, schemaLocation)); + const compiledPattern = /** @type RegExp */ (getCompiledKeywordValue(context.ast, schemaLocation)); const pattern = compiledPattern.source; errors.push({ @@ -29,8 +29,8 @@ const patternErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { - return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/pattern", instance, ast, (/** @type RegExp */ pattern) => { + success: (normalizedOutput, instance, localization, context) => { + return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/pattern", instance, context.ast, (/** @type RegExp */ pattern) => { return localization.getPatternSuccessMessage(pattern.source); }); } diff --git a/src/error-handlers/prefixItems.js b/src/error-handlers/prefixItems.js index 18cc334..59e34e0 100644 --- a/src/error-handlers/prefixItems.js +++ b/src/error-handlers/prefixItems.js @@ -20,7 +20,7 @@ import { * @type ErrorHandler */ const prefixItemsErrorHandler = { - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -35,7 +35,7 @@ const prefixItemsErrorHandler = { ...Object.keys(normalizedOutput["https://json-schema.org/keyword/draft-04/items"] ?? {}) ]; for (const schemaLocation of schemaLocations) { - const prefixItems = /** @type string | string[] */ (getCompiledKeywordValue(ast, schemaLocation)); + const prefixItems = /** @type string | string[] */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (typeof prefixItems === "string") { // A single schema for all items applies to items that can't be named continue; @@ -45,14 +45,14 @@ const prefixItemsErrorHandler = { for (let index = length; index < prefixItems.length; index++) { // The item's subschema only applies if there's an item at that index const item = getPlaceholder(instance, String(index)); - const output = evaluateRequirements(prefixItems[index], item, ast); + const output = evaluateRequirements(prefixItems[index], item, context.ast); successes.push(...describeConditional({ condition: (localization) => [{ message: localization.getHasItemSuccessMessage(index), instanceLocation: Instance.uri(instance), schemaLocations: [schemaLocation] }], - then: (localization) => getSuccesses(output, instance, localization, ast) + then: (localization) => getSuccesses(output, instance, localization, context) }, instance, schemaLocation, localization)); } } diff --git a/src/error-handlers/properties.js b/src/error-handlers/properties.js index bb3ad78..70b7b01 100644 --- a/src/error-handlers/properties.js +++ b/src/error-handlers/properties.js @@ -19,7 +19,7 @@ import { * @type ErrorHandler */ const propertiesErrorHandler = { - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -30,7 +30,7 @@ const propertiesErrorHandler = { } for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/properties"]) { - const properties = /** @type Record */ (getCompiledKeywordValue(ast, schemaLocation)); + const properties = /** @type Record */ (getCompiledKeywordValue(context.ast, schemaLocation)); const isObject = Instance.typeOf(instance) === "object"; for (const propertyName in properties) { @@ -40,14 +40,14 @@ const propertiesErrorHandler = { // The property's subschema only applies if the property is present const property = getPlaceholder(instance, propertyName); - const output = evaluateRequirements(properties[propertyName], property, ast); + const output = evaluateRequirements(properties[propertyName], property, context.ast); successes.push(...describeConditional({ condition: (localization) => [{ message: localization.getHasPropertySuccessMessage([propertyName]), instanceLocation: Instance.uri(instance), schemaLocations: [schemaLocation] }], - then: (localization) => getSuccesses(output, instance, localization, ast) + then: (localization) => getSuccesses(output, instance, localization, context) }, instance, schemaLocation, localization)); } } diff --git a/src/error-handlers/required.js b/src/error-handlers/required.js index 57d5804..0f0b020 100644 --- a/src/error-handlers/required.js +++ b/src/error-handlers/required.js @@ -8,7 +8,7 @@ import { getCompiledKeywordValue } from "../json-schema-errors.js"; /** @type ErrorHandler */ const requiredErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { /** @type {Set} */ const allMissingRequired = new Set(); const allSchemaLocations = []; @@ -19,7 +19,7 @@ const requiredErrorHandler = { } allSchemaLocations.push(schemaLocation); - const required = /** @type string[] */ (getCompiledKeywordValue(ast, schemaLocation)); + const required = /** @type string[] */ (getCompiledKeywordValue(context.ast, schemaLocation)); addMissingProperties(required, instance, allMissingRequired); } @@ -30,7 +30,7 @@ const requiredErrorHandler = { } allSchemaLocations.push(schemaLocation); - const dependencies = /** @type {[string, string[]][]} */ (getCompiledKeywordValue(ast, schemaLocation)); + const dependencies = /** @type {[string, string[]][]} */ (getCompiledKeywordValue(context.ast, schemaLocation)); for (const [propertyName, requiredProperties] of dependencies) { if (!Instance.has(propertyName, instance)) { @@ -45,7 +45,7 @@ const requiredErrorHandler = { continue; } - const dependencies = /** @type {[string, unknown][]} */ (getCompiledKeywordValue(ast, schemaLocation)); + const dependencies = /** @type {[string, unknown][]} */ (getCompiledKeywordValue(context.ast, schemaLocation)); let hasArrayFormDependencies = false; for (const [propertyName, dependency] of dependencies) { @@ -74,7 +74,7 @@ const requiredErrorHandler = { }]; }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { /** @type ErrorObject[] */ const successes = []; @@ -82,7 +82,7 @@ const requiredErrorHandler = { const allRequired = new Set(); const requiredSchemaLocations = []; for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/required"]) { - const required = /** @type string[] */ (getCompiledKeywordValue(ast, schemaLocation)); + const required = /** @type string[] */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (required.length) { requiredSchemaLocations.push(schemaLocation); addAll(required, allRequired); @@ -98,7 +98,7 @@ const requiredErrorHandler = { } for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/dependentRequired"]) { - const dependencies = /** @type {[string, string[]][]} */ (getCompiledKeywordValue(ast, schemaLocation)); + const dependencies = /** @type {[string, string[]][]} */ (getCompiledKeywordValue(context.ast, schemaLocation)); for (const [propertyName, requiredProperties] of dependencies) { if (requiredProperties.length > 0) { successes.push({ @@ -111,7 +111,7 @@ const requiredErrorHandler = { } for (const schemaLocation in normalizedOutput["https://json-schema.org/keyword/draft-04/dependencies"]) { - const dependencies = /** @type {[string, unknown][]} */ (getCompiledKeywordValue(ast, schemaLocation)); + const dependencies = /** @type {[string, unknown][]} */ (getCompiledKeywordValue(context.ast, schemaLocation)); for (const [propertyName, dependency] of dependencies) { if (Array.isArray(dependency) && dependency.length > 0) { successes.push({ diff --git a/src/error-handlers/typeConstEnum.js b/src/error-handlers/typeConstEnum.js index 7abf40e..7ba8c34 100644 --- a/src/error-handlers/typeConstEnum.js +++ b/src/error-handlers/typeConstEnum.js @@ -9,7 +9,7 @@ const ALL_TYPES = new Set(["null", "boolean", "number", "string", "array", "obje /** @type {ErrorHandler} */ const typeConstEnumErrorHandler = { - error: (normalizedErrors, instance, localization, ast) => { + error: (normalizedErrors, instance, localization, context) => { let allowedTypes = new Set(ALL_TYPES); /** @type {string[]} */ const failedTypeLocations = []; @@ -19,7 +19,7 @@ const typeConstEnumErrorHandler = { failedTypeLocations.push(schemaLocation); /** @type {string | string[]} */ - const value = /** @type {string | string[]} */ (getCompiledKeywordValue(ast, schemaLocation)); + const value = /** @type {string | string[]} */ (getCompiledKeywordValue(context.ast, schemaLocation)); const types = Array.isArray(value) ? value : [value]; /** @type {Set} */ const keywordTypes = new Set(types); @@ -51,7 +51,7 @@ const typeConstEnumErrorHandler = { } const keywordJson = new Set(); - const constValueJson = /** @type string */ (getCompiledKeywordValue(ast, schemaLocation)); + const constValueJson = /** @type string */ (getCompiledKeywordValue(context.ast, schemaLocation)); if (allowedTypes.has(jsonTypeOf(constValueJson))) { keywordJson.add(constValueJson); } else { @@ -68,7 +68,7 @@ const typeConstEnumErrorHandler = { } const keywordJson = new Set(); - const enumValuesJson = /** @type string[] */ (getCompiledKeywordValue(ast, schemaLocation)); + const enumValuesJson = /** @type string[] */ (getCompiledKeywordValue(context.ast, schemaLocation)); for (const enumValueJson of enumValuesJson) { if (allowedTypes.has(jsonTypeOf(enumValueJson))) { keywordJson.add(enumValueJson); @@ -112,15 +112,15 @@ const typeConstEnumErrorHandler = { } }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { return [ - ...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/type", instance, ast, (/** @type string | string[] */ type) => { + ...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/type", instance, context.ast, (/** @type string | string[] */ type) => { return localization.getTypeSuccessMessage(Array.isArray(type) ? type : [type]); }), - ...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/const", instance, ast, (/** @type string */ constJson) => { + ...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/const", instance, context.ast, (/** @type string */ constJson) => { return localization.getEnumSuccessMessage([JSON.parse(constJson)]); }), - ...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/enum", instance, ast, (/** @type string[] */ enumJson) => { + ...describeKeyword(normalizedOutput, "https://json-schema.org/keyword/enum", instance, context.ast, (/** @type string[] */ enumJson) => { return localization.getEnumSuccessMessage(enumJson.map((json) => JSON.parse(json))); }) ]; diff --git a/src/error-handlers/uniqueItems.js b/src/error-handlers/uniqueItems.js index 8b90796..02c9313 100644 --- a/src/error-handlers/uniqueItems.js +++ b/src/error-handlers/uniqueItems.js @@ -42,9 +42,9 @@ const uniqueItemsErrorHandler = { return errors; }, - success: (normalizedOutput, instance, localization, ast) => { + success: (normalizedOutput, instance, localization, context) => { // 'uniqueItems: false' allows anything, so there's nothing to say - return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/uniqueItems", instance, ast, (/** @type boolean */ uniqueItems) => { + return describeKeyword(normalizedOutput, "https://json-schema.org/keyword/uniqueItems", instance, context.ast, (/** @type boolean */ uniqueItems) => { return uniqueItems ? localization.getUniqueItemsSuccessMessage() : undefined; }); } diff --git a/src/index.d.ts b/src/index.d.ts index e1ed76e..a54b2a3 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -189,22 +189,29 @@ export const removeErrorHandler: (errorHandlerUri: string) => void; * passing, such as with `not`. A handler can have either or both. */ export type ErrorHandler = { - error?: (normalizedErrors: InstanceOutput, instance: JsonNode, localization: Localization, ast: AST) => ErrorObject[]; - success?: (normalizedOutput: InstanceOutput, instance: JsonNode, localization: Localization, ast: AST) => ErrorObject[]; + error?: (normalizedErrors: InstanceOutput, instance: JsonNode, localization: Localization, context: ErrorHandlerContext) => ErrorObject[]; + success?: (normalizedOutput: InstanceOutput, instance: JsonNode, localization: Localization, context: ErrorHandlerContext) => ErrorObject[]; +}; + +/** + * What error handlers need to know about the schema and how it was validated. + */ +export type ErrorHandlerContext = { + ast: AST; }; /** * Converts the normalized error format to human readable errors. It's used to * build errors in applicator error handlers. */ -export const getErrors: (normalizedErrors: NormalizedOutput, instance: JsonNode, localization: Localization, ast: AST) => ErrorObject[]; +export const getErrors: (normalizedErrors: NormalizedOutput, instance: JsonNode, localization: Localization, context: ErrorHandlerContext) => ErrorObject[]; /** * Converts the normalized output of a passing subschema to human readable * messages describing how the instance satisfied the subschema. It's used to * build errors in applicator error handlers that fail when a subschema passes. */ -export const getSuccesses: (normalizedOutput: NormalizedOutput, instance: JsonNode, localization: Localization, ast: AST) => ErrorObject[]; +export const getSuccesses: (normalizedOutput: NormalizedOutput, instance: JsonNode, localization: Localization, context: ErrorHandlerContext) => ErrorObject[]; export type { Localization }; diff --git a/src/json-schema-errors.js b/src/json-schema-errors.js index 94a7d63..cba1923 100644 --- a/src/json-schema-errors.js +++ b/src/json-schema-errors.js @@ -24,7 +24,7 @@ export const jsonSchemaErrors = async (errorOutput, schemaUri, instance, options plugins: [...ast.plugins] }); const localization = Localization.forLocale(options.locale ?? "en-US"); - return getErrors(normalizedErrors, rootInstance, localization, ast); + return getErrors(normalizedErrors, rootInstance, localization, { ast }); }; /** @type Record */ @@ -276,11 +276,11 @@ export const evaluateRequirements = (schemaLocation, instance, ast) => { * stands in for any of those locations and then the description is moved to * the parent because it applies to all of them. * - * @type (subschemaLocation: string, placeholder: JsonNode, parent: JsonNode, localization: Localization, ast: AST) => API.ErrorObject[] + * @type (subschemaLocation: string, placeholder: JsonNode, parent: JsonNode, localization: Localization, context: API.ErrorHandlerContext) => API.ErrorObject[] */ -export const describeEach = (subschemaLocation, placeholder, parent, localization, ast) => { - const output = evaluateRequirements(subschemaLocation, placeholder, ast); - return getSuccesses(output, parent, localization, ast) +export const describeEach = (subschemaLocation, placeholder, parent, localization, context) => { + const output = evaluateRequirements(subschemaLocation, placeholder, context.ast); + return getSuccesses(output, parent, localization, context) .map((success) => relocate(success, Instance.uri(placeholder), Instance.uri(parent))); }; @@ -300,10 +300,10 @@ export const describeEach = (subschemaLocation, placeholder, parent, localizatio * the group of what each location requires, given how many things are in it, and `none` describes the scope * being empty, which is what the subschema requires if it's `false`. * - * @type (scope: Scope, instance: JsonNode, schemaLocation: string, localization: Localization, ast: AST) => API.ErrorObject[] + * @type (scope: Scope, instance: JsonNode, schemaLocation: string, localization: Localization, context: API.ErrorHandlerContext) => API.ErrorObject[] */ -export const describeScope = ({ subschemaLocation, placeholder, each, none }, instance, schemaLocation, localization, ast) => { - if (ast[subschemaLocation] === false) { +export const describeScope = ({ subschemaLocation, placeholder, each, none }, instance, schemaLocation, localization, context) => { + if (context.ast[subschemaLocation] === false) { return [{ message: none(localization), instanceLocation: Instance.uri(instance), @@ -311,7 +311,7 @@ export const describeScope = ({ subschemaLocation, placeholder, each, none }, in }]; } - const description = describeEach(subschemaLocation, placeholder, instance, localization, ast); + const description = describeEach(subschemaLocation, placeholder, instance, localization, context); if (description.length === 0) { return []; } @@ -428,14 +428,14 @@ export const removeErrorHandler = (errorHandlerUri) => { }; /** @type API.getErrors */ -export const getErrors = (normalizedErrors, rootInstance, localization, ast) => { +export const getErrors = (normalizedErrors, rootInstance, localization, context) => { /** @type API.ErrorObject[] */ const errors = []; for (const instanceLocation in normalizedErrors) { const instance = /** @type JsonNode */ (Instance.get(instanceLocation, rootInstance)); for (const errorHandlerUri in errorHandlers) { - const errorObjects = errorHandlers[errorHandlerUri].error?.(normalizedErrors[instanceLocation], instance, localization, ast) ?? []; + const errorObjects = errorHandlers[errorHandlerUri].error?.(normalizedErrors[instanceLocation], instance, localization, context) ?? []; errors.push(...errorObjects); } } @@ -501,7 +501,7 @@ const withoutConditionalResults = (normalizedOutput) => { const resultKey = (instanceLocation, keywordUri, keywordLocation) => JSON.stringify([instanceLocation, keywordUri, keywordLocation]); /** @type API.getSuccesses */ -export const getSuccesses = (normalizedOutput, rootInstance, localization, ast) => { +export const getSuccesses = (normalizedOutput, rootInstance, localization, context) => { // Descriptions of nested subschemas can get very large, so stop describing // past some depth and say that there's more if (descriptionDepth >= MAX_DESCRIPTION_DEPTH) { @@ -527,7 +527,7 @@ export const getSuccesses = (normalizedOutput, rootInstance, localization, ast) const instance = getInstance(instanceLocation, rootInstance) ?? toPlaceholder(instanceLocation, rootInstance); for (const errorHandlerUri in errorHandlers) { - const successObjects = errorHandlers[errorHandlerUri].success?.(describedOutput[instanceLocation], instance, localization, ast) ?? []; + const successObjects = errorHandlers[errorHandlerUri].success?.(describedOutput[instanceLocation], instance, localization, context) ?? []; successes.push(...successObjects); } } diff --git a/src/output-format.js b/src/output-format.js index e78ed32..903267b 100644 --- a/src/output-format.js +++ b/src/output-format.js @@ -16,7 +16,7 @@ const jseOutputFormatHandler = (options) => { plugin, getErrors: (instance, context) => { const localization = Localization.forLocale(options.locale ?? "en-US"); - return getErrors(plugin.output, instance, localization, context.ast); + return getErrors(plugin.output, instance, localization, { ast: context.ast }); } }; }; From 1b752064da01120a74f166f1047f39275e5c39d1 Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Mon, 5 Oct 2026 11:50:06 -0700 Subject: [PATCH 2/2] Only describe format as a requirement when it's validated Whether 'format' is an assertion depends on how the validator is configured, so success and negated messages always said that 'format' only applies if formats are validated. Now the error handler context says whether a 'format' keyword was validated. The format handler uses plain messages when it was, says nothing when it's only an annotation, and keeps the hedged messages when it's not known. jsonSchemaErrors takes an isFormatAsserted option because only the caller knows how their validator was configured. The JSE output format works it out from @hyperjump/json-schema's shouldValidateFormat setting and each dialect's default. Formats that aren't supported always pass, so they aren't validated either. This also adds support for the v1 'format' keyword. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++ src/error-handlers/format.js | 16 ++-- src/format.test.js | 120 ++++++++++++++++++++++++++++++ src/hyperjump-json-schema.test.js | 2 +- src/index.d.ts | 15 ++++ src/index.js | 1 + src/json-schema-errors.js | 7 +- src/localization.js | 5 ++ src/output-format.js | 46 +++++++++++- src/translations/en-US.js | 6 +- 10 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 src/format.test.js diff --git a/README.md b/README.md index 4c9b340..cc77444 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,16 @@ console.log(errors); // ] ``` +Validators often only validate the `format` keyword if they're configured to. +Use the `isFormatAsserted` option to say whether the validator validated +formats. If it isn't given, messages that describe `format` say that it only +applies if formats are validated. The `JSE` output format works this out from +`@hyperjump/json-schema`'s configuration and each dialect's default. + +```TypeScript +const errors = await jsonSchemaErrors(output, schemaUri, instance, { isFormatAsserted: true }); +``` + ## API https://json-schema-errors.hyperjump.io diff --git a/src/error-handlers/format.js b/src/error-handlers/format.js index 0e670a1..643427b 100644 --- a/src/error-handlers/format.js +++ b/src/error-handlers/format.js @@ -6,6 +6,7 @@ import { describeKeyword, getCompiledKeywordValue } from "../json-schema-errors. */ const keywordUris = [ + "https://json-schema.org/keyword/format", "https://json-schema.org/keyword/draft-2020-12/format", "https://json-schema.org/keyword/draft-2020-12/format-assertion", "https://json-schema.org/keyword/draft-2019-09/format", @@ -41,13 +42,18 @@ const formatErrorHandler = { }, success: (normalizedOutput, instance, localization, context) => { - // Whether 'format' is an assertion depends on how the validator is - // configured, which we don't know, so the messages say that it only applies - // if formats are validated. That includes 'format-assertion' because some - // validators can be configured not to validate it either. + // A 'format' that's only an annotation doesn't require anything. If it's + // not known, the message says that it only applies if formats are validated. return keywordUris.flatMap((keywordUri) => { return describeKeyword(normalizedOutput, keywordUri, instance, context.ast, (/** @type string */ format) => { - return localization.getFormatSuccessMessage(format); + switch (context.isFormatAsserted(keywordUri, format)) { + case true: + return localization.getFormatSuccessMessage(format); + case false: + return undefined; + default: + return localization.getFormatIfValidatedSuccessMessage(format); + } }); }); } diff --git a/src/format.test.js b/src/format.test.js new file mode 100644 index 0000000..e35676d --- /dev/null +++ b/src/format.test.js @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { registerSchema, setShouldValidateFormat, unregisterSchema, validate } from "@hyperjump/json-schema/draft-2020-12"; +import { BASIC } from "@hyperjump/json-schema/experimental"; +import "@hyperjump/json-schema/draft-07"; +import "@hyperjump/json-schema"; +import "@hyperjump/json-schema/formats"; +import { JSE, jsonSchemaErrors } from "./index.js"; + +/** + * @import { ErrorObject } from "./index.js" + */ + +/** + * Whether 'format' asserts depends on whether formats are validated, so these + * can't be covered by the test suite, which always validates formats. + */ +describe("format descriptions", () => { + const schemaUri = "https://example.com/main"; + const formatAssertionDialectUri = "https://example.com/format-assertion"; + + registerSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $vocabulary: { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/format-assertion": true + }, + $dynamicAnchor: "meta", + allOf: [ + { $ref: "https://json-schema.org/draft/2020-12/meta/core" }, + { $ref: "https://json-schema.org/draft/2020-12/meta/applicator" }, + { $ref: "https://json-schema.org/draft/2020-12/meta/format-assertion" } + ] + }, formatAssertionDialectUri); + + afterEach(() => { + unregisterSchema(schemaUri); + setShouldValidateFormat(undefined); + }); + + const dialectUri = "https://json-schema.org/draft/2020-12/schema"; + + /** @type (errors: ErrorObject[]) => string[] */ + const messages = (errors) => [errors[0], ...(errors[0].alternatives ?? []).flat()].map((error) => error.message); + + /** @type (dialectUri: string, format?: string) => Promise */ + const describeNotFormat = async (dialectUri, format = "email") => { + registerSchema({ not: { format } }, schemaUri, dialectUri); + const output = await validate(schemaUri, "a@example.com", JSE); + if (output.valid) { + throw Error("Expected validation to fail"); + } + return messages(output.errors); + }; + + const described = [ + "Expected the following to be true", + "The value is a string that doesn't match the 'email' format" + ]; + const notDescribed = ["A value is not allowed here"]; + + test("2020-12 format isn't validated by default", async () => { + expect(await describeNotFormat(dialectUri)).to.eql(notDescribed); + }); + + test("2020-12 format is validated if turned on", async () => { + setShouldValidateFormat(true); + expect(await describeNotFormat(dialectUri)).to.eql(described); + }); + + test("draft-07 format is validated by default", async () => { + expect(await describeNotFormat("http://json-schema.org/draft-07/schema")).to.eql(described); + }); + + test("draft-07 format isn't validated if turned off", async () => { + setShouldValidateFormat(false); + expect(await describeNotFormat("http://json-schema.org/draft-07/schema")).to.eql(notDescribed); + }); + + test("2020-12 format-assertion is validated even if turned off", async () => { + setShouldValidateFormat(false); + expect(await describeNotFormat(formatAssertionDialectUri)).to.eql(described); + }); + + test("v1 format is validated by default", async () => { + expect(await describeNotFormat("https://json-schema.org/v1")).to.eql(described); + }); + + test("v1 format isn't validated if turned off", async () => { + setShouldValidateFormat(false); + expect(await describeNotFormat("https://json-schema.org/v1")).to.eql(notDescribed); + }); + + test("unsupported formats aren't validated", async () => { + setShouldValidateFormat(true); + expect(await describeNotFormat(dialectUri, "unsupported")).to.eql(notDescribed); + }); + + test("jsonSchemaErrors describes format as only applying if formats are validated if that's not known", async () => { + setShouldValidateFormat(true); + registerSchema({ not: { format: "email" } }, schemaUri, dialectUri); + const output = await validate(schemaUri, "a@example.com", BASIC); + const errors = await jsonSchemaErrors(output, schemaUri, "a@example.com"); + expect(messages(errors)).to.eql([ + "Expected the following to be true", + "The value is a string that doesn't match the 'email' format (if formats are validated)" + ]); + }); + + test("jsonSchemaErrors describes format if the validator validates formats", async () => { + setShouldValidateFormat(true); + registerSchema({ not: { format: "email" } }, schemaUri, dialectUri); + const output = await validate(schemaUri, "a@example.com", BASIC); + const errors = await jsonSchemaErrors(output, schemaUri, "a@example.com", { isFormatAsserted: true }); + expect(messages(errors)).to.eql([ + "Expected the following to be true", + "The value is a string that doesn't match the 'email' format" + ]); + }); +}); diff --git a/src/hyperjump-json-schema.test.js b/src/hyperjump-json-schema.test.js index 8749e74..5a89947 100644 --- a/src/hyperjump-json-schema.test.js +++ b/src/hyperjump-json-schema.test.js @@ -83,7 +83,7 @@ const runTests = (dialectUri, dialect) => { const instance = testCase.instance; const output = await validate(schemaUri, instance, BASIC); - const errors = await jsonSchemaErrors(output, schemaUri, instance); + const errors = await jsonSchemaErrors(output, schemaUri, instance, { isFormatAsserted: true }); expect(errors).to.eql(buildErrors(testCase.errors, schemaUri)); expectNestedLocations(errors); diff --git a/src/index.d.ts b/src/index.d.ts index a54b2a3..53cbc20 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -57,6 +57,14 @@ export type JsonSchemaErrorsOptions = { * @example "en-US" */ locale?: string; + + /** + * Whether the validator treated the `format` keyword as an assertion rather + * than an annotation. Validators often only validate `format` if they're + * configured to. If this isn't given, messages that describe `format` say + * that it only applies if formats are validated. + */ + isFormatAsserted?: boolean; }; /** @@ -198,6 +206,13 @@ export type ErrorHandler = { */ export type ErrorHandlerContext = { ast: AST; + + /** + * Whether the validator treated a `format` keyword as an assertion rather + * than an annotation. It can depend on the dialect, given by the keyword + * URI, and on the format. `undefined` if it's not known. + */ + isFormatAsserted: (keywordUri: string, format: string) => boolean | undefined; }; /** diff --git a/src/index.js b/src/index.js index cb497e5..47e4c6f 100644 --- a/src/index.js +++ b/src/index.js @@ -108,6 +108,7 @@ setNormalizationHandler("https://json-schema.org/keyword/draft-2020-12/dynamicRe setNormalizationHandler("https://json-schema.org/keyword/else", elseNormalizationHandler); setNormalizationHandler("https://json-schema.org/keyword/enum", enumNormalizationHandler); setNormalizationHandler("https://json-schema.org/keyword/examples", examplesNormalizationHandler); +setNormalizationHandler("https://json-schema.org/keyword/format", formatNormalizationHandler); setNormalizationHandler("https://json-schema.org/keyword/draft-2020-12/format", formatNormalizationHandler); setNormalizationHandler("https://json-schema.org/keyword/draft-2020-12/format-assertion", formatNormalizationHandler); setNormalizationHandler("https://json-schema.org/keyword/draft-2019-09/format", formatNormalizationHandler); diff --git a/src/json-schema-errors.js b/src/json-schema-errors.js index cba1923..90ab9de 100644 --- a/src/json-schema-errors.js +++ b/src/json-schema-errors.js @@ -8,7 +8,7 @@ import { Localization } from "./localization.js"; /** * @import * as API from "./index.d.ts" * @import { Browser } from "@hyperjump/browser"; - * @import { AST, SchemaDocument, CompiledSchema, Node } from "@hyperjump/json-schema/experimental"; + * @import { AST, SchemaDocument, Node } from "@hyperjump/json-schema/experimental"; * @import { JsonNode } from "@hyperjump/json-schema/instance/experimental" */ @@ -24,7 +24,10 @@ export const jsonSchemaErrors = async (errorOutput, schemaUri, instance, options plugins: [...ast.plugins] }); const localization = Localization.forLocale(options.locale ?? "en-US"); - return getErrors(normalizedErrors, rootInstance, localization, { ast }); + return getErrors(normalizedErrors, rootInstance, localization, { + ast, + isFormatAsserted: () => options.isFormatAsserted + }); }; /** @type Record */ diff --git a/src/localization.js b/src/localization.js index 0045659..de28e86 100644 --- a/src/localization.js +++ b/src/localization.js @@ -351,6 +351,11 @@ export class Localization { return this.#formatSuccessMessage("format", { format }); } + /** @type (format: string) => string */ + getFormatIfValidatedSuccessMessage(format) { + return this.#formatSuccessMessage("formatIfValidated", { format }); + } + /** @type (maxItems: number) => string */ getMaxItemsSuccessMessage(maxItems) { return this.#formatSuccessMessage("maxItems", { maxItems }); diff --git a/src/output-format.js b/src/output-format.js index 903267b..e643363 100644 --- a/src/output-format.js +++ b/src/output-format.js @@ -1,4 +1,5 @@ -import { setOutputFormat } from "@hyperjump/json-schema/experimental"; +import { getKeyword, setOutputFormat } from "@hyperjump/json-schema/experimental"; +import { getShouldValidateFormat } from "@hyperjump/json-schema/draft-2020-12"; import { getErrors } from "./json-schema-errors.js"; import { Localization } from "./localization.js"; import { JsonSchemaErrorsOutputPlugin } from "./output-plugin.js"; @@ -16,9 +17,50 @@ const jseOutputFormatHandler = (options) => { plugin, getErrors: (instance, context) => { const localization = Localization.forLocale(options.locale ?? "en-US"); - return getErrors(plugin.output, instance, localization, { ast: context.ast }); + return getErrors(plugin.output, instance, localization, { + ast: context.ast, + isFormatAsserted + }); } }; }; +/** + * Whether @hyperjump/json-schema validates 'format' depends on the dialect and + * on whether format validation is turned on or off. If it's not set, each + * dialect has its own default. + * + * @type (keywordUri: string, format: string) => boolean | undefined + */ +const isFormatAsserted = (keywordUri, format) => { + const shouldValidateFormat = getShouldValidateFormat(); + + switch (keywordUri) { + case "https://json-schema.org/keyword/draft-2020-12/format-assertion": + // Always validated. Validation throws if a format isn't supported. + return true; + case "https://json-schema.org/keyword/format": + case "https://json-schema.org/keyword/draft-2019-09/format-assertion": + case "https://json-schema.org/keyword/draft-07/format": + case "https://json-schema.org/keyword/draft-06/format": + case "https://json-schema.org/keyword/draft-04/format": + if (shouldValidateFormat === false) { + return false; + } + break; + case "https://json-schema.org/keyword/draft-2020-12/format": + case "https://json-schema.org/keyword/draft-2019-09/format": + if (shouldValidateFormat !== true) { + return false; + } + break; + default: + return undefined; + } + + // Formats that aren't supported always pass + const { formats } = /** @type {{ formats: Record }} */ (/** @type unknown */ (getKeyword(keywordUri))); + return Object.hasOwn(formats, format); +}; + setOutputFormat(JSE, jseOutputFormatHandler); diff --git a/src/translations/en-US.js b/src/translations/en-US.js index 7931b81..846e1a5 100644 --- a/src/translations/en-US.js +++ b/src/translations/en-US.js @@ -18,8 +18,10 @@ enum-message = Expected one of {$expected} enum-success-message = The value is one of {$expected} enum-negated-message = The value is not one of {$expected} format-message = Expected a value matching the '{$format}' format -format-success-message = The value is either not a string or matches the '{$format}' format (if formats are validated) -format-negated-message = The value is a string that doesn't match the '{$format}' format (if formats are validated) +format-success-message = The value is either not a string or matches the '{$format}' format +format-negated-message = The value is a string that doesn't match the '{$format}' format +formatIfValidated-success-message = The value is either not a string or matches the '{$format}' format (if formats are validated) +formatIfValidated-negated-message = The value is a string that doesn't match the '{$format}' format (if formats are validated) unknown-message = Validation failed for '{$keyword}' type-success-message = The value is {$count -> [one] {$type ->