-
Notifications
You must be signed in to change notification settings - Fork 464
[eslint-miner] feat(eslint): add no-json-stringify-error rule #43523
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
Changes from all commits
6e52783
d748c45
a47a44b
15eb2ac
b8354dc
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 |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| import { RuleTester } from "eslint"; | ||
| import { describe, it } from "vitest"; | ||
| import { noJsonStringifyErrorRule } from "./no-json-stringify-error"; | ||
|
|
||
| const cjsRuleTester = new RuleTester({ | ||
| languageOptions: { | ||
| ecmaVersion: 2022, | ||
| sourceType: "commonjs", | ||
| }, | ||
| }); | ||
|
|
||
| const esmRuleTester = new RuleTester({ | ||
| languageOptions: { | ||
| ecmaVersion: 2022, | ||
| sourceType: "module", | ||
| }, | ||
| }); | ||
|
|
||
| describe("no-json-stringify-error", () => { | ||
| it("valid: JSON.stringify on a non-caught variable is not flagged", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [`const obj = { a: 1 }; JSON.stringify(obj);`, `JSON.stringify({ message: "hello" });`, `JSON.stringify("a string");`, `const data = fetchData(); JSON.stringify(data);`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("valid: JSON.stringify on a non-error variable inside a catch block is not flagged", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [`try { f(); } catch (err) { const data = { a: 1 }; JSON.stringify(data); }`, `try { f(); } catch (err) { JSON.stringify(someOtherVar); }`, `try { f(); } catch (err) { JSON.stringify({ message: err.message }); }`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("valid: JSON.stringify with explicit error properties is not flagged", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [`try { f(); } catch (err) { JSON.stringify({ message: err.message, stack: err.stack }); }`, `try { f(); } catch (err) { JSON.stringify({ error: String(err) }); }`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("valid: JSON.stringify on catch param that shadows outer scope is not flagged outside the catch", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [ | ||
| `const err = { a: 1 }; try { f(); } catch (err) { } JSON.stringify(err);`, | ||
| `try { f(); } catch (err) { [1].forEach(function(err) { JSON.stringify(err); }); }`, | ||
| `try { f(); } catch (err) { items.map(err => JSON.stringify(err)); }`, | ||
| ], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("valid: JSON.stringify on promise .catch() non-error param is not flagged", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [`p.catch(function(err) { JSON.stringify(otherVar); })`, `p.catch(err => JSON.stringify(notErr))`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("valid: bare catch {} without binding is not flagged", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [`try { f(); } catch { JSON.stringify(someObj); }`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: JSON.stringify(err) in catch block is flagged", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `try { f(); } catch (err) { core.error(JSON.stringify(err)); }`, | ||
| errors: [ | ||
| { | ||
| messageId: "jsonStringifyError", | ||
| data: { errorVar: "err" }, | ||
| suggestions: [ | ||
| { | ||
| messageId: "useGetErrorMessage", | ||
| data: { errorVar: "err" }, | ||
| output: `try { f(); } catch (err) { core.error(getErrorMessage(err)); }`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: JSON.stringify(error, null, 2) in catch block is flagged", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [], | ||
|
Contributor
Author
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] Each test case creates a new 💡 SuggestionConsolidate into a single cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, {
valid: [
// non-caught variables
`const obj = { a: 1 }; JSON.stringify(obj);`,
// catch-block non-error
`try { f(); } catch (err) { JSON.stringify(someOtherVar); }`,
// ... etc
],
invalid: [
// catch block
{ code: `try { f(); } catch (err) { core.error(JSON.stringify(err)); }`, errors: [...] },
// ... etc
],
});This aligns the test structure with the established codebase convention and makes the rule's full input/output contract immediately scannable. @copilot please address this. |
||
| invalid: [ | ||
| { | ||
| code: `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: `try { f(); } catch (error) { core.error(\`details: \${getErrorMessage(error)}\`); }`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: JSON.stringify(err) in promise .catch() arrow callback is flagged", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `p.catch(err => core.error(JSON.stringify(err)));`, | ||
| errors: [ | ||
| { | ||
| messageId: "jsonStringifyError", | ||
| data: { errorVar: "err" }, | ||
| suggestions: [ | ||
| { | ||
| messageId: "useGetErrorMessage", | ||
| data: { errorVar: "err" }, | ||
| output: `p.catch(err => core.error(getErrorMessage(err)));`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: JSON.stringify(err) in promise .catch() function callback is flagged", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `p.catch(function(err) { core.error(JSON.stringify(err)); });`, | ||
| errors: [ | ||
| { | ||
| messageId: "jsonStringifyError", | ||
| data: { errorVar: "err" }, | ||
| suggestions: [ | ||
| { | ||
| messageId: "useGetErrorMessage", | ||
| data: { errorVar: "err" }, | ||
| output: `p.catch(function(err) { core.error(getErrorMessage(err)); });`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: nested catch — each catch variable is tracked independently", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `try { f(); } catch (outer) { try { g(); } catch (inner) { } core.error(JSON.stringify(outer)); }`, | ||
| errors: [ | ||
| { | ||
| messageId: "jsonStringifyError", | ||
| data: { errorVar: "outer" }, | ||
| suggestions: [ | ||
| { | ||
| messageId: "useGetErrorMessage", | ||
| data: { errorVar: "outer" }, | ||
| output: `try { f(); } catch (outer) { try { g(); } catch (inner) { } core.error(getErrorMessage(outer)); }`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: works with ES module syntax", () => { | ||
| esmRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `try { fetch(url); } catch (e) { console.error(JSON.stringify(e)); }`, | ||
| errors: [ | ||
| { | ||
| messageId: "jsonStringifyError", | ||
| data: { errorVar: "e" }, | ||
| suggestions: [ | ||
| { | ||
| messageId: "useGetErrorMessage", | ||
| data: { errorVar: "e" }, | ||
| output: `try { fetch(url); } catch (e) { console.error(getErrorMessage(e)); }`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: inner catch variable also flagged when outer has JSON.stringify", () => { | ||
| cjsRuleTester.run("no-json-stringify-error", noJsonStringifyErrorRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `try { f(); } catch (outer) { try { g(); } catch (inner) { core.error(JSON.stringify(inner)); } }`, | ||
| errors: [ | ||
| { | ||
| messageId: "jsonStringifyError", | ||
| data: { errorVar: "inner" }, | ||
| suggestions: [ | ||
| { | ||
| messageId: "useGetErrorMessage", | ||
| data: { errorVar: "inner" }, | ||
| output: `try { f(); } catch (outer) { try { g(); } catch (inner) { core.error(getErrorMessage(inner)); } }`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; | ||
|
|
||
| const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); | ||
|
|
||
| interface ErrorScope { | ||
| varName: string; | ||
| isSentinel: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when the function is passed directly as the first argument to a | ||
| * `.catch()` call. Named-reference handlers (for example `p.catch(handler)`) | ||
| * are intentionally out of scope. | ||
| */ | ||
| function isCatchCallback(node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression): boolean { | ||
| const parent = node.parent; | ||
| if (!parent || parent.type !== AST_NODE_TYPES.CallExpression) return false; | ||
| const callee = parent.callee; | ||
| if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) return false; | ||
| const prop = callee.property; | ||
| return prop.type === AST_NODE_TYPES.Identifier && prop.name === "catch" && parent.arguments[0] === node; | ||
|
Contributor
Author
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. Suggestion: also check
p.then(null, function(err) { JSON.stringify(err); }); // not caught by current ruleThis is a lower-priority gap — the current scope catches the most common pattern — but worth noting for future completeness. Adding a check for @copilot please address this. |
||
| } | ||
|
|
||
| export const noJsonStringifyErrorRule = createRule({ | ||
| name: "no-json-stringify-error", | ||
| meta: { | ||
| type: "problem", | ||
| hasSuggestions: true, | ||
| docs: { | ||
| description: "Disallow JSON.stringify() on caught error variables — Error properties (message, stack, etc.) are non-enumerable and produce {} silently", | ||
| }, | ||
| schema: [], | ||
| messages: { | ||
|
Contributor
Author
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] 💡 SuggestionAdd a short JSDoc comment to /**
* Returns true if the function is passed directly as the first argument to any
* `.catch()` method call (Promise.catch or custom thenables). Named-reference
* handlers (p.catch(handler)) are out of scope.
*/
function isCatchCallback(...): boolean {This makes the tradeoff explicit for future maintainers. @copilot please address this. |
||
| 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.", | ||
| useGetErrorMessage: "Replace with getErrorMessage({{errorVar}}) — ensure getErrorMessage is imported from error_helpers.cjs.", | ||
| }, | ||
| }, | ||
| defaultOptions: [], | ||
| create(context) { | ||
| // Stack tracking caught error variable names. | ||
| // Each scope entry holds varName (empty string for sentinel) and isSentinel flag. | ||
| const scopeStack: ErrorScope[] = []; | ||
|
|
||
| function getCaughtVarNames(): Set<string> { | ||
| const names = new Set<string>(); | ||
| // Walk from the innermost active scope outward and stop at the first | ||
| // sentinel so non-.catch() callbacks cannot see shadowed catch vars. | ||
| for (let i = scopeStack.length - 1; i >= 0; i--) { | ||
| const scope = scopeStack[i]; | ||
| if (scope.isSentinel) break; | ||
| if (scope.varName) names.add(scope.varName); | ||
| } | ||
| return names; | ||
| } | ||
|
|
||
| function enterFunction(node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression): void { | ||
| if (isCatchCallback(node)) { | ||
| const params = node.params; | ||
| if (params.length === 1 && params[0].type === AST_NODE_TYPES.Identifier) { | ||
| scopeStack.push({ varName: params[0].name, isSentinel: false }); | ||
| } else { | ||
| // No-param or destructuring: push sentinel so outer frames are not affected | ||
| scopeStack.push({ varName: "", isSentinel: true }); | ||
| } | ||
| } else { | ||
| // Non-.catch() function: sentinel to avoid false positives from shadowed names | ||
| scopeStack.push({ varName: "", isSentinel: true }); | ||
| } | ||
|
Comment on lines
+66
to
+69
|
||
| } | ||
|
|
||
| function exitFunction(): void { | ||
| scopeStack.pop(); | ||
| } | ||
|
|
||
| return { | ||
| // Track catch clause parameters | ||
| CatchClause(node) { | ||
| const param = node.param; | ||
| if (!param || param.type !== AST_NODE_TYPES.Identifier) { | ||
| scopeStack.push({ varName: "", isSentinel: true }); | ||
| } else { | ||
| scopeStack.push({ varName: param.name, isSentinel: false }); | ||
| } | ||
| }, | ||
| "CatchClause:exit"() { | ||
| scopeStack.pop(); | ||
| }, | ||
|
|
||
| // Track .catch() callback parameters | ||
| ArrowFunctionExpression: enterFunction, | ||
| "ArrowFunctionExpression:exit": exitFunction, | ||
| FunctionExpression: enterFunction, | ||
| "FunctionExpression:exit": exitFunction, | ||
|
|
||
| // Detect JSON.stringify(caughtErrorVar, ...) | ||
| CallExpression(node) { | ||
| const caughtNames = getCaughtVarNames(); | ||
| if (caughtNames.size === 0) return; | ||
|
|
||
| const callee = node.callee; | ||
| if (callee.type !== AST_NODE_TYPES.MemberExpression) return; | ||
| if (callee.computed) return; | ||
| const obj = callee.object; | ||
|
Contributor
Author
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] The 💡 SuggestionRemove the // before: two guards; the first is redundant because getCaughtVarNames handles empty stacks
CallExpression(node) {
if (scopeStack.length === 0) return; // <- remove or document
const caughtNames = getCaughtVarNames();
if (caughtNames.size === 0) return;
...
}@copilot please address this. |
||
| const prop = callee.property; | ||
| if (obj.type !== AST_NODE_TYPES.Identifier || obj.name !== "JSON") return; | ||
| if (prop.type !== AST_NODE_TYPES.Identifier || prop.name !== "stringify") return; | ||
|
|
||
| const firstArg = node.arguments[0]; | ||
| if (!firstArg || firstArg.type !== AST_NODE_TYPES.Identifier) return; | ||
| if (!caughtNames.has(firstArg.name)) return; | ||
|
|
||
| const errorVar = firstArg.name; | ||
| context.report({ | ||
| node, | ||
| messageId: "jsonStringifyError", | ||
| data: { errorVar }, | ||
| suggest: [ | ||
| { | ||
| messageId: "useGetErrorMessage" as const, | ||
| data: { errorVar }, | ||
| fix(fixer) { | ||
| return fixer.replaceText(node, `getErrorMessage(${errorVar})`); | ||
| }, | ||
| }, | ||
|
Contributor
Author
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] The auto-fix suggestion replaces 💡 SuggestionUpdate useGetErrorMessage: "Replace with getErrorMessage({{errorVar}}) — ensure getErrorMessage is imported from error_helpers.cjs before applying.",This matches the wording used in the existing @copilot please address this. |
||
| ], | ||
| }); | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
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] Missing a test for the shadowing false-positive scenario: a non-
.catch()callback (e.g.Array.map) inside a catch block where the callback parameter has the same name as the caught error.💡 Suggested test to add to the valid set
This exercises the boundary between sentinel and non-sentinel scope frames and would fail if
getCaughtVarNamesleaks outer catch names through a sentinel entry.@copilot please address this.