diff --git a/packages/normalization-core/src/error-coercion.test.ts b/packages/normalization-core/src/error-coercion.test.ts index 64a665171b65..fc543fb6fafe 100644 --- a/packages/normalization-core/src/error-coercion.test.ts +++ b/packages/normalization-core/src/error-coercion.test.ts @@ -26,6 +26,27 @@ describe("formatErrorMessage", () => { ); }); + it("omits cause text the wrapper message already spells out", () => { + // Wrappers that embed the cause verbatim printed the whole sentence twice. + const parseFailure = new SyntaxError("JSON5: invalid character 'j' at 1:7"); + const wrapped = new Error(`Failed to parse --file as JSON5: ${parseFailure.message}`, { + cause: parseFailure, + }); + expect(format(wrapped)).toBe( + "Failed to parse --file as JSON5: JSON5: invalid character 'j' at 1:7", + ); + + // Codes keep their own segment even when the detail already names them. + const errno = Object.assign( + new Error("ENOENT: no such file or directory, open '/tmp/missing.json'"), + { code: "ENOENT" }, + ); + const notFound = new Error("--file not found: /tmp/missing.json.", { cause: errno }); + expect(format(notFound)).toBe( + "--file not found: /tmp/missing.json. | ENOENT: no such file or directory, open '/tmp/missing.json' | ENOENT", + ); + }); + it("formats status/code records and structured non-Error causes", () => { expect(format({ status: 500, code: "EPIPE" })).toBe("status=500 code=EPIPE"); expect(format({ status: 404 })).toBe("status=404 code=unknown"); diff --git a/packages/normalization-core/src/error-coercion.ts b/packages/normalization-core/src/error-coercion.ts index 6deeed1300f2..c96cc060e2b7 100644 --- a/packages/normalization-core/src/error-coercion.ts +++ b/packages/normalization-core/src/error-coercion.ts @@ -87,6 +87,17 @@ export function formatErrorMessage(value: unknown, options: FormatErrorMessageOp formatted += ` | ${message}`; seenMessages.add(message); }; + // Wrappers routinely embed the cause verbatim ("failed to parse X: "), + // which exact-match dedupe misses, so the whole sentence prints twice. Codes stay on + // their own: a trailing bare code is this formatter's convention even when the detail + // already names it. + const appendCauseErrorMessage = (message: string | undefined): void => { + if (message && formatted.includes(message)) { + seenMessages.add(message); + return; + } + appendCauseMessage(message); + }; if (options.includeCode) { const code = readProperty(value, "code"); if (typeof code === "string" || typeof code === "number") { @@ -96,7 +107,7 @@ export function formatErrorMessage(value: unknown, options: FormatErrorMessageOp while (cause && !seen.has(cause)) { seen.add(cause); if (cause instanceof Error) { - appendCauseMessage(cause.message); + appendCauseErrorMessage(cause.message); const code = readProperty(cause, "code"); if (typeof code === "string" || typeof code === "number") { appendCauseMessage(String(code));