Skip to content

Commit 339f495

Browse files
sonar-nigel[bot]Vibe Bot
andauthored
JS-1486 Fix S7784 false positive for JSON.stringify API serialization (#6713)
Co-authored-by: Vibe Bot <vibe-bot@sonarsource.com>
1 parent d9c4f51 commit 339f495

5 files changed

Lines changed: 204 additions & 4 deletions

File tree

packages/analysis/src/jsts/rules/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,6 @@ SonarJS uses some rules are not shipped in this ESLint plugin to avoid duplicati
568568
| S7780 | [unicorn/prefer-string-raw](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-string-raw.md) |
569569
| S7781 | [unicorn/prefer-string-replace-all](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-string-replace-all.md) |
570570
| S7783 | [unicorn/prefer-string-trim-start-end](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-string-trim-start-end.md) |
571-
| S7784 | [unicorn/prefer-structured-clone](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-structured-clone.md) |
572571
| S7786 | [unicorn/prefer-type-error](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-type-error.md) |
573572
| S7787 | [unicorn/require-module-specifiers](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/require-module-specifiers.md) |
574573

@@ -661,6 +660,7 @@ The following rules are used in SonarJS but not available in this ESLint plugin.
661660
| S7759 | [unicorn/prefer-date-now](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-date-now.md) |
662661
| S7763 | [unicorn/prefer-export-from](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-export-from.md) |
663662
| S7770 | [unicorn/prefer-native-coercion-functions](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-native-coercion-functions.md) |
663+
| S7784 | [unicorn/prefer-structured-clone](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-structured-clone.md) |
664664
| S7785 | [unicorn/prefer-top-level-await](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/HEAD/docs/rules/prefer-top-level-await.md) |
665665

666666
<!--- end decorated rules -->
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* SonarQube JavaScript Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
// https://sonarsource.github.io/rspec/#/rspec/S7784/javascript
18+
19+
import type { Rule } from 'eslint';
20+
import type estree from 'estree';
21+
import type { TSESTree } from '@typescript-eslint/utils';
22+
import { generateMeta } from '../helpers/generate-meta.js';
23+
import { interceptReport } from '../helpers/decorators/interceptor.js';
24+
import * as meta from './generated-meta.js';
25+
26+
/**
27+
* Decorates the unicorn/prefer-structured-clone rule to suppress reports when
28+
* JSON.parse(JSON.stringify(x)) appears inside a JSON.stringify() call.
29+
* In that context the intent is serialization normalization, not deep cloning.
30+
*/
31+
export function decorate(rule: Rule.RuleModule): Rule.RuleModule {
32+
return interceptReport(
33+
{
34+
...rule,
35+
meta: generateMeta(meta, rule.meta),
36+
},
37+
(context, descriptor) => {
38+
if (!('node' in descriptor) || !isInsideJsonStringify(descriptor.node)) {
39+
context.report(descriptor);
40+
}
41+
},
42+
);
43+
}
44+
45+
/**
46+
* Returns true when `node` is an argument (directly or nested in object/array/spread
47+
* expressions) of a JSON.stringify() call.
48+
*/
49+
function isInsideJsonStringify(node: estree.Node): boolean {
50+
let child = node as TSESTree.Node;
51+
let parent = child.parent;
52+
53+
while (parent) {
54+
const estreeParent = parent as unknown as estree.Node;
55+
const estreeChild = child as unknown as estree.Node;
56+
if (!isPassthroughContainer(estreeParent, estreeChild)) {
57+
return isJsonStringifyCall(estreeParent);
58+
}
59+
child = parent;
60+
parent = parent.parent;
61+
}
62+
return false;
63+
}
64+
65+
/** Transparent containers that can appear between the reported node and JSON.stringify(). */
66+
function isPassthroughContainer(node: estree.Node, child: estree.Node): boolean {
67+
switch (node.type) {
68+
case 'ObjectExpression':
69+
case 'ArrayExpression':
70+
case 'SpreadElement':
71+
return true;
72+
case 'Property':
73+
// Only passthrough when child is the value side, not the key side.
74+
return (node as estree.Property).value === child;
75+
default:
76+
return false;
77+
}
78+
}
79+
80+
/** Returns true when `node` is a JSON.stringify(...) call expression. */
81+
function isJsonStringifyCall(node: estree.Node): boolean {
82+
if (node.type !== 'CallExpression') {
83+
return false;
84+
}
85+
const { callee } = node as estree.CallExpression;
86+
return (
87+
callee.type === 'MemberExpression' &&
88+
!callee.computed &&
89+
callee.object.type === 'Identifier' &&
90+
callee.object.name === 'JSON' &&
91+
callee.property.type === 'Identifier' &&
92+
callee.property.name === 'stringify'
93+
);
94+
}

packages/analysis/src/jsts/rules/S7784/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@
1515
* along with this program; if not, see https://sonarsource.com/license/ssal/
1616
*/
1717
import { rules } from '../external/unicorn.js';
18-
export const rule = rules['prefer-structured-clone'];
18+
import { decorate } from './decorator.js';
19+
20+
export const rule = decorate(rules['prefer-structured-clone']);

packages/analysis/src/jsts/rules/S7784/meta.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
* along with this program; if not, see https://sonarsource.com/license/ssal/
1616
*/
1717
// https://sonarsource.github.io/rspec/#/rspec/S7784/javascript
18-
export const implementation = 'external';
18+
export const implementation = 'decorated';
1919
export const eslintId = 'prefer-structured-clone';
20-
export const externalPlugin = 'unicorn';
20+
export const externalRules = [
21+
{ externalPlugin: 'unicorn', externalRule: 'prefer-structured-clone' },
22+
];
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* SonarQube JavaScript Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
import { rule } from './index.js';
18+
import { rules } from '../external/unicorn.js';
19+
import { DefaultParserRuleTester } from '../../../../tests/jsts/tools/testers/rule-tester.js';
20+
import { describe, it } from 'node:test';
21+
22+
const upstreamRule = rules['prefer-structured-clone'];
23+
24+
// Sentinel: verify that the upstream ESLint rule still raises on the patterns our decorator fixes.
25+
// If this test starts failing (i.e., the upstream rule no longer reports these patterns),
26+
// it signals that the decorator can be safely removed.
27+
describe('S7784 upstream sentinel', () => {
28+
it('upstream prefer-structured-clone raises on JSON.stringify-nested patterns that decorator suppresses', () => {
29+
const ruleTester = new DefaultParserRuleTester();
30+
ruleTester.run('prefer-structured-clone', upstreamRule, {
31+
valid: [],
32+
invalid: [
33+
// direct argument to JSON.stringify() — suppressed by decorator, raised by upstream
34+
{ code: `const s = JSON.stringify(JSON.parse(JSON.stringify(data)));`, errors: 1 },
35+
// nested in object literal inside JSON.stringify() — suppressed by decorator, raised by upstream
36+
{
37+
code: `const s = JSON.stringify({ items: JSON.parse(JSON.stringify(self.items)) });`,
38+
errors: 1,
39+
},
40+
// nested in array literal inside JSON.stringify() — suppressed by decorator, raised by upstream
41+
{ code: `const s = JSON.stringify([JSON.parse(JSON.stringify(items[0]))]);`, errors: 1 },
42+
// spread inside JSON.stringify() — suppressed by decorator, raised by upstream
43+
{ code: `const s = JSON.stringify({ ...JSON.parse(JSON.stringify(config)) });`, errors: 1 },
44+
// computed property key, value position inside JSON.stringify() — suppressed by decorator, raised by upstream
45+
{
46+
code: `const s = JSON.stringify({ [key]: JSON.parse(JSON.stringify(model)) });`,
47+
errors: 1,
48+
},
49+
],
50+
});
51+
});
52+
});
53+
54+
describe('S7784', () => {
55+
it('S7784', () => {
56+
const ruleTester = new DefaultParserRuleTester();
57+
ruleTester.run('prefer-structured-clone', rule, {
58+
valid: [
59+
{
60+
// direct argument to JSON.stringify(): serialization normalization, not deep clone
61+
code: `const s = JSON.stringify(JSON.parse(JSON.stringify(data)));`,
62+
},
63+
{
64+
// nested in object literal inside JSON.stringify() for API request body
65+
code: `
66+
fetch('/api/save', {
67+
method: 'POST',
68+
body: JSON.stringify({
69+
items: JSON.parse(JSON.stringify(self.items)),
70+
blocks: JSON.parse(JSON.stringify(self.blocks)),
71+
}),
72+
});
73+
`,
74+
},
75+
{
76+
// nested in array literal inside JSON.stringify()
77+
code: `const s = JSON.stringify([JSON.parse(JSON.stringify(items[0]))]);`,
78+
},
79+
{
80+
// spread inside JSON.stringify()
81+
code: `const s = JSON.stringify({ ...JSON.parse(JSON.stringify(config)) });`,
82+
},
83+
{
84+
// computed property key, value position inside JSON.stringify()
85+
code: `const s = JSON.stringify({ [key]: JSON.parse(JSON.stringify(model)) });`,
86+
},
87+
],
88+
invalid: [
89+
{
90+
// genuine deep clone: not inside JSON.stringify()
91+
code: `const clone = JSON.parse(JSON.stringify(original));`,
92+
errors: 1,
93+
},
94+
{
95+
// deep clone in object literal not passed to JSON.stringify()
96+
code: `const obj = { copy: JSON.parse(JSON.stringify(state)) };`,
97+
errors: 1,
98+
},
99+
],
100+
});
101+
});
102+
});

0 commit comments

Comments
 (0)