Add ESLint rule to enforce Lingui msg usage (#9789)

* 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

* Support msg({...}) descriptor form and add auto-fix

- Allow msg() function call form: _(msg({message: 'Hello'}))
- Add auto-fix for string literals: _('Bad') -> _(msg`Bad`)
- Add auto-fix for untagged templates: _(`Bad`) -> _(msg`Bad`)
- No auto-fix for variables/function calls (not safely fixable)

https://claude.ai/code/session_01JMXXPUgAHiSBGmfGwUojKy

* fix complex cases

* run autofix HELL YEAH

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-29 21:05:49 +02:00
committed by GitHub
parent 31553f0d40
commit 2cee96da8e
11 changed files with 331 additions and 23 deletions
+188
View File
@@ -0,0 +1,188 @@
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'}))
`,
},
// msg function call with object (descriptor form)
{
code: `
const {_} = useLingui()
const x = _(msg({message: 'Hello'}))
`,
},
// msg function call with object and context
{
code: `
const {_} = useLingui()
const x = _(msg({message: 'Hello', context: 'greeting'}))
`,
},
],
invalid: [
// Plain string literal (single quotes) - with auto-fix
{
code: `
const {_} = useLingui()
const x = _('Bad')
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Plain string literal (double quotes) - with auto-fix
{
code: `
const {_} = useLingui()
const x = _("Bad")
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Template literal without msg tag - with auto-fix
{
code: `
const {_} = useLingui()
const x = _(\`Bad\`)
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Template literal with interpolation - with auto-fix
{
code: `
const {_} = useLingui()
const name = 'World'
const x = _(\`Hello \${name}\`)
`,
output: `
const {_} = useLingui()
const name = 'World'
const x = _(msg\`Hello \${name}\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// String with backticks that need escaping
{
code: `
const {_} = useLingui()
const x = _('Use \\\`code\\\` here')
`,
output: `
const {_} = useLingui()
const x = _(msg\`Use \\\`code\\\` here\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Variable/identifier - no auto-fix possible
{
code: `
const {_} = useLingui()
const message = 'Hello'
const x = _(message)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Arbitrary function call - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _(getMessage())
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Empty call - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _()
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Tagged template with wrong tag - no auto-fix (would need to replace tag)
{
code: `
const {_} = useLingui()
const x = _(html\`Hello\`)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Number literal - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _(123)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
],
}
ruleTester.run('lingui-msg-rule', linguiMsgRule, tests)
})
+1
View File
@@ -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'),
},
}
+110
View File
@@ -0,0 +1,110 @@
'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,
},
fixable: 'code',
messages: {
missingMsg:
'Lingui _() must be called with msg`...` or msg({...}) 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([
'msg',
'plural',
'select',
'selectOrdinal',
])
/**
* Escape backticks and backslashes for template literal
*/
function escapeForTemplateLiteral(str) {
return str.replace(/\\`/g, '`').replace(/`/g, '\\`')
}
/**
* Try to get a fixer for the given argument
* Returns null if we can't safely fix it
*/
function getFixer(firstArg) {
const sourceCode = context.sourceCode ?? context.getSourceCode()
// Fix string literals: _('foo') -> _(msg`foo`)
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
const escaped = escapeForTemplateLiteral(firstArg.value)
return function (fixer) {
return fixer.replaceText(firstArg, 'msg`' + escaped + '`')
}
}
// Fix untagged template literals: _(`foo`) -> _(msg`foo`)
if (firstArg.type === 'TemplateLiteral') {
const text = sourceCode.getText(firstArg)
return function (fixer) {
return fixer.replaceText(firstArg, 'msg' + text)
}
}
return null
}
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: _(msg(...)), _(plural(...)), _(select(...)), _(selectOrdinal(...))
if (
firstArg.type === 'CallExpression' &&
firstArg.callee.type === 'Identifier' &&
VALID_MACRO_FUNCTIONS.has(firstArg.callee.name)
) {
return
}
// Everything else is invalid
const fix = getFixer(firstArg)
context.report({
node,
messageId: 'missingMsg',
fix,
})
},
}
},
}