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],
+ },
+ ],
+});