From df9f1110875c74a4a2060c48f8a0030c627c618d Mon Sep 17 00:00:00 2001 From: Kaileshwar16 Date: Thu, 22 Oct 2026 18:20:57 +0530 Subject: [PATCH 1/4] perf: reuse flattened anyOf and oneOf alternatives --- src/error-handlers/anyOf.js | 12 +-- src/error-handlers/oneOf.js | 17 ++-- src/json-schema-errors.js | 12 ++- src/test-suite/tests/anyOf.json | 32 ++++++ src/test-suite/tests/oneOf.json | 172 ++++++++++++++++++++++++++++++++ 5 files changed, 227 insertions(+), 18 deletions(-) diff --git a/src/error-handlers/anyOf.js b/src/error-handlers/anyOf.js index b5c6707..b1fe094 100644 --- a/src/error-handlers/anyOf.js +++ b/src/error-handlers/anyOf.js @@ -1,6 +1,6 @@ import * as Instance from "@hyperjump/json-schema/instance/experimental"; import * as Pact from "@hyperjump/pact"; -import { allTrue, flattenOutput, getErrors, getSuccesses, someTrue } from "../json-schema-errors.js"; +import { allTrue, flattenOutput, getErrorsFromFlattenedOutput, getSuccesses, someTrue } from "../json-schema-errors.js"; /** * @import { ErrorHandler, ErrorObject, InstanceOutput } from "../index.d.ts" @@ -34,9 +34,7 @@ const anyOfErrorHandler = { const alternatives = []; const instanceLocation = Instance.uri(instance); - for (const [index, alternative] of anyOf.entries()) { - const results = alternativeResults[index]; - + for (const results of alternativeResults) { // Filter alternatives whose declared type doesn't match the instance type const typeResults = results[instanceLocation]?.["https://json-schema.org/keyword/type"]; if (typeResults && !Object.values(typeResults).every(({ valid }) => valid)) { @@ -56,13 +54,13 @@ const anyOfErrorHandler = { } // The alternative passed all the filters - alternatives.push(getErrors(alternative, instance, context)); + alternatives.push(getErrorsFromFlattenedOutput(results, instance, 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, context)); + for (const results of alternativeResults) { + alternatives.push(getErrorsFromFlattenedOutput(results, instance, context)); } } diff --git a/src/error-handlers/oneOf.js b/src/error-handlers/oneOf.js index 4e1c52f..1816c87 100644 --- a/src/error-handlers/oneOf.js +++ b/src/error-handlers/oneOf.js @@ -1,6 +1,6 @@ import * as Instance from "@hyperjump/json-schema/instance/experimental"; import * as Pact from "@hyperjump/pact"; -import { allowsAnyValue, allTrue, countTrue, flattenOutput, getErrors, getSuccesses, getValidity, negate, someTrue } from "../json-schema-errors.js"; +import { allowsAnyValue, allTrue, countTrue, flattenOutput, getErrorsFromFlattenedOutput, getSuccesses, getValidityFromFlattenedOutput, negate, someTrue } from "../json-schema-errors.js"; /** * @import { JsonNode } from "@hyperjump/json-schema/instance/experimental" @@ -19,10 +19,12 @@ const oneOfErrorHandler = { continue; } const oneOf = oneOfOutput.outputs ?? []; + const alternativeResults = oneOf.map(flattenOutput); const alternativeLocations = /** @type string[] */ (normalizedErrors["https://json-schema.org/keyword/oneOf"][schemaLocation].value); + // Descriptions need the original output to keep conditional requirements separate. const matches = alternativeLocations.flatMap((alternativeLocation, index) => { - return getValidity(oneOf[index]) === true ? [{ alternativeLocation, output: oneOf[index] }] : []; + return getValidityFromFlattenedOutput(alternativeResults[index]) === true ? [{ alternativeLocation, output: oneOf[index] }] : []; }); if (matches.length > 1) { errors.push(multipleMatchesError(matches, schemaLocation, instance, context)); @@ -35,7 +37,6 @@ const oneOfErrorHandler = { Pact.collectArray ); - const alternativeResults = oneOf.map(flattenOutput); const discriminators = propertyLocations.filter((propertyLocation) => { return alternativeResults.some((results) => isPassingProperty(results[propertyLocation])); }); @@ -43,9 +44,7 @@ const oneOfErrorHandler = { const alternatives = []; const instanceLocation = Instance.uri(instance); - for (const [index, alternative] of oneOf.entries()) { - const results = alternativeResults[index]; - + for (const results of alternativeResults) { // Filter alternatives whose declared type doesn't match the instance type const typeResults = results[instanceLocation]?.["https://json-schema.org/keyword/type"]; if (typeResults && !Object.values(typeResults).every(({ valid }) => valid)) { @@ -65,7 +64,7 @@ const oneOfErrorHandler = { } // The alternative passed all the filters - const alternativeErrors = getErrors(alternative, instance, context); + const alternativeErrors = getErrorsFromFlattenedOutput(results, instance, context); if (alternativeErrors.length) { alternatives.push(alternativeErrors); } @@ -73,8 +72,8 @@ 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, context); + for (const results of alternativeResults) { + const alternativeErrors = getErrorsFromFlattenedOutput(results, instance, context); if (alternativeErrors.length) { alternatives.push(alternativeErrors); } diff --git a/src/json-schema-errors.js b/src/json-schema-errors.js index b7f3a18..ab309fb 100644 --- a/src/json-schema-errors.js +++ b/src/json-schema-errors.js @@ -443,10 +443,14 @@ export const removeErrorHandler = (errorHandlerUri) => { /** @type API.getErrors */ export const getErrors = (normalizedErrors, rootInstance, context) => { + return getErrorsFromFlattenedOutput(flattenOutput(normalizedErrors), rootInstance, context); +}; + +/** @type API.getErrors */ +export const getErrorsFromFlattenedOutput = (reportedOutput, rootInstance, context) => { /** @type API.ErrorObject[] */ const errors = []; - const reportedOutput = flattenOutput(normalizedErrors); for (const instanceLocation in reportedOutput) { const instance = /** @type JsonNode */ (Instance.get(instanceLocation, rootInstance)); for (const errorHandlerUri in errorHandlers) { @@ -679,10 +683,14 @@ const sizeOf = (errorObjects) => errorObjects.reduce((size, errorObject) => { * @type API.getValidity */ export const getValidity = (normalizedOutput) => { + return getValidityFromFlattenedOutput(flattenOutput(normalizedOutput)); +}; + +/** @type API.getValidity */ +export const getValidityFromFlattenedOutput = (results) => { /** @type boolean | undefined */ let validity = true; - const results = flattenOutput(normalizedOutput); for (const instanceLocation in results) { for (const keywordUri in results[instanceLocation]) { for (const schemaLocation in results[instanceLocation][keywordUri]) { diff --git a/src/test-suite/tests/anyOf.json b/src/test-suite/tests/anyOf.json index bb26a58..7a05ea8 100644 --- a/src/test-suite/tests/anyOf.json +++ b/src/test-suite/tests/anyOf.json @@ -668,6 +668,38 @@ "schemaLocations": ["#/anyOf/1/properties/b/type"] } ] + }, + { + "description": "anyOf reports an applied conditional in a selected alternative", + "compatibility": "7", + "schema": { + "anyOf": [ + { + "if": { + "type": "number" + }, + "then": { + "minimum": 5 + } + }, + { + "type": "string" + } + ] + }, + "instance": 1, + "errors": [ + { + "messageId": "minimum-message", + "messageParams": { + "minimum": 5 + }, + "instanceLocation": "#", + "schemaLocations": [ + "#/anyOf/0/then/minimum" + ] + } + ] } ] } diff --git a/src/test-suite/tests/oneOf.json b/src/test-suite/tests/oneOf.json index 80c087c..51d46ea 100644 --- a/src/test-suite/tests/oneOf.json +++ b/src/test-suite/tests/oneOf.json @@ -2324,6 +2324,178 @@ "schemaLocations": ["#/oneOf"] } ] + }, + { + "description": "oneOf reports an applied conditional in a selected alternative", + "compatibility": "7", + "schema": { + "oneOf": [ + { + "if": { + "type": "number" + }, + "then": { + "minimum": 5 + } + }, + { + "type": "string" + } + ] + }, + "instance": 1, + "errors": [ + { + "messageId": "minimum-message", + "messageParams": { + "minimum": 5 + }, + "instanceLocation": "#", + "schemaLocations": [ + "#/oneOf/0/then/minimum" + ] + } + ] + }, + { + "description": "oneOf matching alternative keeps direct and conditional references separate", + "compatibility": "2019", + "schema": { + "oneOf": [ + { + "$ref": "#/$defs/user" + }, + { + "required": [ + "other" + ] + } + ], + "$defs": { + "base": { + "required": [ + "id" + ] + }, + "admin": { + "$ref": "#/$defs/base", + "required": [ + "permissions" + ] + }, + "user": { + "$ref": "#/$defs/base", + "if": { + "required": [ + "admin" + ] + }, + "then": { + "$ref": "#/$defs/admin" + } + } + } + }, + "instance": { + "id": 1, + "admin": true, + "permissions": [], + "other": true + }, + "errors": [ + { + "messageId": "oneOf-multiple-matches-message", + "alternatives": [ + [ + { + "messageId": "count-true-message", + "messageParams": { + "kind": "atLeast", + "min": 1 + }, + "alternatives": [ + [ + { + "messageId": "required-negated-message", + "messageParams": { + "count": 1, + "required": { + "or": [ + "'admin'" + ] + } + }, + "instanceLocation": "#", + "schemaLocations": [ + "#/$defs/user/if/required" + ] + } + ], + [ + { + "messageId": "required-success-message", + "messageParams": { + "count": 2, + "required": { + "and": [ + "'id'", + "'permissions'" + ] + } + }, + "instanceLocation": "#", + "schemaLocations": [ + "#/$defs/base/required", + "#/$defs/admin/required" + ] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": [ + "#/$defs/user/if" + ] + }, + { + "messageId": "required-success-message", + "messageParams": { + "count": 1, + "required": { + "and": [ + "'id'" + ] + } + }, + "instanceLocation": "#", + "schemaLocations": [ + "#/$defs/base/required" + ] + } + ], + [ + { + "messageId": "required-success-message", + "messageParams": { + "count": 1, + "required": { + "and": [ + "'other'" + ] + } + }, + "instanceLocation": "#", + "schemaLocations": [ + "#/oneOf/1/required" + ] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": [ + "#/oneOf" + ] + } + ] } ] } From 1c6c0c9f1e6b8b31afa9f48d369ec56e9951aebd Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Fri, 30 Oct 2026 12:38:26 -0700 Subject: [PATCH 2/4] fix: don't count oneOf alternatives without output as matches Co-Authored-By: Claude Opus 5.5 --- src/error-handlers/oneOf.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/error-handlers/oneOf.js b/src/error-handlers/oneOf.js index 1816c87..eb4a7d3 100644 --- a/src/error-handlers/oneOf.js +++ b/src/error-handlers/oneOf.js @@ -23,8 +23,9 @@ const oneOfErrorHandler = { const alternativeLocations = /** @type string[] */ (normalizedErrors["https://json-schema.org/keyword/oneOf"][schemaLocation].value); // Descriptions need the original output to keep conditional requirements separate. - const matches = alternativeLocations.flatMap((alternativeLocation, index) => { - return getValidityFromFlattenedOutput(alternativeResults[index]) === true ? [{ alternativeLocation, output: oneOf[index] }] : []; + // An alternative without output has unknown validity, so it isn't a match. + const matches = alternativeResults.flatMap((results, index) => { + return getValidityFromFlattenedOutput(results) === true ? [{ alternativeLocation: alternativeLocations[index], output: oneOf[index] }] : []; }); if (matches.length > 1) { errors.push(multipleMatchesError(matches, schemaLocation, instance, context)); From 70f4a109ab8dbc5d83f1e0014c353ee85177d2ce Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Fri, 30 Oct 2026 12:38:26 -0700 Subject: [PATCH 3/4] Give flattened output its own type Co-Authored-By: Claude Opus 5.5 --- src/json-schema-errors.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/json-schema-errors.js b/src/json-schema-errors.js index ab309fb..9f7c558 100644 --- a/src/json-schema-errors.js +++ b/src/json-schema-errors.js @@ -12,6 +12,14 @@ import { Localization } from "./localization.js"; * @import { JsonNode } from "@hyperjump/json-schema/instance/experimental" */ +/** + * Normalized output with the results of simple applicators' subschemas + * flattened into the parent schema's results. The brand keeps unflattened + * output from being passed where flattened output is expected. + * + * @typedef {API.NormalizedOutput & { readonly __flattened: true }} FlattenedOutput + */ + /** @type API.jsonSchemaErrors */ export const jsonSchemaErrors = async (errorOutput, schemaUri, instance, options = {}) => { const rootInstance = Instance.fromJs(instance); @@ -446,7 +454,7 @@ export const getErrors = (normalizedErrors, rootInstance, context) => { return getErrorsFromFlattenedOutput(flattenOutput(normalizedErrors), rootInstance, context); }; -/** @type API.getErrors */ +/** @type (results: FlattenedOutput, rootInstance: JsonNode, context: API.ErrorHandlerContext) => API.ErrorObject[] */ export const getErrorsFromFlattenedOutput = (reportedOutput, rootInstance, context) => { /** @type API.ErrorObject[] */ const errors = []; @@ -462,8 +470,8 @@ export const getErrorsFromFlattenedOutput = (reportedOutput, rootInstance, conte return errors; }; -/** @type (normalizedOutput: API.NormalizedOutput) => API.NormalizedOutput */ -export const flattenOutput = (normalizedOutput) => flatten(normalizedOutput, isApplied); +/** @type (normalizedOutput: API.NormalizedOutput) => FlattenedOutput */ +export const flattenOutput = (normalizedOutput) => /** @type FlattenedOutput */ (flatten(normalizedOutput, isApplied)); /** * The results of a simple applicator's subschemas are results of its parent @@ -686,7 +694,7 @@ export const getValidity = (normalizedOutput) => { return getValidityFromFlattenedOutput(flattenOutput(normalizedOutput)); }; -/** @type API.getValidity */ +/** @type (results: FlattenedOutput) => boolean | undefined */ export const getValidityFromFlattenedOutput = (results) => { /** @type boolean | undefined */ let validity = true; From 225b5e8e54b19a4834c43adc5575069c4c904c56 Mon Sep 17 00:00:00 2001 From: Jason Desrosiers Date: Fri, 30 Oct 2026 12:40:16 -0700 Subject: [PATCH 4/4] perf: reuse oneOf alternatives' errors in the fallback Co-Authored-By: Claude Opus 5.5 --- src/error-handlers/oneOf.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/error-handlers/oneOf.js b/src/error-handlers/oneOf.js index eb4a7d3..37477cb 100644 --- a/src/error-handlers/oneOf.js +++ b/src/error-handlers/oneOf.js @@ -44,8 +44,11 @@ const oneOfErrorHandler = { const alternatives = []; const instanceLocation = Instance.uri(instance); + // Errors of alternatives that passed the filters, kept for the fallback + /** @type ErrorObject[][] */ + const errorsByAlternative = []; - for (const results of alternativeResults) { + for (const [index, results] of alternativeResults.entries()) { // Filter alternatives whose declared type doesn't match the instance type const typeResults = results[instanceLocation]?.["https://json-schema.org/keyword/type"]; if (typeResults && !Object.values(typeResults).every(({ valid }) => valid)) { @@ -66,6 +69,7 @@ const oneOfErrorHandler = { // The alternative passed all the filters const alternativeErrors = getErrorsFromFlattenedOutput(results, instance, context); + errorsByAlternative[index] = alternativeErrors; if (alternativeErrors.length) { alternatives.push(alternativeErrors); } @@ -73,8 +77,8 @@ const oneOfErrorHandler = { // If all alternatives were filtered out, default to returning all of them if (alternatives.length === 0) { - for (const results of alternativeResults) { - const alternativeErrors = getErrorsFromFlattenedOutput(results, instance, context); + for (const [index, results] of alternativeResults.entries()) { + const alternativeErrors = errorsByAlternative[index] ?? getErrorsFromFlattenedOutput(results, instance, context); if (alternativeErrors.length) { alternatives.push(alternativeErrors); }