Browse Source

Refactor colors, heuristically detect the source of a validation failure

master
Sven Slootweg 1 year ago
parent
commit
2d26d92d17
  1. 1
      index.js
  2. 1
      package.json
  3. 100
      src/aggregrate-errors.js
  4. 32
      src/colors.js
  5. 66
      src/parse-stacktrace.js
  6. 19
      yarn.lock

1
index.js

@ -4,6 +4,7 @@
require("./src/compose-rules");
module.exports = {
// TODO: Provide a 'basePathsToIgnore' option for all of the below methods, so that third-party modules could wrap these methods without having themselves show up as the source of the error? Their base paths can then be treated like internals paths in the error parsing code that pinpoints the validation call site.
validateArguments: require("./src/api/validate-arguments"),
validateOptions: require("./src/api/validate-options"),
validateValue: require("./src/api/validate-value"),

1
package.json

@ -28,6 +28,7 @@
"assure-array": "^1.0.0",
"create-error": "^0.3.1",
"default-value": "^1.0.0",
"execall": "^2.0.0",
"flatten": "^1.0.3",
"indent-string": "^4.0.0",
"is-arguments": "^1.0.4",

100
src/aggregrate-errors.js

@ -1,12 +1,13 @@
"use strict";
const indentString = require("indent-string");
const supportsColor = require("supports-color");
const matchVirtualProperty = require("@validatem/match-virtual-property");
const asExpression = require("as-expression");
const syncpipe = require("syncpipe");
const AggregrateValidationError = require("./aggregrate-validation-error");
const parseStacktrace = require("./parse-stacktrace");
const { dim, dimBold, highlight, highlightBold } = require("./colors");
// TODO: Omit the "At (root)" for path-less errors, to avoid confusion when singular values are being compared?
// TODO: Move out the path generating logic into a separate module, to better support custom error formatting code
@ -17,29 +18,15 @@ function joinPathSegments(segments) {
: "(root)";
}
// NOTE: We do some manual ANSI escape code stuff here for now, because using `chalk` would significantly inflate the bundle size of the core.
// TODO: Find a better solution for this.
let openHighlight, openDim, closeColor;
if (supportsColor.stderr) {
openHighlight = `\u001b[36m`; // cyan
openDim = `\u001b[90m`; // gray
closeColor = `\u001b[39m`;
} else {
openHighlight = "";
openDim = "";
closeColor = "";
}
function renderErrorList(errors, subErrorLevels = 0) {
let rephrasedErrors = errors.map((error, i) => {
let pathSegments = error.path.map((segment) => {
if (segment == null) {
throw new Error(`Unexpected empty path segment encountered; this is a bug, please report it!`);
} else if (typeof segment === "string" || typeof segment === "number") {
return openHighlight + String(segment) + closeColor;
return highlight(String(segment));
} else if (matchVirtualProperty(segment)) {
return openDim + `(${segment.name})` + closeColor;
return dim(`(${segment.name})`);
} else {
throw new Error(`Unexpected path segment encountered: ${segment}; this is a bug, please report it!`);
}
@ -86,11 +73,84 @@ function renderErrorList(errors, subErrorLevels = 0) {
}).join("\n");
}
module.exports = function aggregrateAndThrowErrors(errors) {
let detailLines = renderErrorList(errors);
function determineLocation() {
try {
throw new Error(`Dummy error to obtain a stacktrace`);
} catch (error) {
try {
return syncpipe(error, [
(_) => parseStacktrace(_),
(_) => removeInternalFrames(_),
(_) => _[0],
(_) => {
return {
... _,
shortPath: abbreviatePath(_.location.path)
};
}
]);
} catch (parsingError) {
// If *anything at all* went wrong, we will just give up and return nothing, because the stacktrace parsing code is fragile, and we don't want that to be a reason for someone not to get a validation error displayed to them.
// FIXME: Do we want to have this visible as a warning in 1.0.0? Or should this warning be opt-in, for when the user wants more detail about *why* the location of the error could not be determined? Since there may be legitimate reasons for that to occur, eg. in bundled code without source maps.
console.warn("An error occurred during stacktrace parsing; please report this as a bug in @validatem/core!", parsingError);
}
}
}
// NOTE: This must be changed if aggregrate-errors.js is ever moved within the module!
let internalBasePathRegex = /(.+)src$/;
function getInternalBasePath() {
let match = internalBasePathRegex.exec(__dirname);
if (match != null) {
return match[1];
} else {
throw new Error(`Did not find expected basePath, instead got: ${__dirname}`);
}
}
function removeInternalFrames(stack) {
let internalBasePath = getInternalBasePath();
return stack.filter((frame) => {
return (!frame.location.path.startsWith(internalBasePath));
});
}
function abbreviatePath(path) {
// TODO: Maybe add a special case for paths within node_modules? For when an error originates from a package the user is depending on.
let segments = path.split(/[\\\/]/);
let [ thirdLast, secondLast, last ] = segments.slice(-3);
if (last != null) {
let isIndexFile = /^index\.[a-z0-9]+$/.test(last);
let relevantSegments = (isIndexFile)
? [ thirdLast, secondLast, last ]
: [ secondLast, last ];
return relevantSegments.join("/");
} else {
// This path is extremely short, so we'll just return it as-is.
return segments.join("/");
}
}
module.exports = function aggregrateAndThrowErrors(errors) {
if (errors.length > 0) {
return new AggregrateValidationError(`One or more validation errors occurred:\n${detailLines}`, {
let detailLines = renderErrorList(errors);
let frame = determineLocation();
let functionString = (frame.alias != null)
? `${frame.alias} [${frame.functionName}]`
: frame.functionName;
let locationString = (frame != null)
? `${highlightBold(functionString)} in ${highlightBold(frame.shortPath)}, at line ${highlightBold(frame.location.line)} (${frame.location.path})`
: dimBold(`(could not determine location)`);
return new AggregrateValidationError(`One or more validation errors occurred at ${locationString}:\n${detailLines}`, {
errors: errors
});
}

32
src/colors.js

@ -0,0 +1,32 @@
"use strict";
const supportsColor = require("supports-color");
// NOTE: We do some manual ANSI escape code stuff here for now, because using `chalk` would significantly inflate the bundle size of the core.
// TODO: Find a better solution for this.
let openHighlight, openDim, openHighlightBold, openDimBold, closeColor;
if (supportsColor.stderr) {
openHighlight = `\u001b[32m`; // green
openDim = `\u001b[90m`; // gray
openHighlightBold = `\u001b[32;1m`; // green bold
openDimBold = `\u001b[90;1m`; // gray bold
// closeColor = `\u001b[39m`; // Does not reset bold!
closeColor = `\u001b[0m`;
} else {
openHighlight = "";
openDim = "";
openHighlightBold = ""; // cyan bold
openDimBold = ""; // gray bold
closeColor = "";
}
module.exports = {
dim: (string) => openDim + string + closeColor,
highlight: (string) => openHighlight + string + closeColor,
dimBold: (string) => openDimBold + string + closeColor,
highlightBold: (string) => openHighlightBold + string + closeColor,
};

66
src/parse-stacktrace.js

@ -0,0 +1,66 @@
"use strict";
// FIXME: Move this into its own package!
const execall = require("execall");
const defaultValue = require("default-value");
// I'm so, so sorry...
let lineRegex = /\s+at\s+(?:(?:([^\[$\n]+)\[as ([^\]]+)\] |([^\($\n]+))\(([^\)\n]+)\)|([^\n]+))(?:\n|$)/gm;
let positionRegex = /(.+):(\d+):(\d+)/;
function maybeTrim(value) {
if (value != null) {
return value.trim();
} else {
return value;
}
}
function parseLocation(locationString) {
let match = positionRegex.exec(locationString);
if (match != null) {
return {
path: match[1],
line: parseInt(match[2]),
column: parseInt(match[3])
};
} else {
throw new Error(`Could not parse location from string: ${locationString}`);
}
}
function extractFrames(stack) {
// TODO: Maybe make this code even more cautious, and match each stacktrace line individually, aborting as soon as any one line cannot be parsed?
let matches = execall(lineRegex, stack);
return matches.map((match) => {
let groups = match.subMatches;
return {
functionName: maybeTrim(defaultValue(groups[0], groups[2])),
alias: groups[1],
location: parseLocation(defaultValue(groups[3], groups[4]))
};
});
}
module.exports = function parseStackTrace(error) {
let stack = error.stack;
let lines = stack.split("\n");
let firstStackLine = lines
.map((line) => line.trim())
.findIndex((line) => line.startsWith("at ") && positionRegex.test(line));
if (firstStackLine !== -1) {
let cleanStack = lines
.slice(firstStackLine)
.join("\n");
return extractFrames(cleanStack);
} else {
throw new Error(`Could not find a stacktrace frame`);
}
};

19
yarn.lock

@ -322,6 +322,13 @@ cli-width@^2.0.0:
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=
clone-regexp@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/clone-regexp/-/clone-regexp-2.2.0.tgz#7d65e00885cd8796405c35a737e7a86b7429e36f"
integrity sha512-beMpP7BOtTipFuW8hrJvREQ2DrRu3BE7by0ZpibtfBA+qfHYvMGTc2Yb1JMYPKg/JUw0CHYvpg796aNTSW9z7Q==
dependencies:
is-regexp "^2.0.0"
color-convert@^1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@ -552,6 +559,13 @@ esutils@^2.0.2:
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
execall@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/execall/-/execall-2.0.0.tgz#16a06b5fe5099df7d00be5d9c06eecded1663b45"
integrity sha512-0FU2hZ5Hh6iQnarpRtQurM/aAvp3RIbfvgLHrcqJYzhXyV2KFruhuChf9NC6waAhiUR7FFtlugkI4p7f2Fqlow==
dependencies:
clone-regexp "^2.1.0"
external-editor@^3.0.3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
@ -798,6 +812,11 @@ is-regex@^1.0.5:
dependencies:
has "^1.0.3"
is-regexp@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-2.1.0.tgz#cd734a56864e23b956bf4e7c66c396a4c0b22c2d"
integrity sha512-OZ4IlER3zmRIoB9AqNhEggVxqIH4ofDns5nRrPS6yQxXE1TPCUpFznBfRQmQa8uC+pXqjMnukiJBxCisIxiLGA==
is-string@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"

Loading…
Cancel
Save