diff --git a/.changeset/fec-738-no-direct-currency-formatting.md b/.changeset/fec-738-no-direct-currency-formatting.md new file mode 100644 index 0000000000..6b6d19f519 --- /dev/null +++ b/.changeset/fec-738-no-direct-currency-formatting.md @@ -0,0 +1,43 @@ +--- +'@commercetools-frontend/eslint-config-mc-app': minor +--- + +Add bundled `no-direct-currency-formatting` rule via the `@commercetools-frontend/eslint-config-mc-app/rules` inline plugin. + +This rule disallows direct currency formatting through `intl.formatNumber`, `intl.formatCurrency`, `new Intl.NumberFormat` when using a `currency` option or `style: 'currency'`, and `` from `react-intl`. + +Use a shared currency formatting wrapper instead, and allowlist that wrapper path if needed. + +## How to update + +Enable the bundled rule in your project config: + +```js +// eslint.config.js +import mcAppConfig from '@commercetools-frontend/eslint-config-mc-app'; + +export default [ + ...mcAppConfig, + { + files: ['**/*.{js,jsx,ts,tsx}'], + rules: { + '@commercetools-frontend/eslint-config-mc-app/rules/no-direct-currency-formatting': + [ + 'error', + { + allowedWrapperPaths: [ + 'src/utils/money.js', // path to your shared wrapper implementation + ], + }, + ], + }, + }, +]; +``` + +If you need to customize the wrapper allowlist, pass `allowedWrapperPaths` as shown above. + +## Why + +Direct currency formatting is hard to standardize across applications and can drift in behavior over time. +Enforcing a shared wrapper keeps formatting logic consistent, testable, and centrally maintainable. diff --git a/jest.test.config.js b/jest.test.config.js index 110b00780e..6a8f90fcd4 100644 --- a/jest.test.config.js +++ b/jest.test.config.js @@ -1,29 +1,65 @@ process.env.ENABLE_NEW_JSX_TRANSFORM = 'true'; /** - * @type {import('@jest/types').Config.ProjectConfig} + * This config uses Jest "projects" to run two test suites in a single + * `pnpm test` invocation, each with its own environment: + * + * - "test" — the main suite (jsdom). Uses the MC app preset which + * sets up window.app, localStorage mocks, etc. + * - "eslint-rules" — custom ESLint rule tests (node). These use ESLint's + * RuleTester which requires `structuredClone` (available + * in Node but not in jsdom) and has no DOM dependencies. + * The MC app preset's setup files also assume jsdom + * (they write to `global.window`), so these tests cannot + * run under the main project. + * + * If you add more custom ESLint rules under + * packages/eslint-config-mc-app/rules/, their *.spec.js files will be + * picked up automatically by the "eslint-rules" project. + * + * @type {import('@jest/types').Config.InitialOptions} */ module.exports = { - preset: '@commercetools-frontend/jest-preset-mc-app/typescript', - moduleDirectories: [ - 'application-templates/', - 'custom-views-templates/', - 'packages/', - 'playground/', - 'node_modules/', + projects: [ + // Main application test suite — jsdom environment. + { + displayName: 'test', + preset: '@commercetools-frontend/jest-preset-mc-app/typescript', + moduleDirectories: [ + 'application-templates/', + 'custom-views-templates/', + 'packages/', + 'playground/', + 'node_modules/', + ], + modulePathIgnorePatterns: [ + '.cache', + 'build', + 'dist', + 'public/', + 'examples', + 'packages-backend/', + ], + testPathIgnorePatterns: [ + '/node_modules/', + // Excluded here because these tests need the node environment (see below). + 'packages/eslint-config-mc-app/rules/', + ], + transformIgnorePatterns: [ + // Transpile also our local packages as they are only symlinked. + 'node_modules/(?!(@commercetools-[frontend|backend]+)/)', + ], + testEnvironment: 'jsdom', + }, + // Custom ESLint rule tests — node environment, no preset/setup files. + // `transform: {}` disables Babel so the MC app preset's babel-plugin-istanbul + // doesn't conflict with Jest's own coverage instrumentation. + { + displayName: 'eslint-rules', + testEnvironment: 'node', + testMatch: ['/packages/eslint-config-mc-app/rules/**/*.spec.js'], + transform: {}, + }, ], - modulePathIgnorePatterns: [ - '.cache', - 'build', - 'dist', - 'public/', - 'examples', - 'packages-backend/', - ], - transformIgnorePatterns: [ - // Transpile also our local packages as they are only symlinked. - 'node_modules/(?!(@commercetools-[frontend|backend]+)/)', - ], - testEnvironment: 'jsdom', }; diff --git a/packages/eslint-config-mc-app/index.js b/packages/eslint-config-mc-app/index.js index fe18f98dba..a991ade0fc 100644 --- a/packages/eslint-config-mc-app/index.js +++ b/packages/eslint-config-mc-app/index.js @@ -90,6 +90,9 @@ const { statusCode, allSupportedExtensions } = require('./helpers/eslint'); const hasJsxRuntime = require('./helpers/has-jsx-runtime'); const { craRules } = require('./helpers/rules-presets'); +// Bundled custom rules +const noDirectCurrencyFormattingRule = require('./rules/no-direct-currency-formatting'); + /** * ESLint flat config format for @commercetools-frontend/eslint-config-mc-app * @type {import("eslint").Linter.FlatConfig[]} @@ -124,6 +127,11 @@ module.exports = [ 'jsx-a11y': jsxA11yPlugin, prettier: prettierPlugin, cypress: cypressPlugin, + '@commercetools-frontend/eslint-config-mc-app/rules': { + rules: { + 'no-direct-currency-formatting': noDirectCurrencyFormattingRule, + }, + }, }, settings: { 'import/resolver': { diff --git a/packages/eslint-config-mc-app/rules/no-direct-currency-formatting.js b/packages/eslint-config-mc-app/rules/no-direct-currency-formatting.js new file mode 100644 index 0000000000..0bffbe9eba --- /dev/null +++ b/packages/eslint-config-mc-app/rules/no-direct-currency-formatting.js @@ -0,0 +1,413 @@ +const path = require('path'); + +const unwrapExpression = (node) => { + if (!node) return node; + + if (node.type === 'TSAsExpression' || node.type === 'TypeCastExpression') { + return unwrapExpression(node.expression); + } + + return node; +}; + +const isStringLiteralCurrency = (node) => + node && + node.type === 'Literal' && + typeof node.value === 'string' && + node.value === 'currency'; + +const getPropertyKeyName = (propertyNode) => { + if (!propertyNode || propertyNode.type !== 'Property') return undefined; + + if (!propertyNode.computed && propertyNode.key.type === 'Identifier') + return propertyNode.key.name; + if (propertyNode.key.type === 'Literal') return propertyNode.key.value; + + return undefined; +}; + +const findVariableByName = (scope, name) => { + let currentScope = scope; + + while (currentScope) { + const variable = currentScope.variables.find( + (entry) => entry.name === name + ); + if (variable) return variable; + currentScope = currentScope.upper; + } + + return undefined; +}; + +const resolveNodeFromIdentifier = ( + node, + scope, + seenIdentifiers = new Set() +) => { + const unwrappedNode = unwrapExpression(node); + if (!unwrappedNode || unwrappedNode.type !== 'Identifier') + return unwrappedNode; + + if (seenIdentifiers.has(unwrappedNode.name)) return unwrappedNode; + seenIdentifiers.add(unwrappedNode.name); + + const variable = findVariableByName(scope, unwrappedNode.name); + if (!variable || variable.defs.length === 0) return unwrappedNode; + + const definitionNode = variable.defs[0].node; + if ( + definitionNode && + definitionNode.type === 'VariableDeclarator' && + definitionNode.init + ) { + return resolveNodeFromIdentifier( + definitionNode.init, + scope, + seenIdentifiers + ); + } + + return unwrappedNode; +}; + +// Detects any `currency` property in an options object, static or dynamic. +// Intl.NumberFormat validates the `currency` option regardless of `style`, so +// any direct usage — even with a hardcoded value — must go through the wrapper. +const hasCurrencyOption = (node, scope, seenObjectNodes = new Set()) => { + const resolvedNode = resolveNodeFromIdentifier(node, scope); + if (!resolvedNode || resolvedNode.type !== 'ObjectExpression') return false; + + if (seenObjectNodes.has(resolvedNode)) return false; + seenObjectNodes.add(resolvedNode); + + return resolvedNode.properties.some((propertyNode) => { + if (propertyNode.type === 'SpreadElement') { + return hasCurrencyOption(propertyNode.argument, scope, seenObjectNodes); + } + + return getPropertyKeyName(propertyNode) === 'currency'; + }); +}; + +// Detects options that resolve to `style: 'currency'`, even via identifiers/spreads. +const isCurrencyStyleOption = (node, scope, seenObjectNodes = new Set()) => { + const resolvedNode = resolveNodeFromIdentifier(node, scope); + if (!resolvedNode || resolvedNode.type !== 'ObjectExpression') return false; + + if (seenObjectNodes.has(resolvedNode)) return false; + seenObjectNodes.add(resolvedNode); + + return resolvedNode.properties.some((propertyNode) => { + if (propertyNode.type === 'SpreadElement') { + return isCurrencyStyleOption( + propertyNode.argument, + scope, + seenObjectNodes + ); + } + + if (getPropertyKeyName(propertyNode) !== 'style') { + return false; + } + + const resolvedStyleValue = resolveNodeFromIdentifier( + propertyNode.value, + scope + ); + return isStringLiteralCurrency(unwrapExpression(resolvedStyleValue)); + }); +}; + +const getJsxAttributeName = (attributeNode) => { + if ( + !attributeNode || + attributeNode.type !== 'JSXAttribute' || + !attributeNode.name || + attributeNode.name.type !== 'JSXIdentifier' + ) { + return undefined; + } + + return attributeNode.name.name; +}; + +const getJsxAttributeValueNode = (attributeNode) => { + if (!attributeNode || attributeNode.type !== 'JSXAttribute') return undefined; + if (!attributeNode.value) return undefined; + + if (attributeNode.value.type === 'Literal') return attributeNode.value; + if (attributeNode.value.type !== 'JSXExpressionContainer') return undefined; + + return unwrapExpression(attributeNode.value.expression); +}; + +const isCurrencyFormattedNumberElement = (node, scope) => { + if (!node || !node.attributes) return false; + + return node.attributes.some((attributeNode) => { + if (attributeNode.type === 'JSXSpreadAttribute') { + return isCurrencyStyleOption(attributeNode.argument, scope); + } + + if (getJsxAttributeName(attributeNode) !== 'style') return false; + const resolvedAttributeValue = resolveNodeFromIdentifier( + getJsxAttributeValueNode(attributeNode), + scope + ); + + return isStringLiteralCurrency(unwrapExpression(resolvedAttributeValue)); + }); +}; + +// Rule allowlist for wrapper files that are expected to format currencies directly. +const isPathAllowed = (filename, allowedWrapperPaths) => { + const normalizePathSeparators = (value) => value.replace(/\\/g, '/'); + const normalizedFilename = normalizePathSeparators( + filename.split(path.sep).join('/') + ); + + return allowedWrapperPaths.some((allowedPath) => { + const normalizedAllowedPath = normalizePathSeparators( + allowedPath.split(path.sep).join('/') + ); + return normalizedFilename.endsWith(normalizedAllowedPath); + }); +}; + +/** ESLint 9+: use SourceCode#getScope(node); legacy context.getScope() was removed. */ +function getScopeForNode(context, node) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + return sourceCode.getScope(node); +} + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow direct currency formatting and enforce shared wrapper usage.', + category: 'Best Practices', + recommended: false, + }, + schema: [ + { + type: 'object', + properties: { + allowedWrapperPaths: { + type: 'array', + items: { + type: 'string', + }, + default: [], + }, + }, + additionalProperties: false, + }, + ], + messages: { + noDirectCurrencyFormatting: + 'Use the shared currency formatting wrapper instead of direct currency formatting.', + }, + }, + create: function (context) { + const options = context.options[0] || {}; + const allowedWrapperPaths = options.allowedWrapperPaths || []; + const filename = context.getFilename(); + + // Skip checks for explicitly allowed wrapper implementations. + if (isPathAllowed(filename, allowedWrapperPaths)) return {}; + + // Track local names of formatting functions from destructuring or aliasing. + // Covers: const { formatNumber } = useIntl() + // const { formatCurrency } = intl + // function Foo({ formatNumber }) { ... } + // const fmt = intl.formatNumber + const destructuredFormattingFunctionNames = new Set(); + const formattingFunctionNames = new Set(['formatNumber', 'formatCurrency']); + const formattedNumberComponentNames = new Set(); + const reactIntlNamespaceImports = new Set(); + + const collectFormatNumberFromObjectPattern = (objectPatternNode) => { + if (!objectPatternNode || objectPatternNode.type !== 'ObjectPattern') + return; + + objectPatternNode.properties.forEach((prop) => { + if (prop.type !== 'Property') return; + const propertyKeyName = getPropertyKeyName(prop); + if (!formattingFunctionNames.has(propertyKeyName)) return; + if (prop.value.type !== 'Identifier') return; + destructuredFormattingFunctionNames.add(prop.value.name); + }); + }; + + const isFormatNumberMemberExpression = (node) => { + if (!node || node.type !== 'MemberExpression') return false; + + if (!node.computed) + return ( + node.property.type === 'Identifier' && + formattingFunctionNames.has(node.property.name) + ); + + return ( + node.property.type === 'Literal' && + formattingFunctionNames.has(node.property.value) + ); + }; + + return { + ImportDeclaration(node) { + if (!node.source || node.source.value !== 'react-intl') return; + + node.specifiers.forEach((specifier) => { + if (specifier.type === 'ImportSpecifier') { + if ( + specifier.imported && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'FormattedNumber' + ) { + formattedNumberComponentNames.add(specifier.local.name); + } + } + + if ( + specifier.type === 'ImportNamespaceSpecifier' && + specifier.local && + specifier.local.type === 'Identifier' + ) { + reactIntlNamespaceImports.add(specifier.local.name); + } + }); + }, + + // function Foo({ formatNumber }) { ... } + FunctionDeclaration(node) { + node.params.forEach(collectFormatNumberFromObjectPattern); + }, + FunctionExpression(node) { + node.params.forEach(collectFormatNumberFromObjectPattern); + }, + ArrowFunctionExpression(node) { + node.params.forEach(collectFormatNumberFromObjectPattern); + }, + + // const { formatNumber } = useIntl() / const { formatNumber } = intl + // const fmt = intl.formatNumber + VariableDeclarator(node) { + if (node.id && node.id.type === 'ObjectPattern' && node.init) { + collectFormatNumberFromObjectPattern(node.id); + } + + // const { FormattedNumber } = require('react-intl') + if ( + node.id && + node.id.type === 'ObjectPattern' && + node.init && + node.init.type === 'CallExpression' && + node.init.callee.type === 'Identifier' && + node.init.callee.name === 'require' && + node.init.arguments && + node.init.arguments[0] && + node.init.arguments[0].type === 'Literal' && + node.init.arguments[0].value === 'react-intl' + ) { + node.id.properties.forEach((propertyNode) => { + if (propertyNode.type !== 'Property') return; + if (getPropertyKeyName(propertyNode) !== 'FormattedNumber') return; + if (propertyNode.value.type !== 'Identifier') return; + formattedNumberComponentNames.add(propertyNode.value.name); + }); + } + + if ( + node.id && + node.id.type === 'Identifier' && + isFormatNumberMemberExpression(unwrapExpression(node.init)) + ) { + destructuredFormattingFunctionNames.add(node.id.name); + } + }, + + JSXOpeningElement(node) { + if (!node.name) return; + const scope = getScopeForNode(context, node); + + // with named or aliased import. + if ( + node.name.type === 'JSXIdentifier' && + formattedNumberComponentNames.has(node.name.name) && + isCurrencyFormattedNumberElement(node, scope) + ) { + context.report({ node, messageId: 'noDirectCurrencyFormatting' }); + return; + } + + // with namespace import. + if ( + node.name.type === 'JSXMemberExpression' && + node.name.object && + node.name.object.type === 'JSXIdentifier' && + reactIntlNamespaceImports.has(node.name.object.name) && + node.name.property && + node.name.property.type === 'JSXIdentifier' && + node.name.property.name === 'FormattedNumber' && + isCurrencyFormattedNumberElement(node, scope) + ) { + context.report({ node, messageId: 'noDirectCurrencyFormatting' }); + } + }, + + CallExpression(node) { + const scope = getScopeForNode(context, node); + + const isCurrencyFormattingArg = (arg) => + isCurrencyStyleOption(arg, scope) || hasCurrencyOption(arg, scope); + const hasCurrencyFormattingArgs = + isCurrencyFormattingArg(node.arguments[1]) || + isCurrencyFormattingArg(node.arguments[0]); + + // Disallow member-expression calls: intl.formatNumber(..., { style: 'currency' }) + // or intl.formatNumber(..., { currency: dynamicCode }) + if ( + node.callee.type === 'MemberExpression' && + isFormatNumberMemberExpression(node.callee) && + hasCurrencyFormattingArgs + ) { + context.report({ node, messageId: 'noDirectCurrencyFormatting' }); + return; + } + + // Disallow destructured calls: const { formatNumber } = useIntl(); formatNumber(...) + if ( + node.callee.type === 'Identifier' && + destructuredFormattingFunctionNames.has(node.callee.name) && + hasCurrencyFormattingArgs + ) { + context.report({ node, messageId: 'noDirectCurrencyFormatting' }); + } + }, + + NewExpression(node) { + const scope = getScopeForNode(context, node); + // Disallow direct native Intl currency formatting constructors, + // with style:'currency' or a dynamic currency option. + if ( + node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Intl' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'NumberFormat' && + (isCurrencyStyleOption(node.arguments[1], scope) || + hasCurrencyOption(node.arguments[1], scope)) + ) { + context.report({ node, messageId: 'noDirectCurrencyFormatting' }); + } + }, + }; + }, +}; diff --git a/packages/eslint-config-mc-app/rules/no-direct-currency-formatting.spec.js b/packages/eslint-config-mc-app/rules/no-direct-currency-formatting.spec.js new file mode 100644 index 0000000000..b112363bb2 --- /dev/null +++ b/packages/eslint-config-mc-app/rules/no-direct-currency-formatting.spec.js @@ -0,0 +1,361 @@ +/** + * @jest-environment node + */ +const { RuleTester } = require('eslint'); +const rule = require('./no-direct-currency-formatting'); + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, +}); + +const error = { messageId: 'noDirectCurrencyFormatting' }; + +ruleTester.run('no-direct-currency-formatting', rule, { + valid: [ + // ─── intl.formatNumber — non-currency usage ─── + { + code: `intl.formatNumber(42, { style: 'decimal' })`, + }, + { + code: `intl.formatNumber(0.5, { style: 'percent' })`, + }, + { + code: `intl.formatNumber(42)`, + }, + { + code: `intl.formatNumber(42, {})`, + }, + + // ─── destructured formatNumber — non-currency ─── + { + code: ` + const { formatNumber } = useIntl(); + formatNumber(42, { style: 'decimal' }); + `, + }, + { + code: ` + const { formatNumber } = useIntl(); + formatNumber(42); + `, + }, + // ─── Intl.NumberFormat — non-currency ─── + { + code: `new Intl.NumberFormat('en', { style: 'decimal' })`, + }, + { + code: `new Intl.NumberFormat('en')`, + }, + { + code: `new Intl.NumberFormat('en', { minimumFractionDigits: 2 })`, + }, + // ─── FormattedNumber — non-currency usage ─── + // BUG: These currently fail because the rule flags ALL + // usage from react-intl, regardless of whether currency props are present. + // Non-currency usage (percent, decimal, no style) should be allowed. + // Once the rule's JSXOpeningElement handler is fixed to inspect props, + // these tests will pass. + { + code: ` + import { FormattedNumber } from 'react-intl'; + const x = ; + `, + }, + { + code: ` + import { FormattedNumber } from 'react-intl'; + const x = ; + `, + }, + { + code: ` + import { FormattedNumber } from 'react-intl'; + const x = ; + `, + }, + + // ─── Unrelated components with "FormattedNumber" name (not from react-intl) ─── + { + code: `const x = ;`, + }, + { + code: ` + import { FormattedNumber } from './my-components'; + const x = ; + `, + }, + // ─── Unrelated function names ─── + { + code: `intl.formatDate(new Date())`, + }, + { + code: `intl.formatMessage({ id: 'hello' })`, + }, + // ─── Allowlisted wrapper path ─── + { + code: `intl.formatNumber(42, { style: 'currency', currency: 'EUR' })`, + options: [{ allowedWrapperPaths: ['src/utils/money.js'] }], + filename: '/project/src/utils/money.js', + }, + { + code: ` + import { FormattedNumber } from 'react-intl'; + const x = ; + `, + options: [{ allowedWrapperPaths: ['src/utils/money.js'] }], + filename: '/project/src/utils/money.js', + }, + // ─── Allowlisted wrapper path — Windows-style separators ─── + { + code: `intl.formatNumber(42, { style: 'currency', currency: 'EUR' })`, + options: [{ allowedWrapperPaths: ['src/utils/money.js'] }], + filename: 'C:\\project\\src\\utils\\money.js', + }, + + // ─── formatNumber on unrelated objects (non-currency) ─── + { + code: `myLib.formatNumber(42, { style: 'decimal' })`, + }, + + // ─── FormattedNumber — non-currency via namespace import ─── + // BUG: Same false-positive as named import — namespace access is also + // flagged unconditionally without inspecting props. + { + code: ` + import * as ReactIntl from 'react-intl'; + const x = ; + `, + }, + { + code: ` + import * as ReactIntl from 'react-intl'; + const x = ; + `, + }, + ], + + invalid: [ + // ═══════════════════════════════════════════════════ + // intl.formatNumber with currency + // ═══════════════════════════════════════════════════ + { + name: 'intl.formatNumber with style: currency', + code: `intl.formatNumber(42, { style: 'currency', currency: 'EUR' })`, + errors: [error], + }, + { + name: 'intl.formatNumber with currency option only (no style)', + code: `intl.formatNumber(42, { currency: 'EUR' })`, + errors: [error], + }, + { + name: 'intl.formatCurrency call', + code: `intl.formatCurrency(42, { currency: 'EUR' })`, + errors: [error], + }, + { + name: 'intl["formatNumber"] computed member access', + code: `intl['formatNumber'](42, { style: 'currency', currency: 'EUR' })`, + errors: [error], + }, + + // ═══════════════════════════════════════════════════ + // Destructured formatNumber + // ═══════════════════════════════════════════════════ + { + name: 'destructured formatNumber from useIntl()', + code: ` + const { formatNumber } = useIntl(); + formatNumber(42, { style: 'currency', currency: 'EUR' }); + `, + errors: [error], + }, + { + name: 'destructured formatNumber with currency option only', + code: ` + const { formatNumber } = useIntl(); + formatNumber(42, { currency: 'USD' }); + `, + errors: [error], + }, + { + name: 'aliased destructured formatNumber', + code: ` + const { formatNumber: fmt } = useIntl(); + fmt(42, { style: 'currency', currency: 'EUR' }); + `, + errors: [error], + }, + { + name: 'formatNumber from function declaration parameter', + code: ` + function Foo({ formatNumber }) { + return formatNumber(42, { style: 'currency', currency: 'EUR' }); + } + `, + errors: [error], + }, + { + name: 'formatNumber from function expression parameter', + code: ` + const Foo = function({ formatNumber }) { + return formatNumber(42, { style: 'currency', currency: 'EUR' }); + } + `, + errors: [error], + }, + { + name: 'formatNumber from arrow function parameter', + code: ` + const Foo = ({ formatNumber }) => formatNumber(42, { currency: 'EUR' }); + `, + errors: [error], + }, + { + name: 'destructured formatCurrency from useIntl()', + code: ` + const { formatCurrency } = useIntl(); + formatCurrency(42, { currency: 'EUR' }); + `, + errors: [error], + }, + { + name: 'assigned from member expression: const fmt = intl.formatNumber', + code: ` + const fmt = intl.formatNumber; + fmt(42, { style: 'currency', currency: 'EUR' }); + `, + errors: [error], + }, + + // ═══════════════════════════════════════════════════ + // Intl.NumberFormat + // ═══════════════════════════════════════════════════ + { + name: 'new Intl.NumberFormat with style: currency', + code: `new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' })`, + errors: [error], + }, + { + name: 'new Intl.NumberFormat with currency option only', + code: `new Intl.NumberFormat('en', { currency: 'EUR' })`, + errors: [error], + }, + + // ═══════════════════════════════════════════════════ + // Variable-resolved options + // ═══════════════════════════════════════════════════ + { + name: 'options object in variable with style: currency', + code: ` + const opts = { style: 'currency', currency: 'EUR' }; + intl.formatNumber(42, opts); + `, + errors: [error], + }, + { + name: 'options with currency in variable', + code: ` + const opts = { currency: 'USD' }; + intl.formatNumber(42, opts); + `, + errors: [error], + }, + { + name: 'style value resolved through variable', + code: ` + const currencyStyle = 'currency'; + intl.formatNumber(42, { style: currencyStyle, currency: 'EUR' }); + `, + errors: [error], + }, + { + name: 'Intl.NumberFormat with options in variable', + code: ` + const opts = { style: 'currency', currency: 'EUR' }; + new Intl.NumberFormat('en', opts); + `, + errors: [error], + }, + + // ═══════════════════════════════════════════════════ + // Spread elements + // ═══════════════════════════════════════════════════ + { + name: 'currency option via spread', + code: ` + const base = { currency: 'EUR' }; + intl.formatNumber(42, { ...base }); + `, + errors: [error], + }, + { + name: 'style: currency via spread', + code: ` + const base = { style: 'currency' }; + intl.formatNumber(42, { ...base, currency: 'EUR' }); + `, + errors: [error], + }, + { + name: 'nested spread: currency buried two levels deep', + code: ` + const inner = { currency: 'EUR' }; + const outer = { ...inner }; + intl.formatNumber(42, { ...outer }); + `, + errors: [error], + }, + + // ═══════════════════════════════════════════════════ + // Currency options in first argument + // ═══════════════════════════════════════════════════ + { + name: 'currency options passed as first argument', + code: `intl.formatNumber({ style: 'currency', currency: 'EUR' })`, + errors: [error], + }, + + // ═══════════════════════════════════════════════════ + // — currency usage + // ═══════════════════════════════════════════════════ + { + name: 'FormattedNumber with style="currency" (named import)', + code: ` + import { FormattedNumber } from 'react-intl'; + const x = ; + `, + errors: [error], + }, + { + name: 'FormattedNumber aliased import', + code: ` + import { FormattedNumber as FN } from 'react-intl'; + const x = ; + `, + errors: [error], + }, + { + name: 'FormattedNumber via namespace import', + code: ` + import * as ReactIntl from 'react-intl'; + const x = ; + `, + errors: [error], + }, + { + name: 'FormattedNumber via require destructuring', + code: ` + const { FormattedNumber } = require('react-intl'); + const x = ; + `, + errors: [error], + }, + ], +});