From 92bca62a9bbccf067756c5e70caf98f64d1cf33a Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Tue, 8 Sep 2026 13:19:23 +0000 Subject: [PATCH] fix: say which strings an `absolutePath` keyword accepts A string carrying the keyword was described as a plain `string`, so the list of accepted types offered `string` for a value like `"node_modules"` that is a string and is rejected. It reads `absolute path string` now, or `relative path string` for the `false` form - `output.filename` says so where its own description already did. The same report listed a failure once per branch that reached it: a rule condition is reachable both directly and through the array of conditions, so `exclude: ["node_modules"]` printed the whole rule type and the same detail twice. Children formatting to a message an earlier one produced are dropped, which leaves the single line naming the path. `getArticle` covers the string case as well now, so `empty string` no longer reads as `a empty string`. Fixes #66 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK --- .changeset/olive-paths-explain.md | 7 +++++ declarations/validate.d.ts | 4 +++ src/ValidationError.js | 45 ++++++++++++++++++++++++--- src/util/hints.js | 6 ++++ src/validate.js | 1 + test/__snapshots__/index.test.js.snap | 16 +++++----- test/index.test.js | 45 +++++++++++++++++++++++++++ 7 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 .changeset/olive-paths-explain.md diff --git a/.changeset/olive-paths-explain.md b/.changeset/olive-paths-explain.md new file mode 100644 index 0000000..ea5d15d --- /dev/null +++ b/.changeset/olive-paths-explain.md @@ -0,0 +1,7 @@ +--- +"schema-utils": patch +--- + +author: alexander-akait + +A string an `absolutePath` keyword applies to is described as an `absolute path string` or a `relative path string` rather than as a bare `string`, which read as though any string would do. A failure a schema reaches through more than one branch is listed once instead of repeatedly, so a relative path given to a rule condition is reported as the one line that says so. diff --git a/declarations/validate.d.ts b/declarations/validate.d.ts index 8b5077d..4e069fa 100644 --- a/declarations/validate.d.ts +++ b/declarations/validate.d.ts @@ -28,6 +28,10 @@ export type ExtendedSchema = { * undefined will be resolved as null */ undefinedAsNull?: boolean | undefined; + /** + * the string is an absolute path when true, a relative one when false + */ + absolutePath?: boolean | undefined; }; export type Extend = ExtendedSchema; export type Schema = (JSONSchema4 | JSONSchema6 | JSONSchema7) & ExtendedSchema; diff --git a/src/ValidationError.js b/src/ValidationError.js index d8c58a0..a1296c6 100644 --- a/src/ValidationError.js +++ b/src/ValidationError.js @@ -68,6 +68,37 @@ function filterMax(array, fn) { return array.filter((item) => fn(item) === evaluatedMax); } +/** + * Removes the children that format to a message an earlier child already + * produced. A schema can reach the same failure through more than one branch. + * @param {SchemaUtilErrorObject[]} children children + * @param {(error: SchemaUtilErrorObject) => string} format formats one child + * @returns {SchemaUtilErrorObject[]} children without the duplicates + */ +function filterDuplicateChildren(children, format) { + if (children.length < 2) { + return children; + } + + /** @type {Set} */ + const seen = new Set(); + /** @type {SchemaUtilErrorObject[]} */ + const newChildren = []; + + for (const child of children) { + const message = format(child); + + if (seen.has(message)) { + continue; + } + + seen.add(message); + newChildren.push(child); + } + + return newChildren; +} + /** * @param {SchemaUtilErrorObject[]} children children * @returns {SchemaUtilErrorObject[]} filtered children @@ -998,12 +1029,15 @@ class ValidationError extends Error { false, true, )}`; - case "string": - return `${instancePath} should be a ${this.getSchemaPartText( + case "string": { + const stringType = this.getSchemaPartText( parentSchema, false, true, - )}`; + ); + + return `${instancePath} should be ${getArticle(stringType)} ${stringType}`; + } case "boolean": return `${instancePath} should be a ${this.getSchemaPartText( parentSchema, @@ -1347,7 +1381,10 @@ class ValidationError extends Error { }); } - let filteredChildren = filterChildren(children); + let filteredChildren = filterDuplicateChildren( + filterChildren(children), + (nestedError) => this.formatValidationError(nestedError), + ); if (filteredChildren.length === 1) { return this.formatValidationError(filteredChildren[0]); diff --git a/src/util/hints.js b/src/util/hints.js index f7e5492..ff2bb38 100644 --- a/src/util/hints.js +++ b/src/util/hints.js @@ -62,6 +62,12 @@ module.exports.stringHints = function stringHints(schema, logic) { currentSchema.formatMaximum = tmpFormat; } + if (typeof currentSchema.absolutePath === "boolean") { + type = currentSchema.absolutePath + ? "absolute path string" + : "relative path string"; + } + if (typeof currentSchema.minLength === "number") { if (currentSchema.minLength === 1) { type = "non-empty string"; diff --git a/src/validate.js b/src/validate.js index a38ebdd..8b6e563 100644 --- a/src/validate.js +++ b/src/validate.js @@ -55,6 +55,7 @@ const getAjv = memoize(() => { * @property {(string | boolean)=} formatExclusiveMaximum format exclusive maximum * @property {string=} link link * @property {boolean=} undefinedAsNull undefined will be resolved as null + * @property {boolean=} absolutePath the string is an absolute path when true, a relative one when false */ // TODO remove me in the next major release diff --git a/test/__snapshots__/index.test.js.snap b/test/__snapshots__/index.test.js.snap index 31802a6..d901c34 100644 --- a/test/__snapshots__/index.test.js.snap +++ b/test/__snapshots__/index.test.js.snap @@ -392,7 +392,7 @@ exports[`validation should fail validation for array type 1`] = ` exports[`validation should fail validation for array with absolutePath item 1`] = ` "Invalid configuration object. Object has been initialized using a configuration object that does not match the API schema. - configuration.arrayWithAbsolutePath should be an array: - [integer, string, ...]" + [integer, absolute path string, ...]" `; exports[`validation should fail validation for array with additionalItems 1`] = ` @@ -1125,9 +1125,7 @@ exports[`validation should fail validation for module 1`] = ` * configuration.module.rules[0].compiler should be an array: [RegExp | non-empty string | function | [(recursive), ...] | object { and?, exclude?, include?, not?, or?, test? }, ...] * configuration.module.rules[0].compiler should be an object: - object { and?, exclude?, include?, not?, or?, test? } - * configuration.module.rules[0].compiler should be an array: - [RegExp | non-empty string | function | [(recursive), ...] | object { and?, exclude?, include?, not?, or?, test? }, ...]" + object { and?, exclude?, include?, not?, or?, test? }" `; exports[`validation should fail validation for multiple configurations 1`] = ` @@ -1135,10 +1133,10 @@ exports[`validation should fail validation for multiple configurations 1`] = ` - configuration[0].entry[0] should be a non-empty string. -> A non-empty string - configuration[1].output.filename should be one of these: - string | function + relative path string | function -> Specifies the name of each output file on disk. You must **not** specify an absolute path here! The \`output.path\` option determines the location on disk the files are written to, filename is used solely for naming the individual files. Details: - * configuration[1].output.filename should be a string. + * configuration[1].output.filename should be a relative path string. * configuration[1].output.filename should be an instance of function." `; @@ -1147,10 +1145,10 @@ exports[`validation should fail validation for multiple errors 1`] = ` - configuration.entry[0] should be a non-empty string. -> A non-empty string - configuration.output.filename should be one of these: - string | function + relative path string | function -> Specifies the name of each output file on disk. You must **not** specify an absolute path here! The \`output.path\` option determines the location on disk the files are written to, filename is used solely for naming the individual files. Details: - * configuration.output.filename should be a string. + * configuration.output.filename should be a relative path string. * configuration.output.filename should be an instance of function." `; @@ -1647,7 +1645,7 @@ exports[`validation should fail validation for oneOf #3 1`] = ` * configuration.optimization.runtimeChunk should be a boolean. * configuration.optimization.runtimeChunk should be one of these: "single" | "multiple" - * configuration.optimization.runtimeChunk should be a empty string. + * configuration.optimization.runtimeChunk should be an empty string. * configuration.optimization.runtimeChunk should be an object: object { name? }" `; diff --git a/test/index.test.js b/test/index.test.js index 4c0c682..a9eb304 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3061,6 +3061,51 @@ describe("validation", () => { webpackSchema, ); + // A rule condition that is a string has to be an absolute path, and saying so once beats + // listing every branch of the condition schema that the value also failed + it.each([ + ["a string", "node_modules", "configuration.module.rules[0].exclude"], + ["an array", ["node_modules"], "configuration.module.rules[0].exclude[0]"], + ])( + "should report a relative rule condition given as %s without listing every branch", + (_name, exclude, expectedPath) => { + let message; + + try { + validate( + webpackSchema, + { + module: { + rules: [{ test: /\.js$/, exclude, use: ["babel-loader"] }], + }, + }, + {}, + ); + } catch (error) { + if (error.name !== "ValidationError") { + throw error; + } + + message = error.message; + } + + expect(message.split("\n").slice(1).join("\n")).toBe( + ` - ${expectedPath}: The provided value "node_modules" is not an absolute path!`, + ); + }, + ); + + // The type of a string an `absolutePath` keyword applies to says which strings are accepted, + // a bare `string` reads as though any would do + createFailedTestCase( + "absolutePath in a list of alternatives", + { testAbsolutePath: 1 }, + (msg) => { + expect(msg).toContain("should be an absolute path string."); + expect(msg).not.toContain("should be a string."); + }, + ); + // `import.meta.resolve()` returns a `file://` URL, so every option of webpack's own schema that // takes an absolute path has to accept one - these are all of them const WEBPACK_FILE_URL = "file:///directory/deep/tree";