diff --git a/eslint-factory/src/rules/no-json-stringify-error.test.ts b/eslint-factory/src/rules/no-json-stringify-error.test.ts index 76100f6b42e..c2f4c11347a 100644 --- a/eslint-factory/src/rules/no-json-stringify-error.test.ts +++ b/eslint-factory/src/rules/no-json-stringify-error.test.ts @@ -75,9 +75,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "err" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `try { f(); } catch (err) { core.error(String(err)); }`, + }, + { + messageId: "useDetailPreservingFormFallback", data: { errorVar: "err" }, - output: `try { f(); } catch (err) { core.error(getErrorMessage(err)); }`, + output: 'try { f(); } catch (err) { core.error((String(err) + "\\n" + (err?.stack ?? ""))); }', }, ], }, @@ -99,9 +104,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "error" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", + data: { errorVar: "error" }, + output: `try { f(); } catch (error) { core.error(\`details: \${String(error)}\`); }`, + }, + { + messageId: "useDetailPreservingFormFallback", data: { errorVar: "error" }, - output: `try { f(); } catch (error) { core.error(\`details: \${getErrorMessage(error)}\`); }`, + output: 'try { f(); } catch (error) { core.error(`details: ${(String(error) + "\\n" + (error?.stack ?? ""))}`); }', }, ], }, @@ -123,9 +133,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "err" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `p.catch(err => core.error(String(err)));`, + }, + { + messageId: "useDetailPreservingFormFallback", data: { errorVar: "err" }, - output: `p.catch(err => core.error(getErrorMessage(err)));`, + output: 'p.catch(err => core.error((String(err) + "\\n" + (err?.stack ?? ""))));', }, ], }, @@ -147,9 +162,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "err" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `p.catch(function(err) { core.error(String(err)); });`, + }, + { + messageId: "useDetailPreservingFormFallback", data: { errorVar: "err" }, - output: `p.catch(function(err) { core.error(getErrorMessage(err)); });`, + output: 'p.catch(function(err) { core.error((String(err) + "\\n" + (err?.stack ?? ""))); });', }, ], }, @@ -171,9 +191,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "outer" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", + data: { errorVar: "outer" }, + output: `try { f(); } catch (outer) { try { g(); } catch (inner) { } core.error(String(outer)); }`, + }, + { + messageId: "useDetailPreservingFormFallback", data: { errorVar: "outer" }, - output: `try { f(); } catch (outer) { try { g(); } catch (inner) { } core.error(getErrorMessage(outer)); }`, + output: 'try { f(); } catch (outer) { try { g(); } catch (inner) { } core.error((String(outer) + "\\n" + (outer?.stack ?? ""))); }', }, ], }, @@ -209,9 +234,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "err" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", data: { errorVar: "err" }, - output: `p.then(result => result, err => core.error(getErrorMessage(err)));`, + output: `p.then(result => result, err => core.error(String(err)));`, + }, + { + messageId: "useDetailPreservingFormFallback", + data: { errorVar: "err" }, + output: 'p.then(result => result, err => core.error((String(err) + "\\n" + (err?.stack ?? ""))));', }, ], }, @@ -225,9 +255,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "err" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", data: { errorVar: "err" }, - output: `p.then(null, err => core.error(getErrorMessage(err)));`, + output: `p.then(null, err => core.error(String(err)));`, + }, + { + messageId: "useDetailPreservingFormFallback", + data: { errorVar: "err" }, + output: 'p.then(null, err => core.error((String(err) + "\\n" + (err?.stack ?? ""))));', }, ], }, @@ -249,9 +284,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "err" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", data: { errorVar: "err" }, - output: `p.then(null, function(err) { core.error(getErrorMessage(err)); });`, + output: `p.then(null, function(err) { core.error(String(err)); });`, + }, + { + messageId: "useDetailPreservingFormFallback", + data: { errorVar: "err" }, + output: 'p.then(null, function(err) { core.error((String(err) + "\\n" + (err?.stack ?? ""))); });', }, ], }, @@ -280,9 +320,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "e" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", + data: { errorVar: "e" }, + output: `try { fetch(url); } catch (e) { console.error(String(e)); }`, + }, + { + messageId: "useDetailPreservingFormFallback", data: { errorVar: "e" }, - output: `try { fetch(url); } catch (e) { console.error(getErrorMessage(e)); }`, + output: 'try { fetch(url); } catch (e) { console.error((String(e) + "\\n" + (e?.stack ?? ""))); }', }, ], }, @@ -296,9 +341,14 @@ describe("no-json-stringify-error", () => { data: { errorVar: "err" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", data: { errorVar: "err" }, - output: `p.then(ok, err => console.error(getErrorMessage(err)));`, + output: `p.then(ok, err => console.error(String(err)));`, + }, + { + messageId: "useDetailPreservingFormFallback", + data: { errorVar: "err" }, + output: 'p.then(ok, err => console.error((String(err) + "\\n" + (err?.stack ?? ""))));', }, ], }, @@ -320,9 +370,246 @@ describe("no-json-stringify-error", () => { data: { errorVar: "inner" }, suggestions: [ { - messageId: "useGetErrorMessage", + messageId: "useStringFallback", data: { errorVar: "inner" }, - output: `try { f(); } catch (outer) { try { g(); } catch (inner) { core.error(getErrorMessage(inner)); } }`, + output: `try { f(); } catch (outer) { try { g(); } catch (inner) { core.error(String(inner)); } }`, + }, + { + messageId: "useDetailPreservingFormFallback", + data: { errorVar: "inner" }, + output: 'try { f(); } catch (outer) { try { g(); } catch (inner) { core.error((String(inner) + "\\n" + (inner?.stack ?? ""))); } }', + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: when getErrorMessage is in scope, suggests getErrorMessage and detail-preserving form", () => { + cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { + valid: [], + invalid: [ + { + code: `const getErrorMessage = require('./error_helpers').getErrorMessage; try { f(); } catch (err) { core.error(JSON.stringify(err)); }`, + errors: [ + { + messageId: "jsonStringifyError", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useGetErrorMessage", + data: { errorVar: "err" }, + output: `const getErrorMessage = require('./error_helpers').getErrorMessage; try { f(); } catch (err) { core.error(getErrorMessage(err)); }`, + }, + { + messageId: "useDetailPreservingForm", + data: { errorVar: "err" }, + output: 'const getErrorMessage = require(\'./error_helpers\').getErrorMessage; try { f(); } catch (err) { core.error((getErrorMessage(err) + "\\n" + (err?.stack ?? ""))); }', + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: when getErrorMessage is in scope via destructured require, detail-preserving form uses getErrorMessage", () => { + cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { + valid: [], + invalid: [ + { + code: `const { getErrorMessage } = require('./error_helpers'); try { f(); } catch (error) { core.error(\`details: \${JSON.stringify(error, null, 2)}\`); }`, + errors: [ + { + messageId: "jsonStringifyError", + data: { errorVar: "error" }, + suggestions: [ + { + messageId: "useGetErrorMessage", + data: { errorVar: "error" }, + output: `const { getErrorMessage } = require('./error_helpers'); try { f(); } catch (error) { core.error(\`details: \${getErrorMessage(error)}\`); }`, + }, + { + messageId: "useDetailPreservingForm", + data: { errorVar: "error" }, + output: 'const { getErrorMessage } = require(\'./error_helpers\'); try { f(); } catch (error) { core.error(`details: ${(getErrorMessage(error) + "\\n" + (error?.stack ?? ""))}`); }', + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: when getErrorMessage is imported via ESM, suggestions use getErrorMessage", () => { + esmRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { + valid: [], + invalid: [ + { + code: `import { getErrorMessage } from "./error_helpers"; try { f(); } catch (err) { console.error(JSON.stringify(err)); }`, + errors: [ + { + messageId: "jsonStringifyError", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useGetErrorMessage", + data: { errorVar: "err" }, + output: `import { getErrorMessage } from "./error_helpers"; try { f(); } catch (err) { console.error(getErrorMessage(err)); }`, + }, + { + messageId: "useDetailPreservingForm", + data: { errorVar: "err" }, + output: 'import { getErrorMessage } from "./error_helpers"; try { f(); } catch (err) { console.error((getErrorMessage(err) + "\\n" + (err?.stack ?? ""))); }', + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: when getErrorMessage is declared earlier in the catch block, suggestions use getErrorMessage", () => { + cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { + valid: [], + invalid: [ + { + code: `try { f(); } catch (err) { const getErrorMessage = customHelper; console.error(JSON.stringify(err)); }`, + errors: [ + { + messageId: "jsonStringifyError", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useGetErrorMessage", + data: { errorVar: "err" }, + output: `try { f(); } catch (err) { const getErrorMessage = customHelper; console.error(getErrorMessage(err)); }`, + }, + { + messageId: "useDetailPreservingForm", + data: { errorVar: "err" }, + output: 'try { f(); } catch (err) { const getErrorMessage = customHelper; console.error((getErrorMessage(err) + "\\n" + (err?.stack ?? ""))); }', + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: when getErrorMessage is declared after the call site, suggestions fall back to String()", () => { + cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { + valid: [], + invalid: [ + { + code: `try { f(); } catch (err) { console.error(JSON.stringify(err)); const getErrorMessage = customHelper; }`, + errors: [ + { + messageId: "jsonStringifyError", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `try { f(); } catch (err) { console.error(String(err)); const getErrorMessage = customHelper; }`, + }, + { + messageId: "useDetailPreservingFormFallback", + data: { errorVar: "err" }, + output: 'try { f(); } catch (err) { console.error((String(err) + "\\n" + (err?.stack ?? ""))); const getErrorMessage = customHelper; }', + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: detail-preserving suggestion is parenthesized when the call is chained", () => { + cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { + valid: [], + invalid: [ + { + code: `try { f(); } catch (err) { console.error(JSON.stringify(err).slice(0, 20)); }`, + errors: [ + { + messageId: "jsonStringifyError", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `try { f(); } catch (err) { console.error(String(err).slice(0, 20)); }`, + }, + { + messageId: "useDetailPreservingFormFallback", + data: { errorVar: "err" }, + output: 'try { f(); } catch (err) { console.error((String(err) + "\\n" + (err?.stack ?? "")).slice(0, 20)); }', + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: when getErrorMessage is in scope inside a promise rejection handler, suggestions use getErrorMessage", () => { + cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { + valid: [], + invalid: [ + { + code: `const { getErrorMessage } = require('./error_helpers'); p.catch(err => console.error(JSON.stringify(err)));`, + errors: [ + { + messageId: "jsonStringifyError", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useGetErrorMessage", + data: { errorVar: "err" }, + output: `const { getErrorMessage } = require('./error_helpers'); p.catch(err => console.error(getErrorMessage(err)));`, + }, + { + messageId: "useDetailPreservingForm", + data: { errorVar: "err" }, + output: 'const { getErrorMessage } = require(\'./error_helpers\'); p.catch(err => console.error((getErrorMessage(err) + "\\n" + (err?.stack ?? ""))));', + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: when getErrorMessage is not in scope, first suggestion uses String() fallback", () => { + cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { + valid: [], + invalid: [ + { + code: `try { f(); } catch (err) { console.error(JSON.stringify(err)); }`, + errors: [ + { + messageId: "jsonStringifyError", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `try { f(); } catch (err) { console.error(String(err)); }`, + }, + { + messageId: "useDetailPreservingFormFallback", + data: { errorVar: "err" }, + output: 'try { f(); } catch (err) { console.error((String(err) + "\\n" + (err?.stack ?? ""))); }', }, ], }, diff --git a/eslint-factory/src/rules/no-json-stringify-error.ts b/eslint-factory/src/rules/no-json-stringify-error.ts index 5ba4e56773e..e1b6d8cfb08 100644 --- a/eslint-factory/src/rules/no-json-stringify-error.ts +++ b/eslint-factory/src/rules/no-json-stringify-error.ts @@ -46,12 +46,18 @@ export const noJsonStringifyErrorRule = createRule({ schema: [], messages: { jsonStringifyError: - "JSON.stringify({{errorVar}}) produces {} for Error objects — Error properties (message, stack, etc.) are non-enumerable. Prefer getErrorMessage({{errorVar}}) from error_helpers.cjs or explicitly serialize a guarded value after narrowing it.", + "JSON.stringify({{errorVar}}) produces {} for Error objects — Error properties (message, stack, etc.) are non-enumerable. Use getErrorMessage({{errorVar}}) if it is available, or String({{errorVar}}) as a safe import-free alternative.", useGetErrorMessage: "Replace with getErrorMessage({{errorVar}}) — ensure getErrorMessage is imported from error_helpers.cjs.", + useStringFallback: "Replace with String({{errorVar}}) — getErrorMessage is not in scope; String() is a safe import-free alternative.", + useDetailPreservingForm: 'Replace with (getErrorMessage({{errorVar}}) + "\\n" + ({{errorVar}}?.stack ?? "")) to preserve the stack trace — ensure getErrorMessage is imported from error_helpers.cjs.', + useDetailPreservingFormFallback: 'Replace with (String({{errorVar}}) + "\\n" + ({{errorVar}}?.stack ?? "")) to preserve the stack trace.', }, }, defaultOptions: [], create(context) { + const sourceCode = context.sourceCode; + type SourceCodeScope = ReturnType; + // Stack tracking caught error variable names. // Each scope entry holds varName (empty string for sentinel) and isSentinel flag. const scopeStack: ErrorScope[] = []; @@ -87,6 +93,30 @@ export const noJsonStringifyErrorRule = createRule({ scopeStack.pop(); } + function isDefinitionAvailableAtNode(definition: { type: string; name?: TSESTree.Node | null; node: TSESTree.Node }, node: TSESTree.Node): boolean { + if (definition.type === "ImportBinding" || definition.type === "FunctionName") { + return true; + } + + const definitionNode = definition.name ?? definition.node; + return definitionNode.range[0] < node.range[0]; + } + + function hasResolvableLocalBinding(node: TSESTree.Node, name: string): boolean { + let scope: SourceCodeScope | null = sourceCode.getScope(node); + + while (scope) { + const variable = scope.set.get(name); + if (variable && variable.defs.some(definition => isDefinitionAvailableAtNode(definition, node))) { + return true; + } + + scope = scope.upper; + } + + return false; + } + return { // Track catch clause parameters CatchClause(node) { @@ -125,18 +155,46 @@ export const noJsonStringifyErrorRule = createRule({ if (!caughtNames.has(firstArg.name)) return; const errorVar = firstArg.name; + + const getErrorMessageInScope = hasResolvableLocalBinding(node, "getErrorMessage"); + context.report({ node, messageId: "jsonStringifyError", data: { errorVar }, suggest: [ - { - messageId: "useGetErrorMessage" as const, - data: { errorVar }, - fix(fixer) { - return fixer.replaceText(node, `getErrorMessage(${errorVar})`); - }, - }, + // Suggestion 1: concise message-only replacement + getErrorMessageInScope + ? ({ + messageId: "useGetErrorMessage" as const, + data: { errorVar }, + fix(fixer) { + return fixer.replaceText(node, `getErrorMessage(${errorVar})`); + }, + } as const) + : ({ + messageId: "useStringFallback" as const, + data: { errorVar }, + fix(fixer) { + return fixer.replaceText(node, `String(${errorVar})`); + }, + } as const), + // Suggestion 2: detail-preserving replacement (retains stack trace) + getErrorMessageInScope + ? ({ + messageId: "useDetailPreservingForm" as const, + data: { errorVar }, + fix(fixer) { + return fixer.replaceText(node, `(getErrorMessage(${errorVar}) + "\\n" + (${errorVar}?.stack ?? ""))`); + }, + } as const) + : ({ + messageId: "useDetailPreservingFormFallback" as const, + data: { errorVar }, + fix(fixer) { + return fixer.replaceText(node, `(String(${errorVar}) + "\\n" + (${errorVar}?.stack ?? ""))`); + }, + } as const), ], }); },