Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/olive-paths-explain.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions declarations/validate.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 41 additions & 4 deletions src/ValidationError.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} */
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down
6 changes: 6 additions & 0 deletions src/util/hints.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions src/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions test/__snapshots__/index.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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`] = `
Expand Down Expand Up @@ -1125,20 +1125,18 @@ 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`] = `
"Invalid configuration object. Object has been initialized using a configuration object that does not match the API schema.
- 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."
`;

Expand All @@ -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."
`;

Expand Down Expand Up @@ -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? }"
`;
Expand Down
45 changes: 45 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading