@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 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.2.0",
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.2.0",
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
+ });