-
Notifications
You must be signed in to change notification settings - Fork 444
feat(no-json-stringify-error): scope-aware suggestions + detail-preserving second suggestion #43748
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
72df5b3
a882f0e
c014ca1
9636d69
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(getErrorMessage(err)); });`, | ||
| output: `p.catch(function(err) { core.error(String(err)); });`, | ||
| }, | ||
| { | ||
| messageId: "useDetailPreservingFormFallback", | ||
| data: { errorVar: "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(getErrorMessage(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(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(String(err)));`, | ||
| }, | ||
| { | ||
| messageId: "useDetailPreservingFormFallback", | ||
| data: { errorVar: "err" }, | ||
| output: `p.then(result => result, err => core.error(getErrorMessage(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(getErrorMessage(e)); }`, | ||
| output: `try { fetch(url); } catch (e) { console.error(String(e)); }`, | ||
| }, | ||
| { | ||
| messageId: "useDetailPreservingFormFallback", | ||
| data: { errorVar: "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,101 @@ 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(String(inner)); } }`, | ||
| }, | ||
| { | ||
| messageId: "useDetailPreservingFormFallback", | ||
| 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) + "\\n" + (inner.stack ?? "")); } }', | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: when getErrorMessage is in scope, suggests getErrorMessage and detail-preserving form", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The three new "in scope" test cases use Adding a test with it("invalid: when getErrorMessage is imported (ESM), suggests getErrorMessage", () => {
esmRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, {
valid: [],
invalid: [
{
code: `import { getErrorMessage } from "./error_helpers.js"; try { f(); } catch (err) { core.error(JSON.stringify(err)); }`,
errors: [
{
messageId: "jsonStringifyError",
data: { errorVar: "err" },
suggestions: [
{ messageId: "useGetErrorMessage", data: { errorVar: "err" },
output: `import { getErrorMessage } from "./error_helpers.js"; try { f(); } catch (err) { core.error(getErrorMessage(err)); }` },
{ messageId: "useDetailPreservingForm", data: { errorVar: "err" },
output: \`import { getErrorMessage } from "./error_helpers.js"; try { f(); } catch (err) { core.error(getErrorMessage(err) + "\\n" + (err.stack ?? "")); }\` },
],
},
],
},
],
});
});Without this, a regression in ESM scope detection would go unnoticed. @copilot please address this. |
||
| 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 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 ?? "")); }', | ||
| }, | ||
| ], | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,9 @@ export const noJsonStringifyErrorRule = createRule({ | |
| 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.", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Consider a scope-neutral phrasing, e.g.: "JSON.stringify({{errorVar}}) produces {} for Error objects — ... Use getErrorMessage({{errorVar}}) if it is imported, or String({{errorVar}}) as a safe import-free alternative."Alternatively, register a second @copilot please address this. |
||
| 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.', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/grill-with-docs] The 💡 Suggested fixConsider splitting into two message variants, or make the primary message scope-neutral: jsonStringifyError:
"JSON.stringify({{errorVar}}) produces {} for Error objects — Error properties are non-enumerable. " +
"Use the suggestions below to replace it with a safe alternative.",This avoids confusing users who don't have @copilot please address this. |
||
| useDetailPreservingFormFallback: 'Replace with String({{errorVar}}) + "\\n" + ({{errorVar}}.stack ?? "") to preserve the stack trace.', | ||
|
|
||
| }, | ||
| }, | ||
| defaultOptions: [], | ||
|
|
@@ -125,18 +128,54 @@ export const noJsonStringifyErrorRule = createRule({ | |
| if (!caughtNames.has(firstArg.name)) return; | ||
|
|
||
| const errorVar = firstArg.name; | ||
|
|
||
| // Check whether getErrorMessage is resolvable in the current scope chain. | ||
| const getErrorMessageInScope = (() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] No test for 💡 Why this mattersConsider: try { f(); } catch (err) {
const getErrorMessage = customHelper;
core.error(JSON.stringify(err)); // would this pick useGetErrorMessage or useStringFallback?
}Add a test to document the intended behavior (accept @copilot please address this. |
||
| let scope: ReturnType<typeof context.sourceCode.getScope> | null = context.sourceCode.getScope(node); | ||
| while (scope) { | ||
| if (scope.variables.some(v => v.name === "getErrorMessage")) return true; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] 💡 Consider checking for at least one write reference (i.e., an actual binding)// More precise: require at least one write reference so that uninitialized declarations don't count
if (scope.variables.some(v =>
v.name === "getErrorMessage" && v.defs.length > 0
)) return true;
@copilot please address this. |
||
| scope = scope.upper; | ||
| } | ||
| return false; | ||
| })(); | ||
|
|
||
|
|
||
| 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), | ||
| ], | ||
| }); | ||
| }, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] No ESM in-scope test: all three new test cases use
cjsRuleTester. The ESM import path (import { getErrorMessage } from ...) is untested — scope analysis forimportdeclarations differs from CJSrequire()and is worth an explicitesmRuleTestercase.💡 Suggested test addition
Without this, a regression in ESM scope detection would go unnoticed.
@copilot please address this.