diff --git a/src/subsumption.js b/src/subsumption.js new file mode 100644 index 0000000..8fb52bb --- /dev/null +++ b/src/subsumption.js @@ -0,0 +1,155 @@ +/** + * @import { NormalizedOutput, InstanceOutput } from "./index.d.ts" + */ + +/** + * @typedef {{ loc: string, errors: NormalizedOutput[] | null }} FailedCondition + */ + +/** @type {(altA: NormalizedOutput, altB: NormalizedOutput, getValue: (keywordLocation: string) => any) => boolean} */ +export const isSubsumed = (altA, altB, getValue) => { + for (const instLoc in altA) { + if (!(instLoc in altB)) { + return false; + } + + const failedA = getFailedKeywords(altA[instLoc]); + const failedB = getFailedKeywords(altB[instLoc]); + + for (const uriA in failedA) { + for (const itemA of failedA[uriA]) { + if (failedB[uriA] && failedB[uriA].some(/** @type {FailedCondition} */ (b) => b.loc === itemA.loc)) { + continue; + } + + const valA = getValue(itemA.loc); + let subsumed = false; + + if (uriA === "https://json-schema.org/keyword/type") { + subsumed = doesTypeSubsume(valA, failedB, getValue); + } else if (uriA === "https://json-schema.org/keyword/enum") { + subsumed = doesEnumSubsume(valA, failedB, getValue); + } else if (uriA === "https://json-schema.org/keyword/maxLength" || uriA === "https://json-schema.org/keyword/maximum" || uriA === "https://json-schema.org/keyword/maxItems" || uriA === "https://json-schema.org/keyword/maxProperties" || uriA === "https://json-schema.org/keyword/maxContains") { + subsumed = doesMaxBoundSubsume(uriA, valA, failedB, getValue); + } else if (uriA === "https://json-schema.org/keyword/minLength" || uriA === "https://json-schema.org/keyword/minimum" || uriA === "https://json-schema.org/keyword/minItems" || uriA === "https://json-schema.org/keyword/minProperties" || uriA === "https://json-schema.org/keyword/minContains") { + subsumed = doesMinBoundSubsume(uriA, valA, failedB, getValue); + } else if (uriA === "https://json-schema.org/keyword/anyOf" || uriA === "https://json-schema.org/keyword/oneOf") { + subsumed = doesAnyOfSubsume(itemA, altB, getValue); + } + + if (!subsumed) { + return false; + } + } + } + } + + return true; +}; + +/** @type {(valA: any, failedB: Record, getValue: (keywordLocation: string) => any) => boolean} */ +const doesTypeSubsume = (valA, failedB, getValue) => { + const typeA = Array.isArray(valA) ? valA : [valA]; + + if (failedB["https://json-schema.org/keyword/type"]) { + for (const itemB of failedB["https://json-schema.org/keyword/type"]) { + const typeB = Array.isArray(getValue(itemB.loc)) ? getValue(itemB.loc) : [getValue(itemB.loc)]; + if (typeB.every(/** @param {any} t */ (t) => typeA.includes(t))) { + return true; + } + } + } + + if (typeA.includes("string") && (failedB["https://json-schema.org/keyword/minLength"] || failedB["https://json-schema.org/keyword/maxLength"] || failedB["https://json-schema.org/keyword/pattern"] || failedB["https://json-schema.org/keyword/format"])) { + return true; + } + if ((typeA.includes("number") || typeA.includes("integer")) && (failedB["https://json-schema.org/keyword/minimum"] || failedB["https://json-schema.org/keyword/maximum"] || failedB["https://json-schema.org/keyword/exclusiveMinimum"] || failedB["https://json-schema.org/keyword/exclusiveMaximum"] || failedB["https://json-schema.org/keyword/multipleOf"])) { + return true; + } + if (typeA.includes("object") && (failedB["https://json-schema.org/keyword/properties"] || failedB["https://json-schema.org/keyword/required"] || failedB["https://json-schema.org/keyword/minProperties"] || failedB["https://json-schema.org/keyword/maxProperties"] || failedB["https://json-schema.org/keyword/patternProperties"] || failedB["https://json-schema.org/keyword/additionalProperties"] || failedB["https://json-schema.org/keyword/dependentRequired"])) { + return true; + } + if (typeA.includes("array") && (failedB["https://json-schema.org/keyword/items"] || failedB["https://json-schema.org/keyword/minItems"] || failedB["https://json-schema.org/keyword/maxItems"] || failedB["https://json-schema.org/keyword/minContains"] || failedB["https://json-schema.org/keyword/maxContains"] || failedB["https://json-schema.org/keyword/contains"] || failedB["https://json-schema.org/keyword/prefixItems"] || failedB["https://json-schema.org/keyword/additionalItems"] || failedB["https://json-schema.org/keyword/unevaluatedItems"] || failedB["https://json-schema.org/keyword/uniqueItems"])) { + return true; + } + + return false; +}; + +/** @type {(valA: any, failedB: Record, getValue: (keywordLocation: string) => any) => boolean} */ +const doesEnumSubsume = (valA, failedB, getValue) => { + const enumA = valA.map(/** @param {any} v */ (v) => JSON.stringify(v)); + + if (failedB["https://json-schema.org/keyword/enum"]) { + for (const itemB of failedB["https://json-schema.org/keyword/enum"]) { + const enumB = getValue(itemB.loc).map(/** @param {any} v */ (v) => JSON.stringify(v)); + if (enumB.every(/** @param {any} v */ (v) => enumA.includes(v))) { + return true; + } + } + } + + if (failedB["https://json-schema.org/keyword/const"]) { + for (const itemB of failedB["https://json-schema.org/keyword/const"]) { + if (enumA.includes(JSON.stringify(getValue(itemB.loc)))) { + return true; + } + } + } + return false; +}; + +/** @type {(uriA: string, valA: number, failedB: Record, getValue: (keywordLocation: string) => any) => boolean} */ +const doesMaxBoundSubsume = (uriA, valA, failedB, getValue) => { + if (failedB[uriA]) { + for (const itemB of failedB[uriA]) { + if (valA >= getValue(itemB.loc)) { + return true; + } + } + } + return false; +}; + +/** @type {(uriA: string, valA: number, failedB: Record, getValue: (keywordLocation: string) => any) => boolean} */ +const doesMinBoundSubsume = (uriA, valA, failedB, getValue) => { + if (failedB[uriA]) { + for (const itemB of failedB[uriA]) { + if (valA <= getValue(itemB.loc)) { + return true; + } + } + } + return false; +}; + +/** @type {(itemA: FailedCondition, altB: NormalizedOutput, getValue: (keywordLocation: string) => any) => boolean} */ +const doesAnyOfSubsume = (itemA, altB, getValue) => { + if (!itemA.errors) { + return false; + } + for (const nestedAltA of itemA.errors) { + if (isSubsumed(nestedAltA, altB, getValue)) { + return true; + } + } + return false; +}; + +/** @type {(output: InstanceOutput) => Record} */ +const getFailedKeywords = (output) => { + /** @type {Record} */ + const failed = {}; + for (const uri in output) { + for (const loc in output[uri]) { + const val = output[uri][loc]; + if (val === false || Array.isArray(val)) { + if (!failed[uri]) { + failed[uri] = []; + } + failed[uri].push({ loc, errors: Array.isArray(val) ? val : null }); + } + } + } + return failed; +}; diff --git a/src/subsumption.test.js b/src/subsumption.test.js new file mode 100644 index 0000000..2f74c50 --- /dev/null +++ b/src/subsumption.test.js @@ -0,0 +1,175 @@ +/** + * @import { NormalizedOutput } from "./index.js"; + */ +import { describe, test, expect } from "vitest"; +import { isSubsumed } from "./subsumption.js"; + +describe("Algebraic Error Subsumption", () => { + test("type string subsumes type string + minLength", () => { + /** @type NormalizedOutput */ + const altA = { + "": { + "https://json-schema.org/keyword/type": { "/type": false } + } + }; + /** @type NormalizedOutput */ + const altB = { + "": { + "https://json-schema.org/keyword/type": { "/type": false }, + "https://json-schema.org/keyword/minLength": { "/minLength": false } + } + }; + + /** @type {(loc: string) => any} */ + const getValue = (loc) => { + if (loc === "/type") { + return "string"; + } + if (loc === "/minLength") { + return 3; + } + }; + + expect(isSubsumed(altA, altB, getValue)).toBe(true); + expect(isSubsumed(altB, altA, getValue)).toBe(false); + }); + + test("broader type array subsumes strict type", () => { + /** @type NormalizedOutput */ + const altA = { + "": { + "https://json-schema.org/keyword/type": { "/type": false } + } + }; + /** @type NormalizedOutput */ + const altB = { + "": { + "https://json-schema.org/keyword/type": { "/type_strict": false } + } + }; + + /** @type {(loc: string) => any} */ + const getValue = (loc) => { + if (loc === "/type") { + return ["string", "number"]; + } + if (loc === "/type_strict") { + return "string"; + } + }; + + expect(isSubsumed(altA, altB, getValue)).toBe(true); + expect(isSubsumed(altB, altA, getValue)).toBe(false); + }); + + test("enum subsumes narrower enum", () => { + /** @type NormalizedOutput */ + const altA = { + "": { + "https://json-schema.org/keyword/enum": { "/enum_broad": false } + } + }; + /** @type NormalizedOutput */ + const altB = { + "": { + "https://json-schema.org/keyword/enum": { "/enum_strict": false } + } + }; + + /** @type {(loc: string) => any} */ + const getValue = (loc) => { + if (loc === "/enum_broad") { + return ["a", "b"]; + } + if (loc === "/enum_strict") { + return ["a"]; + } + }; + + expect(isSubsumed(altA, altB, getValue)).toBe(true); + expect(isSubsumed(altB, altA, getValue)).toBe(false); + }); + + test("enum subsumes const", () => { + /** @type NormalizedOutput */ + const altA = { + "": { + "https://json-schema.org/keyword/enum": { "/enum_broad": false } + } + }; + /** @type NormalizedOutput */ + const altB = { + "": { + "https://json-schema.org/keyword/const": { "/const_a": false } + } + }; + + /** @type {(loc: string) => any} */ + const getValue = (loc) => { + if (loc === "/enum_broad") { + return ["a", "b"]; + } + if (loc === "/const_a") { + return "a"; + } + }; + + expect(isSubsumed(altA, altB, getValue)).toBe(true); + expect(isSubsumed(altB, altA, getValue)).toBe(false); + }); + + test("nested objects", () => { + /** @type NormalizedOutput */ + const altA = { + "/foo": { + "https://json-schema.org/keyword/type": { "/properties/foo/type": false } + } + }; + /** @type NormalizedOutput */ + const altB = { + "/foo": { + "https://json-schema.org/keyword/type": { "/properties/foo/type": false }, + "https://json-schema.org/keyword/minLength": { "/properties/foo/minLength": false } + } + }; + + /** @type {(loc: string) => any} */ + const getValue = (loc) => { + if (loc === "/properties/foo/type") { + return "string"; + } + if (loc === "/properties/foo/minLength") { + return 3; + } + }; + + expect(isSubsumed(altA, altB, getValue)).toBe(true); + expect(isSubsumed(altB, altA, getValue)).toBe(false); + }); + + test("nested applicators subsume child const", () => { + /** @type NormalizedOutput */ + const altA = { + "": { + "https://json-schema.org/keyword/anyOf": { + "/anyOf": [ + { "": { "https://json-schema.org/keyword/const": { "/anyOf/0/const": false } } }, + { "": { "https://json-schema.org/keyword/const": { "/anyOf/1/const": false } } } + ] + } + } + }; + + /** @type NormalizedOutput */ + const altB = { + "": { + "https://json-schema.org/keyword/const": { "/anyOf/0/const": false } + } + }; + + const getValue = () => null; + + expect(isSubsumed(altA, altB, getValue)).toBe(true); + expect(isSubsumed(altB, altA, getValue)).toBe(false); + }); +});