From 6817b990a85d2ec2fc2012aa97d781ede75cf81a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 22:33:28 +0000 Subject: [PATCH] Add ESLint rule to enforce Lingui msg usage Adds a custom ESLint rule 'lingui-msg-rule' that ensures the Lingui _() function is called with msg`` template literals or plural/select macros, preventing accidental misuse like _('string') which bypasses i18n. https://claude.ai/code/session_01JMXXPUgAHiSBGmfGwUojKy --- eslint.config.mjs | 1 + eslint/__tests__/lingui-msg-rule.test.js | 139 +++++++++++++++++++++++ eslint/index.js | 1 + eslint/lingui-msg-rule.js | 73 ++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 eslint/__tests__/lingui-msg-rule.test.js create mode 100644 eslint/lingui-msg-rule.js diff --git a/eslint.config.mjs b/eslint.config.mjs index e9001882a6..7a06ebdb2e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -121,6 +121,7 @@ export default defineConfig( ], 'bsky-internal/use-exact-imports': 'error', 'bsky-internal/use-prefixed-imports': 'error', + 'bsky-internal/lingui-msg-rule': 'error', /** * React & React Native diff --git a/eslint/__tests__/lingui-msg-rule.test.js b/eslint/__tests__/lingui-msg-rule.test.js new file mode 100644 index 0000000000..be0d801d99 --- /dev/null +++ b/eslint/__tests__/lingui-msg-rule.test.js @@ -0,0 +1,139 @@ +const {RuleTester} = require('eslint') +const tseslint = require('typescript-eslint') +const linguiMsgRule = require('../lingui-msg-rule') + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, +}) + +describe('lingui-msg-rule', () => { + const tests = { + valid: [ + // msg template literal + { + code: ` +const {_} = useLingui() +const x = _(msg\`Hello\`) + `, + }, + // msg template literal with interpolation + { + code: ` +const {_} = useLingui() +const name = 'World' +const x = _(msg\`Hello \${name}\`) + `, + }, + // plural macro + { + code: ` +const {_} = useLingui() +const count = 5 +const x = _(plural(count, {one: '# item', other: '# items'})) + `, + }, + // select macro + { + code: ` +const {_} = useLingui() +const gender = 'female' +const x = _(select(gender, {male: 'He', female: 'She', other: 'They'})) + `, + }, + // selectOrdinal macro + { + code: ` +const {_} = useLingui() +const position = 1 +const x = _(selectOrdinal(position, {one: '#st', two: '#nd', few: '#rd', other: '#th'})) + `, + }, + // Different function named _ (not from useLingui context, but rule doesn't track that) + // This is fine - the rule just checks the pattern + { + code: ` +const _ = (x) => x +const x = _(someValue) + `, + }, + ], + invalid: [ + // Plain string literal (single quotes) + { + code: ` +const {_} = useLingui() +const x = _('Bad') + `, + errors: [{messageId: 'missingMsg'}], + }, + // Plain string literal (double quotes) + { + code: ` +const {_} = useLingui() +const x = _("Bad") + `, + errors: [{messageId: 'missingMsg'}], + }, + // Template literal without msg tag + { + code: ` +const {_} = useLingui() +const x = _(\`Bad\`) + `, + errors: [{messageId: 'missingMsg'}], + }, + // Variable/identifier + { + code: ` +const {_} = useLingui() +const message = 'Hello' +const x = _(message) + `, + errors: [{messageId: 'missingMsg'}], + }, + // Arbitrary function call + { + code: ` +const {_} = useLingui() +const x = _(getMessage()) + `, + errors: [{messageId: 'missingMsg'}], + }, + // Empty call + { + code: ` +const {_} = useLingui() +const x = _() + `, + errors: [{messageId: 'missingMsg'}], + }, + // Tagged template with wrong tag + { + code: ` +const {_} = useLingui() +const x = _(html\`Hello\`) + `, + errors: [{messageId: 'missingMsg'}], + }, + // Number literal + { + code: ` +const {_} = useLingui() +const x = _(123) + `, + errors: [{messageId: 'missingMsg'}], + }, + ], + } + + ruleTester.run('lingui-msg-rule', linguiMsgRule, tests) +}) diff --git a/eslint/index.js b/eslint/index.js index 4d59c8c1d0..1df4590245 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -9,6 +9,7 @@ const plugin = { 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 'use-exact-imports': require('./use-exact-imports'), 'use-prefixed-imports': require('./use-prefixed-imports'), + 'lingui-msg-rule': require('./lingui-msg-rule'), }, } diff --git a/eslint/lingui-msg-rule.js b/eslint/lingui-msg-rule.js new file mode 100644 index 0000000000..990b627144 --- /dev/null +++ b/eslint/lingui-msg-rule.js @@ -0,0 +1,73 @@ +'use strict' + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Enforce that Lingui _() function is called with msg`` template literal or plural/select macros', + recommended: true, + }, + messages: { + missingMsg: + 'Lingui _() must be called with msg`...` template literal or plural/select/selectOrdinal. Example: _(msg`Hello`)', + }, + schema: [], + }, + + create(context) { + // Valid Lingui macro functions that can be passed to _() + const VALID_MACRO_FUNCTIONS = new Set([ + 'plural', + 'select', + 'selectOrdinal', + ]) + + return { + CallExpression(node) { + // Check if this is a call to _() + if (node.callee.type !== 'Identifier' || node.callee.name !== '_') { + return + } + + // Must have at least one argument + if (node.arguments.length === 0) { + context.report({ + node, + messageId: 'missingMsg', + }) + return + } + + const firstArg = node.arguments[0] + + // Valid: _(msg`...`) + if ( + firstArg.type === 'TaggedTemplateExpression' && + firstArg.tag.type === 'Identifier' && + firstArg.tag.name === 'msg' + ) { + return + } + + // Valid: _(plural(...)), _(select(...)), _(selectOrdinal(...)) + if ( + firstArg.type === 'CallExpression' && + firstArg.callee.type === 'Identifier' && + VALID_MACRO_FUNCTIONS.has(firstArg.callee.name) + ) { + return + } + + // Everything else is invalid + context.report({ + node, + messageId: 'missingMsg', + }) + }, + } + }, +}