@commercetools-frontend/eslint-config-mc-app 27.2.0 → 27.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/index.js +8 -0
- package/package.json +2 -2
- package/rules/no-direct-currency-formatting.js +413 -0
- package/rules/no-direct-currency-formatting.spec.js +361 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# @commercetools-frontend/eslint-config-mc-app
|
|
2
2
|
|
|
3
|
+
## 27.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#3961](https://github.com/commercetools/merchant-center-application-kit/pull/3961) [`8efed36`](https://github.com/commercetools/merchant-center-application-kit/commit/8efed3678b99fbf2b23ea7a3d2a62d977a7d51b0) Thanks [@nima-ct](https://github.com/nima-ct)! - Add bundled `no-direct-currency-formatting` rule via the `@commercetools-frontend/eslint-config-mc-app/rules` inline plugin.
|
|
8
|
+
|
|
9
|
+
This rule disallows direct currency formatting through `intl.formatNumber`, `intl.formatCurrency`, `new Intl.NumberFormat` when using a `currency` option or `style: 'currency'`, and `<FormattedNumber />` from `react-intl`.
|
|
10
|
+
|
|
11
|
+
Use a shared currency formatting wrapper instead, and allowlist that wrapper path if needed.
|
|
12
|
+
|
|
13
|
+
## How to update
|
|
14
|
+
|
|
15
|
+
Enable the bundled rule in your project config:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
// eslint.config.js
|
|
19
|
+
import mcAppConfig from '@commercetools-frontend/eslint-config-mc-app';
|
|
20
|
+
|
|
21
|
+
export default [
|
|
22
|
+
...mcAppConfig,
|
|
23
|
+
{
|
|
24
|
+
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
25
|
+
rules: {
|
|
26
|
+
'@commercetools-frontend/eslint-config-mc-app/rules/no-direct-currency-formatting':
|
|
27
|
+
[
|
|
28
|
+
'error',
|
|
29
|
+
{
|
|
30
|
+
allowedWrapperPaths: [
|
|
31
|
+
'src/utils/money.js', // path to your shared wrapper implementation
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If you need to customize the wrapper allowlist, pass `allowedWrapperPaths` as shown above.
|
|
41
|
+
|
|
42
|
+
## Why
|
|
43
|
+
|
|
44
|
+
Direct currency formatting is hard to standardize across applications and can drift in behavior over time.
|
|
45
|
+
Enforcing a shared wrapper keeps formatting logic consistent, testable, and centrally maintainable.
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- Updated dependencies []:
|
|
50
|
+
- @commercetools-frontend/babel-preset-mc-app@27.3.0
|
|
51
|
+
|
|
3
52
|
## 27.2.0
|
|
4
53
|
|
|
5
54
|
### Patch Changes
|
package/index.js
CHANGED
|
@@ -90,6 +90,9 @@ const { statusCode, allSupportedExtensions } = require('./helpers/eslint');
|
|
|
90
90
|
const hasJsxRuntime = require('./helpers/has-jsx-runtime');
|
|
91
91
|
const { craRules } = require('./helpers/rules-presets');
|
|
92
92
|
|
|
93
|
+
// Bundled custom rules
|
|
94
|
+
const noDirectCurrencyFormattingRule = require('./rules/no-direct-currency-formatting');
|
|
95
|
+
|
|
93
96
|
/**
|
|
94
97
|
* ESLint flat config format for @commercetools-frontend/eslint-config-mc-app
|
|
95
98
|
* @type {import("eslint").Linter.FlatConfig[]}
|
|
@@ -124,6 +127,11 @@ module.exports = [
|
|
|
124
127
|
'jsx-a11y': jsxA11yPlugin,
|
|
125
128
|
prettier: prettierPlugin,
|
|
126
129
|
cypress: cypressPlugin,
|
|
130
|
+
'@commercetools-frontend/eslint-config-mc-app/rules': {
|
|
131
|
+
rules: {
|
|
132
|
+
'no-direct-currency-formatting': noDirectCurrencyFormattingRule,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
127
135
|
},
|
|
128
136
|
settings: {
|
|
129
137
|
'import/resolver': {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercetools-frontend/eslint-config-mc-app",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.3.0",
|
|
4
4
|
"description": "ESLint config used by Merchant Center customizations.",
|
|
5
5
|
"bugs": "https://github.com/commercetools/merchant-center-application-kit/issues",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@babel/core": "^7.22.17",
|
|
25
25
|
"@babel/eslint-parser": "^7.22.15",
|
|
26
|
-
"@commercetools-frontend/babel-preset-mc-app": "^27.
|
|
26
|
+
"@commercetools-frontend/babel-preset-mc-app": "^27.3.0",
|
|
27
27
|
"@typescript-eslint/eslint-plugin": "^8.55.0",
|
|
28
28
|
"@typescript-eslint/parser": "^8.55.0",
|
|
29
29
|
"confusing-browser-globals": "^1.0.11",
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const unwrapExpression = (node) => {
|
|
4
|
+
if (!node) return node;
|
|
5
|
+
|
|
6
|
+
if (node.type === 'TSAsExpression' || node.type === 'TypeCastExpression') {
|
|
7
|
+
return unwrapExpression(node.expression);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return node;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const isStringLiteralCurrency = (node) =>
|
|
14
|
+
node &&
|
|
15
|
+
node.type === 'Literal' &&
|
|
16
|
+
typeof node.value === 'string' &&
|
|
17
|
+
node.value === 'currency';
|
|
18
|
+
|
|
19
|
+
const getPropertyKeyName = (propertyNode) => {
|
|
20
|
+
if (!propertyNode || propertyNode.type !== 'Property') return undefined;
|
|
21
|
+
|
|
22
|
+
if (!propertyNode.computed && propertyNode.key.type === 'Identifier')
|
|
23
|
+
return propertyNode.key.name;
|
|
24
|
+
if (propertyNode.key.type === 'Literal') return propertyNode.key.value;
|
|
25
|
+
|
|
26
|
+
return undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const findVariableByName = (scope, name) => {
|
|
30
|
+
let currentScope = scope;
|
|
31
|
+
|
|
32
|
+
while (currentScope) {
|
|
33
|
+
const variable = currentScope.variables.find(
|
|
34
|
+
(entry) => entry.name === name
|
|
35
|
+
);
|
|
36
|
+
if (variable) return variable;
|
|
37
|
+
currentScope = currentScope.upper;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const resolveNodeFromIdentifier = (
|
|
44
|
+
node,
|
|
45
|
+
scope,
|
|
46
|
+
seenIdentifiers = new Set()
|
|
47
|
+
) => {
|
|
48
|
+
const unwrappedNode = unwrapExpression(node);
|
|
49
|
+
if (!unwrappedNode || unwrappedNode.type !== 'Identifier')
|
|
50
|
+
return unwrappedNode;
|
|
51
|
+
|
|
52
|
+
if (seenIdentifiers.has(unwrappedNode.name)) return unwrappedNode;
|
|
53
|
+
seenIdentifiers.add(unwrappedNode.name);
|
|
54
|
+
|
|
55
|
+
const variable = findVariableByName(scope, unwrappedNode.name);
|
|
56
|
+
if (!variable || variable.defs.length === 0) return unwrappedNode;
|
|
57
|
+
|
|
58
|
+
const definitionNode = variable.defs[0].node;
|
|
59
|
+
if (
|
|
60
|
+
definitionNode &&
|
|
61
|
+
definitionNode.type === 'VariableDeclarator' &&
|
|
62
|
+
definitionNode.init
|
|
63
|
+
) {
|
|
64
|
+
return resolveNodeFromIdentifier(
|
|
65
|
+
definitionNode.init,
|
|
66
|
+
scope,
|
|
67
|
+
seenIdentifiers
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return unwrappedNode;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Detects any `currency` property in an options object, static or dynamic.
|
|
75
|
+
// Intl.NumberFormat validates the `currency` option regardless of `style`, so
|
|
76
|
+
// any direct usage — even with a hardcoded value — must go through the wrapper.
|
|
77
|
+
const hasCurrencyOption = (node, scope, seenObjectNodes = new Set()) => {
|
|
78
|
+
const resolvedNode = resolveNodeFromIdentifier(node, scope);
|
|
79
|
+
if (!resolvedNode || resolvedNode.type !== 'ObjectExpression') return false;
|
|
80
|
+
|
|
81
|
+
if (seenObjectNodes.has(resolvedNode)) return false;
|
|
82
|
+
seenObjectNodes.add(resolvedNode);
|
|
83
|
+
|
|
84
|
+
return resolvedNode.properties.some((propertyNode) => {
|
|
85
|
+
if (propertyNode.type === 'SpreadElement') {
|
|
86
|
+
return hasCurrencyOption(propertyNode.argument, scope, seenObjectNodes);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return getPropertyKeyName(propertyNode) === 'currency';
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Detects options that resolve to `style: 'currency'`, even via identifiers/spreads.
|
|
94
|
+
const isCurrencyStyleOption = (node, scope, seenObjectNodes = new Set()) => {
|
|
95
|
+
const resolvedNode = resolveNodeFromIdentifier(node, scope);
|
|
96
|
+
if (!resolvedNode || resolvedNode.type !== 'ObjectExpression') return false;
|
|
97
|
+
|
|
98
|
+
if (seenObjectNodes.has(resolvedNode)) return false;
|
|
99
|
+
seenObjectNodes.add(resolvedNode);
|
|
100
|
+
|
|
101
|
+
return resolvedNode.properties.some((propertyNode) => {
|
|
102
|
+
if (propertyNode.type === 'SpreadElement') {
|
|
103
|
+
return isCurrencyStyleOption(
|
|
104
|
+
propertyNode.argument,
|
|
105
|
+
scope,
|
|
106
|
+
seenObjectNodes
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (getPropertyKeyName(propertyNode) !== 'style') {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const resolvedStyleValue = resolveNodeFromIdentifier(
|
|
115
|
+
propertyNode.value,
|
|
116
|
+
scope
|
|
117
|
+
);
|
|
118
|
+
return isStringLiteralCurrency(unwrapExpression(resolvedStyleValue));
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const getJsxAttributeName = (attributeNode) => {
|
|
123
|
+
if (
|
|
124
|
+
!attributeNode ||
|
|
125
|
+
attributeNode.type !== 'JSXAttribute' ||
|
|
126
|
+
!attributeNode.name ||
|
|
127
|
+
attributeNode.name.type !== 'JSXIdentifier'
|
|
128
|
+
) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return attributeNode.name.name;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const getJsxAttributeValueNode = (attributeNode) => {
|
|
136
|
+
if (!attributeNode || attributeNode.type !== 'JSXAttribute') return undefined;
|
|
137
|
+
if (!attributeNode.value) return undefined;
|
|
138
|
+
|
|
139
|
+
if (attributeNode.value.type === 'Literal') return attributeNode.value;
|
|
140
|
+
if (attributeNode.value.type !== 'JSXExpressionContainer') return undefined;
|
|
141
|
+
|
|
142
|
+
return unwrapExpression(attributeNode.value.expression);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const isCurrencyFormattedNumberElement = (node, scope) => {
|
|
146
|
+
if (!node || !node.attributes) return false;
|
|
147
|
+
|
|
148
|
+
return node.attributes.some((attributeNode) => {
|
|
149
|
+
if (attributeNode.type === 'JSXSpreadAttribute') {
|
|
150
|
+
return isCurrencyStyleOption(attributeNode.argument, scope);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (getJsxAttributeName(attributeNode) !== 'style') return false;
|
|
154
|
+
const resolvedAttributeValue = resolveNodeFromIdentifier(
|
|
155
|
+
getJsxAttributeValueNode(attributeNode),
|
|
156
|
+
scope
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return isStringLiteralCurrency(unwrapExpression(resolvedAttributeValue));
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Rule allowlist for wrapper files that are expected to format currencies directly.
|
|
164
|
+
const isPathAllowed = (filename, allowedWrapperPaths) => {
|
|
165
|
+
const normalizePathSeparators = (value) => value.replace(/\\/g, '/');
|
|
166
|
+
const normalizedFilename = normalizePathSeparators(
|
|
167
|
+
filename.split(path.sep).join('/')
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return allowedWrapperPaths.some((allowedPath) => {
|
|
171
|
+
const normalizedAllowedPath = normalizePathSeparators(
|
|
172
|
+
allowedPath.split(path.sep).join('/')
|
|
173
|
+
);
|
|
174
|
+
return normalizedFilename.endsWith(normalizedAllowedPath);
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** ESLint 9+: use SourceCode#getScope(node); legacy context.getScope() was removed. */
|
|
179
|
+
function getScopeForNode(context, node) {
|
|
180
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
181
|
+
return sourceCode.getScope(node);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
186
|
+
*/
|
|
187
|
+
module.exports = {
|
|
188
|
+
meta: {
|
|
189
|
+
type: 'problem',
|
|
190
|
+
docs: {
|
|
191
|
+
description:
|
|
192
|
+
'Disallow direct currency formatting and enforce shared wrapper usage.',
|
|
193
|
+
category: 'Best Practices',
|
|
194
|
+
recommended: false,
|
|
195
|
+
},
|
|
196
|
+
schema: [
|
|
197
|
+
{
|
|
198
|
+
type: 'object',
|
|
199
|
+
properties: {
|
|
200
|
+
allowedWrapperPaths: {
|
|
201
|
+
type: 'array',
|
|
202
|
+
items: {
|
|
203
|
+
type: 'string',
|
|
204
|
+
},
|
|
205
|
+
default: [],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
additionalProperties: false,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
messages: {
|
|
212
|
+
noDirectCurrencyFormatting:
|
|
213
|
+
'Use the shared currency formatting wrapper instead of direct currency formatting.',
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
create: function (context) {
|
|
217
|
+
const options = context.options[0] || {};
|
|
218
|
+
const allowedWrapperPaths = options.allowedWrapperPaths || [];
|
|
219
|
+
const filename = context.getFilename();
|
|
220
|
+
|
|
221
|
+
// Skip checks for explicitly allowed wrapper implementations.
|
|
222
|
+
if (isPathAllowed(filename, allowedWrapperPaths)) return {};
|
|
223
|
+
|
|
224
|
+
// Track local names of formatting functions from destructuring or aliasing.
|
|
225
|
+
// Covers: const { formatNumber } = useIntl()
|
|
226
|
+
// const { formatCurrency } = intl
|
|
227
|
+
// function Foo({ formatNumber }) { ... }
|
|
228
|
+
// const fmt = intl.formatNumber
|
|
229
|
+
const destructuredFormattingFunctionNames = new Set();
|
|
230
|
+
const formattingFunctionNames = new Set(['formatNumber', 'formatCurrency']);
|
|
231
|
+
const formattedNumberComponentNames = new Set();
|
|
232
|
+
const reactIntlNamespaceImports = new Set();
|
|
233
|
+
|
|
234
|
+
const collectFormatNumberFromObjectPattern = (objectPatternNode) => {
|
|
235
|
+
if (!objectPatternNode || objectPatternNode.type !== 'ObjectPattern')
|
|
236
|
+
return;
|
|
237
|
+
|
|
238
|
+
objectPatternNode.properties.forEach((prop) => {
|
|
239
|
+
if (prop.type !== 'Property') return;
|
|
240
|
+
const propertyKeyName = getPropertyKeyName(prop);
|
|
241
|
+
if (!formattingFunctionNames.has(propertyKeyName)) return;
|
|
242
|
+
if (prop.value.type !== 'Identifier') return;
|
|
243
|
+
destructuredFormattingFunctionNames.add(prop.value.name);
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const isFormatNumberMemberExpression = (node) => {
|
|
248
|
+
if (!node || node.type !== 'MemberExpression') return false;
|
|
249
|
+
|
|
250
|
+
if (!node.computed)
|
|
251
|
+
return (
|
|
252
|
+
node.property.type === 'Identifier' &&
|
|
253
|
+
formattingFunctionNames.has(node.property.name)
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
node.property.type === 'Literal' &&
|
|
258
|
+
formattingFunctionNames.has(node.property.value)
|
|
259
|
+
);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
ImportDeclaration(node) {
|
|
264
|
+
if (!node.source || node.source.value !== 'react-intl') return;
|
|
265
|
+
|
|
266
|
+
node.specifiers.forEach((specifier) => {
|
|
267
|
+
if (specifier.type === 'ImportSpecifier') {
|
|
268
|
+
if (
|
|
269
|
+
specifier.imported &&
|
|
270
|
+
specifier.imported.type === 'Identifier' &&
|
|
271
|
+
specifier.imported.name === 'FormattedNumber'
|
|
272
|
+
) {
|
|
273
|
+
formattedNumberComponentNames.add(specifier.local.name);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (
|
|
278
|
+
specifier.type === 'ImportNamespaceSpecifier' &&
|
|
279
|
+
specifier.local &&
|
|
280
|
+
specifier.local.type === 'Identifier'
|
|
281
|
+
) {
|
|
282
|
+
reactIntlNamespaceImports.add(specifier.local.name);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
// function Foo({ formatNumber }) { ... }
|
|
288
|
+
FunctionDeclaration(node) {
|
|
289
|
+
node.params.forEach(collectFormatNumberFromObjectPattern);
|
|
290
|
+
},
|
|
291
|
+
FunctionExpression(node) {
|
|
292
|
+
node.params.forEach(collectFormatNumberFromObjectPattern);
|
|
293
|
+
},
|
|
294
|
+
ArrowFunctionExpression(node) {
|
|
295
|
+
node.params.forEach(collectFormatNumberFromObjectPattern);
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
// const { formatNumber } = useIntl() / const { formatNumber } = intl
|
|
299
|
+
// const fmt = intl.formatNumber
|
|
300
|
+
VariableDeclarator(node) {
|
|
301
|
+
if (node.id && node.id.type === 'ObjectPattern' && node.init) {
|
|
302
|
+
collectFormatNumberFromObjectPattern(node.id);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// const { FormattedNumber } = require('react-intl')
|
|
306
|
+
if (
|
|
307
|
+
node.id &&
|
|
308
|
+
node.id.type === 'ObjectPattern' &&
|
|
309
|
+
node.init &&
|
|
310
|
+
node.init.type === 'CallExpression' &&
|
|
311
|
+
node.init.callee.type === 'Identifier' &&
|
|
312
|
+
node.init.callee.name === 'require' &&
|
|
313
|
+
node.init.arguments &&
|
|
314
|
+
node.init.arguments[0] &&
|
|
315
|
+
node.init.arguments[0].type === 'Literal' &&
|
|
316
|
+
node.init.arguments[0].value === 'react-intl'
|
|
317
|
+
) {
|
|
318
|
+
node.id.properties.forEach((propertyNode) => {
|
|
319
|
+
if (propertyNode.type !== 'Property') return;
|
|
320
|
+
if (getPropertyKeyName(propertyNode) !== 'FormattedNumber') return;
|
|
321
|
+
if (propertyNode.value.type !== 'Identifier') return;
|
|
322
|
+
formattedNumberComponentNames.add(propertyNode.value.name);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (
|
|
327
|
+
node.id &&
|
|
328
|
+
node.id.type === 'Identifier' &&
|
|
329
|
+
isFormatNumberMemberExpression(unwrapExpression(node.init))
|
|
330
|
+
) {
|
|
331
|
+
destructuredFormattingFunctionNames.add(node.id.name);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
JSXOpeningElement(node) {
|
|
336
|
+
if (!node.name) return;
|
|
337
|
+
const scope = getScopeForNode(context, node);
|
|
338
|
+
|
|
339
|
+
// <FormattedNumber .../> with named or aliased import.
|
|
340
|
+
if (
|
|
341
|
+
node.name.type === 'JSXIdentifier' &&
|
|
342
|
+
formattedNumberComponentNames.has(node.name.name) &&
|
|
343
|
+
isCurrencyFormattedNumberElement(node, scope)
|
|
344
|
+
) {
|
|
345
|
+
context.report({ node, messageId: 'noDirectCurrencyFormatting' });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// <ReactIntl.FormattedNumber .../> with namespace import.
|
|
350
|
+
if (
|
|
351
|
+
node.name.type === 'JSXMemberExpression' &&
|
|
352
|
+
node.name.object &&
|
|
353
|
+
node.name.object.type === 'JSXIdentifier' &&
|
|
354
|
+
reactIntlNamespaceImports.has(node.name.object.name) &&
|
|
355
|
+
node.name.property &&
|
|
356
|
+
node.name.property.type === 'JSXIdentifier' &&
|
|
357
|
+
node.name.property.name === 'FormattedNumber' &&
|
|
358
|
+
isCurrencyFormattedNumberElement(node, scope)
|
|
359
|
+
) {
|
|
360
|
+
context.report({ node, messageId: 'noDirectCurrencyFormatting' });
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
CallExpression(node) {
|
|
365
|
+
const scope = getScopeForNode(context, node);
|
|
366
|
+
|
|
367
|
+
const isCurrencyFormattingArg = (arg) =>
|
|
368
|
+
isCurrencyStyleOption(arg, scope) || hasCurrencyOption(arg, scope);
|
|
369
|
+
const hasCurrencyFormattingArgs =
|
|
370
|
+
isCurrencyFormattingArg(node.arguments[1]) ||
|
|
371
|
+
isCurrencyFormattingArg(node.arguments[0]);
|
|
372
|
+
|
|
373
|
+
// Disallow member-expression calls: intl.formatNumber(..., { style: 'currency' })
|
|
374
|
+
// or intl.formatNumber(..., { currency: dynamicCode })
|
|
375
|
+
if (
|
|
376
|
+
node.callee.type === 'MemberExpression' &&
|
|
377
|
+
isFormatNumberMemberExpression(node.callee) &&
|
|
378
|
+
hasCurrencyFormattingArgs
|
|
379
|
+
) {
|
|
380
|
+
context.report({ node, messageId: 'noDirectCurrencyFormatting' });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Disallow destructured calls: const { formatNumber } = useIntl(); formatNumber(...)
|
|
385
|
+
if (
|
|
386
|
+
node.callee.type === 'Identifier' &&
|
|
387
|
+
destructuredFormattingFunctionNames.has(node.callee.name) &&
|
|
388
|
+
hasCurrencyFormattingArgs
|
|
389
|
+
) {
|
|
390
|
+
context.report({ node, messageId: 'noDirectCurrencyFormatting' });
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
NewExpression(node) {
|
|
395
|
+
const scope = getScopeForNode(context, node);
|
|
396
|
+
// Disallow direct native Intl currency formatting constructors,
|
|
397
|
+
// with style:'currency' or a dynamic currency option.
|
|
398
|
+
if (
|
|
399
|
+
node.callee.type === 'MemberExpression' &&
|
|
400
|
+
!node.callee.computed &&
|
|
401
|
+
node.callee.object.type === 'Identifier' &&
|
|
402
|
+
node.callee.object.name === 'Intl' &&
|
|
403
|
+
node.callee.property.type === 'Identifier' &&
|
|
404
|
+
node.callee.property.name === 'NumberFormat' &&
|
|
405
|
+
(isCurrencyStyleOption(node.arguments[1], scope) ||
|
|
406
|
+
hasCurrencyOption(node.arguments[1], scope))
|
|
407
|
+
) {
|
|
408
|
+
context.report({ node, messageId: 'noDirectCurrencyFormatting' });
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
};
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
const { RuleTester } = require('eslint');
|
|
5
|
+
const rule = require('./no-direct-currency-formatting');
|
|
6
|
+
|
|
7
|
+
const ruleTester = new RuleTester({
|
|
8
|
+
languageOptions: {
|
|
9
|
+
ecmaVersion: 2022,
|
|
10
|
+
sourceType: 'module',
|
|
11
|
+
parserOptions: {
|
|
12
|
+
ecmaFeatures: { jsx: true },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const error = { messageId: 'noDirectCurrencyFormatting' };
|
|
18
|
+
|
|
19
|
+
ruleTester.run('no-direct-currency-formatting', rule, {
|
|
20
|
+
valid: [
|
|
21
|
+
// ─── intl.formatNumber — non-currency usage ───
|
|
22
|
+
{
|
|
23
|
+
code: `intl.formatNumber(42, { style: 'decimal' })`,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
code: `intl.formatNumber(0.5, { style: 'percent' })`,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
code: `intl.formatNumber(42)`,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
code: `intl.formatNumber(42, {})`,
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// ─── destructured formatNumber — non-currency ───
|
|
36
|
+
{
|
|
37
|
+
code: `
|
|
38
|
+
const { formatNumber } = useIntl();
|
|
39
|
+
formatNumber(42, { style: 'decimal' });
|
|
40
|
+
`,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
code: `
|
|
44
|
+
const { formatNumber } = useIntl();
|
|
45
|
+
formatNumber(42);
|
|
46
|
+
`,
|
|
47
|
+
},
|
|
48
|
+
// ─── Intl.NumberFormat — non-currency ───
|
|
49
|
+
{
|
|
50
|
+
code: `new Intl.NumberFormat('en', { style: 'decimal' })`,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
code: `new Intl.NumberFormat('en')`,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
code: `new Intl.NumberFormat('en', { minimumFractionDigits: 2 })`,
|
|
57
|
+
},
|
|
58
|
+
// ─── FormattedNumber — non-currency usage ───
|
|
59
|
+
// BUG: These currently fail because the rule flags ALL <FormattedNumber />
|
|
60
|
+
// usage from react-intl, regardless of whether currency props are present.
|
|
61
|
+
// Non-currency usage (percent, decimal, no style) should be allowed.
|
|
62
|
+
// Once the rule's JSXOpeningElement handler is fixed to inspect props,
|
|
63
|
+
// these tests will pass.
|
|
64
|
+
{
|
|
65
|
+
code: `
|
|
66
|
+
import { FormattedNumber } from 'react-intl';
|
|
67
|
+
const x = <FormattedNumber style="percent" value={0.5} />;
|
|
68
|
+
`,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
code: `
|
|
72
|
+
import { FormattedNumber } from 'react-intl';
|
|
73
|
+
const x = <FormattedNumber style="decimal" value={42} />;
|
|
74
|
+
`,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
code: `
|
|
78
|
+
import { FormattedNumber } from 'react-intl';
|
|
79
|
+
const x = <FormattedNumber value={42} />;
|
|
80
|
+
`,
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// ─── Unrelated components with "FormattedNumber" name (not from react-intl) ───
|
|
84
|
+
{
|
|
85
|
+
code: `const x = <FormattedNumber value={42} />;`,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
code: `
|
|
89
|
+
import { FormattedNumber } from './my-components';
|
|
90
|
+
const x = <FormattedNumber value={42} />;
|
|
91
|
+
`,
|
|
92
|
+
},
|
|
93
|
+
// ─── Unrelated function names ───
|
|
94
|
+
{
|
|
95
|
+
code: `intl.formatDate(new Date())`,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
code: `intl.formatMessage({ id: 'hello' })`,
|
|
99
|
+
},
|
|
100
|
+
// ─── Allowlisted wrapper path ───
|
|
101
|
+
{
|
|
102
|
+
code: `intl.formatNumber(42, { style: 'currency', currency: 'EUR' })`,
|
|
103
|
+
options: [{ allowedWrapperPaths: ['src/utils/money.js'] }],
|
|
104
|
+
filename: '/project/src/utils/money.js',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
code: `
|
|
108
|
+
import { FormattedNumber } from 'react-intl';
|
|
109
|
+
const x = <FormattedNumber style="currency" currency="EUR" />;
|
|
110
|
+
`,
|
|
111
|
+
options: [{ allowedWrapperPaths: ['src/utils/money.js'] }],
|
|
112
|
+
filename: '/project/src/utils/money.js',
|
|
113
|
+
},
|
|
114
|
+
// ─── Allowlisted wrapper path — Windows-style separators ───
|
|
115
|
+
{
|
|
116
|
+
code: `intl.formatNumber(42, { style: 'currency', currency: 'EUR' })`,
|
|
117
|
+
options: [{ allowedWrapperPaths: ['src/utils/money.js'] }],
|
|
118
|
+
filename: 'C:\\project\\src\\utils\\money.js',
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// ─── formatNumber on unrelated objects (non-currency) ───
|
|
122
|
+
{
|
|
123
|
+
code: `myLib.formatNumber(42, { style: 'decimal' })`,
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// ─── FormattedNumber — non-currency via namespace import ───
|
|
127
|
+
// BUG: Same false-positive as named import — namespace access is also
|
|
128
|
+
// flagged unconditionally without inspecting props.
|
|
129
|
+
{
|
|
130
|
+
code: `
|
|
131
|
+
import * as ReactIntl from 'react-intl';
|
|
132
|
+
const x = <ReactIntl.FormattedNumber style="percent" value={0.5} />;
|
|
133
|
+
`,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
code: `
|
|
137
|
+
import * as ReactIntl from 'react-intl';
|
|
138
|
+
const x = <ReactIntl.FormattedNumber value={42} />;
|
|
139
|
+
`,
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
|
|
143
|
+
invalid: [
|
|
144
|
+
// ═══════════════════════════════════════════════════
|
|
145
|
+
// intl.formatNumber with currency
|
|
146
|
+
// ═══════════════════════════════════════════════════
|
|
147
|
+
{
|
|
148
|
+
name: 'intl.formatNumber with style: currency',
|
|
149
|
+
code: `intl.formatNumber(42, { style: 'currency', currency: 'EUR' })`,
|
|
150
|
+
errors: [error],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'intl.formatNumber with currency option only (no style)',
|
|
154
|
+
code: `intl.formatNumber(42, { currency: 'EUR' })`,
|
|
155
|
+
errors: [error],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'intl.formatCurrency call',
|
|
159
|
+
code: `intl.formatCurrency(42, { currency: 'EUR' })`,
|
|
160
|
+
errors: [error],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'intl["formatNumber"] computed member access',
|
|
164
|
+
code: `intl['formatNumber'](42, { style: 'currency', currency: 'EUR' })`,
|
|
165
|
+
errors: [error],
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// ═══════════════════════════════════════════════════
|
|
169
|
+
// Destructured formatNumber
|
|
170
|
+
// ═══════════════════════════════════════════════════
|
|
171
|
+
{
|
|
172
|
+
name: 'destructured formatNumber from useIntl()',
|
|
173
|
+
code: `
|
|
174
|
+
const { formatNumber } = useIntl();
|
|
175
|
+
formatNumber(42, { style: 'currency', currency: 'EUR' });
|
|
176
|
+
`,
|
|
177
|
+
errors: [error],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'destructured formatNumber with currency option only',
|
|
181
|
+
code: `
|
|
182
|
+
const { formatNumber } = useIntl();
|
|
183
|
+
formatNumber(42, { currency: 'USD' });
|
|
184
|
+
`,
|
|
185
|
+
errors: [error],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'aliased destructured formatNumber',
|
|
189
|
+
code: `
|
|
190
|
+
const { formatNumber: fmt } = useIntl();
|
|
191
|
+
fmt(42, { style: 'currency', currency: 'EUR' });
|
|
192
|
+
`,
|
|
193
|
+
errors: [error],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'formatNumber from function declaration parameter',
|
|
197
|
+
code: `
|
|
198
|
+
function Foo({ formatNumber }) {
|
|
199
|
+
return formatNumber(42, { style: 'currency', currency: 'EUR' });
|
|
200
|
+
}
|
|
201
|
+
`,
|
|
202
|
+
errors: [error],
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'formatNumber from function expression parameter',
|
|
206
|
+
code: `
|
|
207
|
+
const Foo = function({ formatNumber }) {
|
|
208
|
+
return formatNumber(42, { style: 'currency', currency: 'EUR' });
|
|
209
|
+
}
|
|
210
|
+
`,
|
|
211
|
+
errors: [error],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'formatNumber from arrow function parameter',
|
|
215
|
+
code: `
|
|
216
|
+
const Foo = ({ formatNumber }) => formatNumber(42, { currency: 'EUR' });
|
|
217
|
+
`,
|
|
218
|
+
errors: [error],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: 'destructured formatCurrency from useIntl()',
|
|
222
|
+
code: `
|
|
223
|
+
const { formatCurrency } = useIntl();
|
|
224
|
+
formatCurrency(42, { currency: 'EUR' });
|
|
225
|
+
`,
|
|
226
|
+
errors: [error],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'assigned from member expression: const fmt = intl.formatNumber',
|
|
230
|
+
code: `
|
|
231
|
+
const fmt = intl.formatNumber;
|
|
232
|
+
fmt(42, { style: 'currency', currency: 'EUR' });
|
|
233
|
+
`,
|
|
234
|
+
errors: [error],
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
// ═══════════════════════════════════════════════════
|
|
238
|
+
// Intl.NumberFormat
|
|
239
|
+
// ═══════════════════════════════════════════════════
|
|
240
|
+
{
|
|
241
|
+
name: 'new Intl.NumberFormat with style: currency',
|
|
242
|
+
code: `new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' })`,
|
|
243
|
+
errors: [error],
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: 'new Intl.NumberFormat with currency option only',
|
|
247
|
+
code: `new Intl.NumberFormat('en', { currency: 'EUR' })`,
|
|
248
|
+
errors: [error],
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
// ═══════════════════════════════════════════════════
|
|
252
|
+
// Variable-resolved options
|
|
253
|
+
// ═══════════════════════════════════════════════════
|
|
254
|
+
{
|
|
255
|
+
name: 'options object in variable with style: currency',
|
|
256
|
+
code: `
|
|
257
|
+
const opts = { style: 'currency', currency: 'EUR' };
|
|
258
|
+
intl.formatNumber(42, opts);
|
|
259
|
+
`,
|
|
260
|
+
errors: [error],
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: 'options with currency in variable',
|
|
264
|
+
code: `
|
|
265
|
+
const opts = { currency: 'USD' };
|
|
266
|
+
intl.formatNumber(42, opts);
|
|
267
|
+
`,
|
|
268
|
+
errors: [error],
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: 'style value resolved through variable',
|
|
272
|
+
code: `
|
|
273
|
+
const currencyStyle = 'currency';
|
|
274
|
+
intl.formatNumber(42, { style: currencyStyle, currency: 'EUR' });
|
|
275
|
+
`,
|
|
276
|
+
errors: [error],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'Intl.NumberFormat with options in variable',
|
|
280
|
+
code: `
|
|
281
|
+
const opts = { style: 'currency', currency: 'EUR' };
|
|
282
|
+
new Intl.NumberFormat('en', opts);
|
|
283
|
+
`,
|
|
284
|
+
errors: [error],
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
// ═══════════════════════════════════════════════════
|
|
288
|
+
// Spread elements
|
|
289
|
+
// ═══════════════════════════════════════════════════
|
|
290
|
+
{
|
|
291
|
+
name: 'currency option via spread',
|
|
292
|
+
code: `
|
|
293
|
+
const base = { currency: 'EUR' };
|
|
294
|
+
intl.formatNumber(42, { ...base });
|
|
295
|
+
`,
|
|
296
|
+
errors: [error],
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: 'style: currency via spread',
|
|
300
|
+
code: `
|
|
301
|
+
const base = { style: 'currency' };
|
|
302
|
+
intl.formatNumber(42, { ...base, currency: 'EUR' });
|
|
303
|
+
`,
|
|
304
|
+
errors: [error],
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: 'nested spread: currency buried two levels deep',
|
|
308
|
+
code: `
|
|
309
|
+
const inner = { currency: 'EUR' };
|
|
310
|
+
const outer = { ...inner };
|
|
311
|
+
intl.formatNumber(42, { ...outer });
|
|
312
|
+
`,
|
|
313
|
+
errors: [error],
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
// ═══════════════════════════════════════════════════
|
|
317
|
+
// Currency options in first argument
|
|
318
|
+
// ═══════════════════════════════════════════════════
|
|
319
|
+
{
|
|
320
|
+
name: 'currency options passed as first argument',
|
|
321
|
+
code: `intl.formatNumber({ style: 'currency', currency: 'EUR' })`,
|
|
322
|
+
errors: [error],
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
// ═══════════════════════════════════════════════════
|
|
326
|
+
// <FormattedNumber /> — currency usage
|
|
327
|
+
// ═══════════════════════════════════════════════════
|
|
328
|
+
{
|
|
329
|
+
name: 'FormattedNumber with style="currency" (named import)',
|
|
330
|
+
code: `
|
|
331
|
+
import { FormattedNumber } from 'react-intl';
|
|
332
|
+
const x = <FormattedNumber style="currency" currency="EUR" value={42} />;
|
|
333
|
+
`,
|
|
334
|
+
errors: [error],
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: 'FormattedNumber aliased import',
|
|
338
|
+
code: `
|
|
339
|
+
import { FormattedNumber as FN } from 'react-intl';
|
|
340
|
+
const x = <FN style="currency" currency="EUR" value={42} />;
|
|
341
|
+
`,
|
|
342
|
+
errors: [error],
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
name: 'FormattedNumber via namespace import',
|
|
346
|
+
code: `
|
|
347
|
+
import * as ReactIntl from 'react-intl';
|
|
348
|
+
const x = <ReactIntl.FormattedNumber style="currency" currency="EUR" value={42} />;
|
|
349
|
+
`,
|
|
350
|
+
errors: [error],
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: 'FormattedNumber via require destructuring',
|
|
354
|
+
code: `
|
|
355
|
+
const { FormattedNumber } = require('react-intl');
|
|
356
|
+
const x = <FormattedNumber style="currency" currency="EUR" value={42} />;
|
|
357
|
+
`,
|
|
358
|
+
errors: [error],
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
});
|