From fb5cfa6cc5b45fba1121079a0187b24835c74151 Mon Sep 17 00:00:00 2001 From: G Wolpert Date: Wed, 15 Apr 2026 10:28:26 +0200 Subject: [PATCH 1/5] feat(require-implicit-id): forbid explicit IDs, optionally require context Add a new rule that disallows explicit id on and Lingui macros, favoring generated IDs. Supports an optional requireContext setting to mandate a context; tagged templates are reported when context is required. Register the rule, add docs (including mutual exclusivity with require-explicit-id), extract shared helpers (findJSXAttribute/findObjectProperty), and refactor require-explicit-id to use them. Include comprehensive tests for the new rule. --- docs/rules/require-explicit-id.md | 4 +- docs/rules/require-implicit-id.md | 71 ++++++ src/helpers.test.ts | 84 +++++++- src/helpers.ts | 37 ++++ src/index.ts | 2 + src/rules/require-explicit-id.ts | 17 +- src/rules/require-implicit-id.ts | 100 +++++++++ tests/src/rules/require-implicit-id.test.ts | 228 ++++++++++++++++++++ 8 files changed, 529 insertions(+), 14 deletions(-) create mode 100644 docs/rules/require-implicit-id.md create mode 100644 src/rules/require-implicit-id.ts create mode 100644 tests/src/rules/require-implicit-id.test.ts diff --git a/docs/rules/require-explicit-id.md b/docs/rules/require-explicit-id.md index b58b62b..0fdca0d 100644 --- a/docs/rules/require-explicit-id.md +++ b/docs/rules/require-explicit-id.md @@ -2,10 +2,12 @@ Enforce that `` components and Lingui macro function calls (`t`, `msg`, `defineMessage`) have an explicit `id`. -Providing an explicit `id` gives translators a stable, human-readable key and prevents auto-generated IDs from changing unexpectedly when the default message is updated. +Providing an explicit `id` gives translators a stable, human-readable key and prevents auto-generated IDs from changing unexpectedly when the default message is updated. See [Benefits of Explicit IDs](https://lingui.dev/guides/explicit-vs-generated-ids#benefits-of-explicit-ids) in the Lingui docs for more details. Tagged template literals (`` t`Hello` ``) don't support `id` — use the function call form instead. +> **⚠️ Conflicts:** This rule directly conflicts with [`require-implicit-id`](./require-implicit-id.md). Do **not** enable both rules at the same time — they are mutually exclusive. + ```jsx // nope ⛔️ Read the docs for more info. diff --git a/docs/rules/require-implicit-id.md b/docs/rules/require-implicit-id.md new file mode 100644 index 0000000..fc7b4c4 --- /dev/null +++ b/docs/rules/require-implicit-id.md @@ -0,0 +1,71 @@ +# require-implicit-id + +Enforce that `` components and Lingui macro function calls (`t`, `msg`, `defineMessage`) do not have an explicit `id`. + +Relying on auto-generated IDs eliminates the need to maintain a separate mapping of IDs to messages and ensures the catalog stays in sync with the source code. See [Benefits of Generated IDs](https://lingui.dev/guides/explicit-vs-generated-ids#benefits-of-generated-ids) in the Lingui docs for more details. + +> **⚠️ Conflicts:** This rule directly conflicts with [`require-explicit-id`](./require-explicit-id.md). Do **not** enable both rules at the same time — they are mutually exclusive. + +```jsx +// nope ⛔️ +Hello +t({ id: "msg.hello", message: "Hello" }) + +// ok ✅ +Hello +t({ message: "Hello" }) +t`Hello` +``` + +## Options + +### `requireContext` + +Type: `boolean` +Default: `false` + +When `true`, the rule additionally requires every translation to include a `context` property (for function calls) or attribute (for ``). The `context` value helps translators disambiguate words or phrases that have the same default message but different meanings. + +Tagged template literals (`` t`Hello` ``) don't support `context` — use the function call form instead. + +### Configuration example + +```jsonc +// Only forbid explicit id (default behavior) +"lingui/require-implicit-id": "error" + +// Also require a context property on every translation +"lingui/require-implicit-id": ["error", { "requireContext": true }] +``` + +### Examples with `requireContext: true` + +```jsx +// nope ⛔️ +Right +t({ message: "right" }) +t`right` + +// ok ✅ +Right +Right +t({ message: "right", context: "direction" }) +msg({ message: "right", context: "correctness" }) +``` + +This is particularly useful for ambiguous terms: + +```js +// "right" as a direction +const ex1 = msg({ + message: `right`, + context: "direction", +}); + +// "right" as correctness +const ex2 = msg({ + message: `right`, + context: "correctness", +}); +``` + diff --git a/src/helpers.test.ts b/src/helpers.test.ts index 2237258..5ed5991 100644 --- a/src/helpers.test.ts +++ b/src/helpers.test.ts @@ -1,6 +1,88 @@ import { parse } from '@typescript-eslint/parser' import { TSESTree } from '@typescript-eslint/utils' -import { buildCalleePath } from './helpers' +import { buildCalleePath, findJSXAttribute, findObjectProperty } from './helpers' + +describe('findJSXAttribute', () => { + function buildJSXElement(code: string) { + const t = parse(code, { jsx: true }) + return (t.body[0] as TSESTree.ExpressionStatement).expression as TSESTree.JSXElement + } + + it('should find an attribute by name', () => { + const node = buildJSXElement('Hello') + const attr = findJSXAttribute(node, 'id') + + expect(attr).toBeDefined() + expect(attr!.name.name).toBe('id') + }) + + it('should return undefined when attribute is missing', () => { + const node = buildJSXElement('Hello') + const attr = findJSXAttribute(node, 'id') + + expect(attr).toBeUndefined() + }) + + it('should find the correct attribute among several', () => { + const node = buildJSXElement('Hello') + + expect(findJSXAttribute(node, 'id')).toBeDefined() + expect(findJSXAttribute(node, 'context')).toBeDefined() + expect(findJSXAttribute(node, 'missing')).toBeUndefined() + }) + + it('should ignore JSX spread attributes', () => { + const node = buildJSXElement('Hello') + + expect(findJSXAttribute(node, 'id')).toBeUndefined() + }) +}) + +describe('findObjectProperty', () => { + function buildObjectExpression(code: string) { + const t = parse(code) + return (t.body[0] as TSESTree.ExpressionStatement).expression as TSESTree.ObjectExpression + } + + it('should find a property with an identifier key', () => { + const obj = buildObjectExpression('({ id: "msg.hello", message: "Hello" })') + const prop = findObjectProperty(obj, 'id') + + expect(prop).toBeDefined() + expect((prop!.key as TSESTree.Identifier).name).toBe('id') + }) + + it('should find a property with a string literal key', () => { + const obj = buildObjectExpression("({ 'id': 'msg.hello', message: 'Hello' })") + const prop = findObjectProperty(obj, 'id') + + expect(prop).toBeDefined() + expect((prop!.key as TSESTree.Literal).value).toBe('id') + }) + + it('should return undefined when property is missing', () => { + const obj = buildObjectExpression('({ message: "Hello" })') + const prop = findObjectProperty(obj, 'id') + + expect(prop).toBeUndefined() + }) + + it('should find the correct property among several', () => { + const obj = buildObjectExpression('({ id: "msg.hello", message: "Hello", context: "greeting" })') + + expect(findObjectProperty(obj, 'id')).toBeDefined() + expect(findObjectProperty(obj, 'message')).toBeDefined() + expect(findObjectProperty(obj, 'context')).toBeDefined() + expect(findObjectProperty(obj, 'missing')).toBeUndefined() + }) + + it('should ignore spread elements', () => { + const obj = buildObjectExpression('({ ...defaults, message: "Hello" })') + + expect(findObjectProperty(obj, 'id')).toBeUndefined() + expect(findObjectProperty(obj, 'message')).toBeDefined() + }) +}) describe('buildCalleePath', () => { function buildCallExp(code: string) { diff --git a/src/helpers.ts b/src/helpers.ts index f50d8af..28288d4 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -128,6 +128,43 @@ export function isJSXAttribute(node: TSESTree.Node | undefined): node is TSESTre return (node as TSESTree.Node)?.type === TSESTree.AST_NODE_TYPES.JSXAttribute } +/** + * Find a JSX attribute by name on a JSX element's opening tag. + * + * @example + * // Given `Hello` + * findJSXAttribute(node, 'id') // → JSXAttribute node for `id="msg.hello"` + * findJSXAttribute(node, 'context') // → JSXAttribute node for `context="greeting"` + * findJSXAttribute(node, 'missing') // → undefined + */ +export function findJSXAttribute(node: TSESTree.JSXElement, attrName: string) { + return node.openingElement.attributes.find( + (attr): attr is TSESTree.JSXAttribute => + attr.type === TSESTree.AST_NODE_TYPES.JSXAttribute && + attr.name.type === TSESTree.AST_NODE_TYPES.JSXIdentifier && + attr.name.name === attrName, + ) +} + +/** + * Find a property by name in an object expression. + * Handles both identifier keys (`{ id: '...' }`) and string-literal keys (`{ 'id': '...' }`). + * + * @example + * // Given `t({ id: "msg.hello", message: "Hello" })` + * findObjectProperty(objectNode, 'id') // → Property node for `id: "msg.hello"` + * findObjectProperty(objectNode, 'message') // → Property node for `message: "Hello"` + * findObjectProperty(objectNode, 'missing') // → undefined + */ +export function findObjectProperty(object: TSESTree.ObjectExpression, propName: string) { + return object.properties.find( + (prop): prop is TSESTree.Property => + prop.type === TSESTree.AST_NODE_TYPES.Property && + ((prop.key.type === TSESTree.AST_NODE_TYPES.Identifier && prop.key.name === propName) || + (prop.key.type === TSESTree.AST_NODE_TYPES.Literal && prop.key.value === propName)), + ) +} + export function buildCalleePath(node: TSESTree.Expression) { let current = node diff --git a/src/index.ts b/src/index.ts index ea53a81..f16cc3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import * as noTransInsideTransRule from './rules/no-trans-inside-trans' import * as consistentPluralFormatRule from './rules/consistent-plural-format' import * as noPluralInsideTransRule from './rules/no-plural-inside-trans' import * as requireExplicitIdRule from './rules/require-explicit-id' +import * as requireImplicitIdRule from './rules/require-implicit-id' import { ESLint, Linter } from 'eslint' import { FlatConfig, RuleModule } from '@typescript-eslint/utils/ts-eslint' @@ -23,6 +24,7 @@ const rules = { [consistentPluralFormatRule.name]: consistentPluralFormatRule.rule, [noPluralInsideTransRule.name]: noPluralInsideTransRule.rule, [requireExplicitIdRule.name]: requireExplicitIdRule.rule, + [requireImplicitIdRule.name]: requireImplicitIdRule.rule, } type RuleKey = keyof typeof rules diff --git a/src/rules/require-explicit-id.ts b/src/rules/require-explicit-id.ts index aa335eb..fd15074 100644 --- a/src/rules/require-explicit-id.ts +++ b/src/rules/require-explicit-id.ts @@ -1,6 +1,8 @@ import { TSESTree } from '@typescript-eslint/utils' import { createRule } from '../create-rule' import { + findJSXAttribute, + findObjectProperty, LinguiCallExpressionQuery, LinguiTaggedTemplateExpressionMessageQuery, LinguiTransQuery, @@ -73,12 +75,7 @@ export const rule = createRule({ return { [LinguiTransQuery](node: TSESTree.JSXElement) { - const idAttr = node.openingElement.attributes.find( - (attr): attr is TSESTree.JSXAttribute => - attr.type === TSESTree.AST_NODE_TYPES.JSXAttribute && - attr.name.type === TSESTree.AST_NODE_TYPES.JSXIdentifier && - attr.name.name === 'id', - ) + const idAttr = findJSXAttribute(node, 'id') if (!idAttr) { context.report({ @@ -90,6 +87,7 @@ export const rule = createRule({ // Only validate string literal values; skip complex expressions silently let idValue: string | null = null + const attVal = idAttr.value; if ( idAttr.value && idAttr.value.type === TSESTree.AST_NODE_TYPES.Literal && @@ -132,12 +130,7 @@ export const rule = createRule({ return } - const idProp = arg.properties.find( - (prop): prop is TSESTree.Property => - prop.type === TSESTree.AST_NODE_TYPES.Property && - ((prop.key.type === TSESTree.AST_NODE_TYPES.Identifier && prop.key.name === 'id') || - (prop.key.type === TSESTree.AST_NODE_TYPES.Literal && prop.key.value === 'id')), - ) + const idProp = findObjectProperty(arg, 'id') if (!idProp) { context.report({ diff --git a/src/rules/require-implicit-id.ts b/src/rules/require-implicit-id.ts new file mode 100644 index 0000000..7f24f1b --- /dev/null +++ b/src/rules/require-implicit-id.ts @@ -0,0 +1,100 @@ +import { TSESTree } from '@typescript-eslint/utils' +import { createRule } from '../create-rule' +import { + findJSXAttribute, + findObjectProperty, + LinguiCallExpressionQuery, + LinguiTaggedTemplateExpressionMessageQuery, + LinguiTransQuery, +} from '../helpers' + +export type Option = { + requireContext?: boolean +} + +export const name = 'require-implicit-id' + + +export const rule = createRule({ + name, + meta: { + docs: { + description: + "forbid explicit 'id' for Lingui macros and optionally require 'context' for disambiguation", + recommended: 'error', + }, + messages: { + forbiddenIdTrans: + " must not have an explicit 'id' attribute; use auto-generated IDs instead", + forbiddenIdCall: + "Macro function call must not have an explicit 'id' property; use auto-generated IDs instead", + missingContextTrans: + " requires a 'context' attribute to disambiguate translations", + missingContextCall: + "Macro function call requires a 'context' property to disambiguate translations", + noContextInTaggedTemplate: + "Tagged template literal doesn't support 'context'. Use {{ fn }}({ message: '...', context: '...' }) instead", + }, + schema: [ + { + type: 'object', + properties: { + requireContext: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + type: 'problem' as const, + }, + + defaultOptions: [], + + create: function (context) { + const requireContext = context.options[0]?.requireContext ?? false + + return { + [LinguiTransQuery](node: TSESTree.JSXElement) { + if (findJSXAttribute(node, 'id')) { + context.report({ node: findJSXAttribute(node, 'id')!, messageId: 'forbiddenIdTrans' }) + } + + if (requireContext && !findJSXAttribute(node, 'context')) { + context.report({ node, messageId: 'missingContextTrans' }) + } + }, + + [LinguiTaggedTemplateExpressionMessageQuery](node: TSESTree.TemplateLiteral) { + if (!requireContext) return + + const parent = node.parent as TSESTree.TaggedTemplateExpression + // The AST query guarantees tag is an Identifier (t, msg, defineMessage) + const fn = (parent.tag as TSESTree.Identifier).name + + context.report({ + node: parent, + messageId: 'noContextInTaggedTemplate', + data: { fn }, + }) + }, + + [LinguiCallExpressionQuery](node: TSESTree.CallExpression) { + const arg = node.arguments[0] + + if (!arg || arg.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) { + return + } + + if (findObjectProperty(arg, 'id')) { + context.report({ node: findObjectProperty(arg, 'id')!, messageId: 'forbiddenIdCall' }) + } + + if (requireContext && !findObjectProperty(arg, 'context')) { + context.report({ node, messageId: 'missingContextCall' }) + } + }, + } + }, +}) + diff --git a/tests/src/rules/require-implicit-id.test.ts b/tests/src/rules/require-implicit-id.test.ts new file mode 100644 index 0000000..9abf801 --- /dev/null +++ b/tests/src/rules/require-implicit-id.test.ts @@ -0,0 +1,228 @@ +import { rule, name } from '../../../src/rules/require-implicit-id' +import { RuleTester } from '@typescript-eslint/rule-tester' + +describe('', () => {}) + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}) + +ruleTester.run(name, rule, { + valid: [ + // ─── Base rule (no options) ─────────────────────────────────────── + + // Trans without id — valid + { + code: 'Hello', + }, + { + code: 'Hello World', + }, + { + code: ` + Read the documentation + for more info. + `, + }, + + // Call expressions without id — valid + { + code: 't({ message: "Hello" })', + }, + { + code: 'msg({ message: "Hello" })', + }, + { + code: 'defineMessage({ message: "Hello" })', + }, + // Call expression with no arguments — early return, no error + { + code: 't()', + }, + // Call expression with non-object argument — early return, no error + { + code: 't("Hello")', + }, + + // Tagged templates — always valid when requireContext is false + { + code: 't`Hello`', + }, + { + code: 'msg`Hello`', + }, + { + code: 'defineMessage`Hello`', + }, + + // ─── requireContext: true ───────────────────────────────────────── + + // Trans with context — valid + { + code: 'Right', + options: [{ requireContext: true }], + }, + { + code: 'Right', + options: [{ requireContext: true }], + }, + { + code: 'Right', + options: [{ requireContext: true }], + }, + + // Call expressions with context — valid + { + code: 't({ message: "right", context: "direction" })', + options: [{ requireContext: true }], + }, + { + code: 'msg({ message: "right", context: "correctness" })', + options: [{ requireContext: true }], + }, + { + code: 'defineMessage({ message: "right", context: "direction" })', + options: [{ requireContext: true }], + }, + // String literal key 'context' — treated like identifier key + { + code: "t({ message: 'right', 'context': 'direction' })", + options: [{ requireContext: true }], + }, + // Call expression with no arguments — early return, no error + { + code: 't()', + options: [{ requireContext: true }], + }, + // Call expression with non-object argument — early return, no error + { + code: 't("Hello")', + options: [{ requireContext: true }], + }, + ], + invalid: [ + // ─── Base rule (no options) — id is forbidden ───────────────────── + + // Trans with id — forbidden + { + code: 'Hello', + errors: [{ messageId: 'forbiddenIdTrans' }], + }, + { + code: 'Hello World', + errors: [{ messageId: 'forbiddenIdTrans' }], + }, + { + code: 'Hello', + errors: [{ messageId: 'forbiddenIdTrans' }], + }, + { + code: 'Hello', + errors: [{ messageId: 'forbiddenIdTrans' }], + }, + + // Call expressions with id — forbidden + { + code: 't({ id: "msg.hello", message: "Hello" })', + errors: [{ messageId: 'forbiddenIdCall' }], + }, + { + code: 'msg({ id: "msg.hello", message: "Hello" })', + errors: [{ messageId: 'forbiddenIdCall' }], + }, + { + code: 'defineMessage({ id: "msg.hello", message: "Hello" })', + errors: [{ messageId: 'forbiddenIdCall' }], + }, + // String literal key 'id' — still forbidden + { + code: "t({ 'id': 'msg.hello', message: 'Hello' })", + errors: [{ messageId: 'forbiddenIdCall' }], + }, + // Expression id — still forbidden + { + code: 't({ id: someVar, message: "Hello" })', + errors: [{ messageId: 'forbiddenIdCall' }], + }, + + // ─── requireContext: true — missing context ─────────────────────── + + // Trans missing context + { + code: 'Right', + options: [{ requireContext: true }], + errors: [{ messageId: 'missingContextTrans' }], + }, + { + code: 'Right', + options: [{ requireContext: true }], + errors: [{ messageId: 'missingContextTrans' }], + }, + + // Call expressions missing context + { + code: 't({ message: "right" })', + options: [{ requireContext: true }], + errors: [{ messageId: 'missingContextCall' }], + }, + { + code: 'msg({ message: "right" })', + options: [{ requireContext: true }], + errors: [{ messageId: 'missingContextCall' }], + }, + { + code: 'defineMessage({ message: "right" })', + options: [{ requireContext: true }], + errors: [{ messageId: 'missingContextCall' }], + }, + + // Tagged templates — can't carry context, must use function call form + { + code: 't`right`', + options: [{ requireContext: true }], + errors: [{ messageId: 'noContextInTaggedTemplate' }], + }, + { + code: 'msg`right`', + options: [{ requireContext: true }], + errors: [{ messageId: 'noContextInTaggedTemplate' }], + }, + { + code: 'defineMessage`right`', + options: [{ requireContext: true }], + errors: [{ messageId: 'noContextInTaggedTemplate' }], + }, + + // ─── requireContext: true — both id forbidden AND context missing ─ + + // Trans with id but no context — two errors + { + code: 'Hello', + options: [{ requireContext: true }], + errors: [{ messageId: 'missingContextTrans' }, { messageId: 'forbiddenIdTrans' }], + }, + + // Call expression with id but no context — two errors + { + code: 't({ id: "msg.hello", message: "Hello" })', + options: [{ requireContext: true }], + errors: [{ messageId: 'missingContextCall' }, { messageId: 'forbiddenIdCall' }], + }, + + // Call expression with id and context — only id error + { + code: 't({ id: "msg.hello", message: "Hello", context: "greeting" })', + options: [{ requireContext: true }], + errors: [{ messageId: 'forbiddenIdCall' }], + }, + ], +}) + + + From b73de686fa5dea5f6a3ac6c4361aaf787976a6d4 Mon Sep 17 00:00:00 2001 From: G Wolpert Date: Wed, 15 Apr 2026 10:59:15 +0200 Subject: [PATCH 2/5] fmt: run prettier formatter --- README.md | 1 + docs/rules/require-implicit-id.md | 9 +++--- src/helpers.test.ts | 4 ++- src/rules/require-explicit-id.ts | 35 ++++++++++----------- src/rules/require-implicit-id.ts | 5 +-- tests/src/rules/require-explicit-id.test.ts | 10 ++++++ tests/src/rules/require-implicit-id.test.ts | 13 -------- 7 files changed, 36 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 8862d9d..b525ca2 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,4 @@ Alternatively, add `lingui` to the plugins section, and configure the rules you - [consistent-plural-format](docs/rules/consistent-plural-format.md) - [no-plural-inside-trans](docs/rules/no-plural-inside-trans.md) - [require-explicit-id](docs/rules/require-explicit-id.md) +- [require-implicit-id](docs/rules/require-implicit-id.md) diff --git a/docs/rules/require-implicit-id.md b/docs/rules/require-implicit-id.md index fc7b4c4..a9dd87c 100644 --- a/docs/rules/require-implicit-id.md +++ b/docs/rules/require-implicit-id.md @@ -59,13 +59,12 @@ This is particularly useful for ambiguous terms: // "right" as a direction const ex1 = msg({ message: `right`, - context: "direction", -}); + context: 'direction', +}) // "right" as correctness const ex2 = msg({ message: `right`, - context: "correctness", -}); + context: 'correctness', +}) ``` - diff --git a/src/helpers.test.ts b/src/helpers.test.ts index 5ed5991..65b4289 100644 --- a/src/helpers.test.ts +++ b/src/helpers.test.ts @@ -68,7 +68,9 @@ describe('findObjectProperty', () => { }) it('should find the correct property among several', () => { - const obj = buildObjectExpression('({ id: "msg.hello", message: "Hello", context: "greeting" })') + const obj = buildObjectExpression( + '({ id: "msg.hello", message: "Hello", context: "greeting" })', + ) expect(findObjectProperty(obj, 'id')).toBeDefined() expect(findObjectProperty(obj, 'message')).toBeDefined() diff --git a/src/rules/require-explicit-id.ts b/src/rules/require-explicit-id.ts index fd15074..2a05995 100644 --- a/src/rules/require-explicit-id.ts +++ b/src/rules/require-explicit-id.ts @@ -55,9 +55,8 @@ export const rule = createRule({ options: [option], } = context - const rulePatterns = option?.patterns?.map( - (pattern: string) => new RegExp(pattern, option?.flags), - ) + const flags = option?.flags + const rulePatterns = option?.patterns?.map((pattern: string) => new RegExp(pattern, flags)) function validatePattern(node: TSESTree.Node, idValue: string) { if (!rulePatterns?.length) { @@ -87,21 +86,21 @@ export const rule = createRule({ // Only validate string literal values; skip complex expressions silently let idValue: string | null = null - const attVal = idAttr.value; + const attrVal = idAttr.value + + // id="msg.hello" — plain string literal if ( - idAttr.value && - idAttr.value.type === TSESTree.AST_NODE_TYPES.Literal && - typeof idAttr.value.value === 'string' - ) { - idValue = idAttr.value.value - } else if ( - idAttr.value && - idAttr.value.type === TSESTree.AST_NODE_TYPES.JSXExpressionContainer && - idAttr.value.expression.type === TSESTree.AST_NODE_TYPES.Literal && - typeof idAttr.value.expression.value === 'string' + attrVal?.type === TSESTree.AST_NODE_TYPES.Literal && + typeof attrVal.value === 'string' ) { - // Handle id={"msg.hello"} the same as id="msg.hello" - idValue = idAttr.value.expression.value + idValue = attrVal.value + + // id={"msg.hello"} — string literal wrapped in a JSX expression container + } else if (attrVal?.type === TSESTree.AST_NODE_TYPES.JSXExpressionContainer) { + const expr = attrVal.expression + if (expr.type === TSESTree.AST_NODE_TYPES.Literal && typeof expr.value === 'string') { + idValue = expr.value + } } if (idValue == null) { @@ -113,8 +112,8 @@ export const rule = createRule({ [LinguiTaggedTemplateExpressionMessageQuery](node: TSESTree.TemplateLiteral) { const parent = node.parent as TSESTree.TaggedTemplateExpression - const fn = - parent.tag.type === TSESTree.AST_NODE_TYPES.Identifier ? parent.tag.name : 'function' + // The AST query guarantees tag is an Identifier (t, msg, defineMessage) + const fn = (parent.tag as TSESTree.Identifier).name context.report({ node: parent, diff --git a/src/rules/require-implicit-id.ts b/src/rules/require-implicit-id.ts index 7f24f1b..3beff58 100644 --- a/src/rules/require-implicit-id.ts +++ b/src/rules/require-implicit-id.ts @@ -14,7 +14,6 @@ export type Option = { export const name = 'require-implicit-id' - export const rule = createRule({ name, meta: { @@ -28,8 +27,7 @@ export const rule = createRule({ " must not have an explicit 'id' attribute; use auto-generated IDs instead", forbiddenIdCall: "Macro function call must not have an explicit 'id' property; use auto-generated IDs instead", - missingContextTrans: - " requires a 'context' attribute to disambiguate translations", + missingContextTrans: " requires a 'context' attribute to disambiguate translations", missingContextCall: "Macro function call requires a 'context' property to disambiguate translations", noContextInTaggedTemplate: @@ -97,4 +95,3 @@ export const rule = createRule({ } }, }) - diff --git a/tests/src/rules/require-explicit-id.test.ts b/tests/src/rules/require-explicit-id.test.ts index c29544a..bae305a 100644 --- a/tests/src/rules/require-explicit-id.test.ts +++ b/tests/src/rules/require-explicit-id.test.ts @@ -47,6 +47,16 @@ ruleTester.run(name, rule, { code: 'Hello', options: [{ patterns: ['^msg\\.'] }], }, + // boolean id attribute — silently skipped (no string value to validate) + { + code: 'Hello', + options: [{ patterns: ['^msg\\.'] }], + }, + // non-string expression — silently skipped + { + code: 'Hello', + options: [{ patterns: ['^msg\\.'] }], + }, // flags option — case-insensitive match { code: 'Hello', diff --git a/tests/src/rules/require-implicit-id.test.ts b/tests/src/rules/require-implicit-id.test.ts index 9abf801..4b89b5b 100644 --- a/tests/src/rules/require-implicit-id.test.ts +++ b/tests/src/rules/require-implicit-id.test.ts @@ -15,8 +15,6 @@ const ruleTester = new RuleTester({ ruleTester.run(name, rule, { valid: [ - // ─── Base rule (no options) ─────────────────────────────────────── - // Trans without id — valid { code: 'Hello', @@ -61,8 +59,6 @@ ruleTester.run(name, rule, { code: 'defineMessage`Hello`', }, - // ─── requireContext: true ───────────────────────────────────────── - // Trans with context — valid { code: 'Right', @@ -107,8 +103,6 @@ ruleTester.run(name, rule, { }, ], invalid: [ - // ─── Base rule (no options) — id is forbidden ───────────────────── - // Trans with id — forbidden { code: 'Hello', @@ -151,8 +145,6 @@ ruleTester.run(name, rule, { errors: [{ messageId: 'forbiddenIdCall' }], }, - // ─── requireContext: true — missing context ─────────────────────── - // Trans missing context { code: 'Right', @@ -199,8 +191,6 @@ ruleTester.run(name, rule, { errors: [{ messageId: 'noContextInTaggedTemplate' }], }, - // ─── requireContext: true — both id forbidden AND context missing ─ - // Trans with id but no context — two errors { code: 'Hello', @@ -223,6 +213,3 @@ ruleTester.run(name, rule, { }, ], }) - - - From 6079da12f50bb1266f41d02c13933a99b9689330 Mon Sep 17 00:00:00 2001 From: G Wolpert Date: Thu, 16 Apr 2026 00:28:59 +0200 Subject: [PATCH 3/5] Updated implicit-id to no longer include requireContext option --- docs/rules/require-implicit-id.md | 52 --------- src/rules/require-implicit-id.ts | 51 +-------- tests/src/rules/require-implicit-id.test.ts | 112 +------------------- 3 files changed, 4 insertions(+), 211 deletions(-) diff --git a/docs/rules/require-implicit-id.md b/docs/rules/require-implicit-id.md index a9dd87c..6c94076 100644 --- a/docs/rules/require-implicit-id.md +++ b/docs/rules/require-implicit-id.md @@ -16,55 +16,3 @@ t({ id: "msg.hello", message: "Hello" }) t({ message: "Hello" }) t`Hello` ``` - -## Options - -### `requireContext` - -Type: `boolean` -Default: `false` - -When `true`, the rule additionally requires every translation to include a `context` property (for function calls) or attribute (for ``). The `context` value helps translators disambiguate words or phrases that have the same default message but different meanings. - -Tagged template literals (`` t`Hello` ``) don't support `context` — use the function call form instead. - -### Configuration example - -```jsonc -// Only forbid explicit id (default behavior) -"lingui/require-implicit-id": "error" - -// Also require a context property on every translation -"lingui/require-implicit-id": ["error", { "requireContext": true }] -``` - -### Examples with `requireContext: true` - -```jsx -// nope ⛔️ -Right -t({ message: "right" }) -t`right` - -// ok ✅ -Right -Right -t({ message: "right", context: "direction" }) -msg({ message: "right", context: "correctness" }) -``` - -This is particularly useful for ambiguous terms: - -```js -// "right" as a direction -const ex1 = msg({ - message: `right`, - context: 'direction', -}) - -// "right" as correctness -const ex2 = msg({ - message: `right`, - context: 'correctness', -}) -``` diff --git a/src/rules/require-implicit-id.ts b/src/rules/require-implicit-id.ts index 3beff58..a88517b 100644 --- a/src/rules/require-implicit-id.ts +++ b/src/rules/require-implicit-id.ts @@ -4,22 +4,16 @@ import { findJSXAttribute, findObjectProperty, LinguiCallExpressionQuery, - LinguiTaggedTemplateExpressionMessageQuery, LinguiTransQuery, } from '../helpers' -export type Option = { - requireContext?: boolean -} - export const name = 'require-implicit-id' -export const rule = createRule({ +export const rule = createRule<[], string>({ name, meta: { docs: { - description: - "forbid explicit 'id' for Lingui macros and optionally require 'context' for disambiguation", + description: "forbid explicit 'id' for Lingui macros", recommended: 'error', }, messages: { @@ -27,54 +21,19 @@ export const rule = createRule({ " must not have an explicit 'id' attribute; use auto-generated IDs instead", forbiddenIdCall: "Macro function call must not have an explicit 'id' property; use auto-generated IDs instead", - missingContextTrans: " requires a 'context' attribute to disambiguate translations", - missingContextCall: - "Macro function call requires a 'context' property to disambiguate translations", - noContextInTaggedTemplate: - "Tagged template literal doesn't support 'context'. Use {{ fn }}({ message: '...', context: '...' }) instead", }, - schema: [ - { - type: 'object', - properties: { - requireContext: { - type: 'boolean', - }, - }, - additionalProperties: false, - }, - ], + schema: [], type: 'problem' as const, }, defaultOptions: [], create: function (context) { - const requireContext = context.options[0]?.requireContext ?? false - return { [LinguiTransQuery](node: TSESTree.JSXElement) { if (findJSXAttribute(node, 'id')) { context.report({ node: findJSXAttribute(node, 'id')!, messageId: 'forbiddenIdTrans' }) } - - if (requireContext && !findJSXAttribute(node, 'context')) { - context.report({ node, messageId: 'missingContextTrans' }) - } - }, - - [LinguiTaggedTemplateExpressionMessageQuery](node: TSESTree.TemplateLiteral) { - if (!requireContext) return - - const parent = node.parent as TSESTree.TaggedTemplateExpression - // The AST query guarantees tag is an Identifier (t, msg, defineMessage) - const fn = (parent.tag as TSESTree.Identifier).name - - context.report({ - node: parent, - messageId: 'noContextInTaggedTemplate', - data: { fn }, - }) }, [LinguiCallExpressionQuery](node: TSESTree.CallExpression) { @@ -87,10 +46,6 @@ export const rule = createRule({ if (findObjectProperty(arg, 'id')) { context.report({ node: findObjectProperty(arg, 'id')!, messageId: 'forbiddenIdCall' }) } - - if (requireContext && !findObjectProperty(arg, 'context')) { - context.report({ node, messageId: 'missingContextCall' }) - } }, } }, diff --git a/tests/src/rules/require-implicit-id.test.ts b/tests/src/rules/require-implicit-id.test.ts index 4b89b5b..752f1fe 100644 --- a/tests/src/rules/require-implicit-id.test.ts +++ b/tests/src/rules/require-implicit-id.test.ts @@ -48,7 +48,7 @@ ruleTester.run(name, rule, { code: 't("Hello")', }, - // Tagged templates — always valid when requireContext is false + // Tagged templates — always valid { code: 't`Hello`', }, @@ -58,49 +58,6 @@ ruleTester.run(name, rule, { { code: 'defineMessage`Hello`', }, - - // Trans with context — valid - { - code: 'Right', - options: [{ requireContext: true }], - }, - { - code: 'Right', - options: [{ requireContext: true }], - }, - { - code: 'Right', - options: [{ requireContext: true }], - }, - - // Call expressions with context — valid - { - code: 't({ message: "right", context: "direction" })', - options: [{ requireContext: true }], - }, - { - code: 'msg({ message: "right", context: "correctness" })', - options: [{ requireContext: true }], - }, - { - code: 'defineMessage({ message: "right", context: "direction" })', - options: [{ requireContext: true }], - }, - // String literal key 'context' — treated like identifier key - { - code: "t({ message: 'right', 'context': 'direction' })", - options: [{ requireContext: true }], - }, - // Call expression with no arguments — early return, no error - { - code: 't()', - options: [{ requireContext: true }], - }, - // Call expression with non-object argument — early return, no error - { - code: 't("Hello")', - options: [{ requireContext: true }], - }, ], invalid: [ // Trans with id — forbidden @@ -144,72 +101,5 @@ ruleTester.run(name, rule, { code: 't({ id: someVar, message: "Hello" })', errors: [{ messageId: 'forbiddenIdCall' }], }, - - // Trans missing context - { - code: 'Right', - options: [{ requireContext: true }], - errors: [{ messageId: 'missingContextTrans' }], - }, - { - code: 'Right', - options: [{ requireContext: true }], - errors: [{ messageId: 'missingContextTrans' }], - }, - - // Call expressions missing context - { - code: 't({ message: "right" })', - options: [{ requireContext: true }], - errors: [{ messageId: 'missingContextCall' }], - }, - { - code: 'msg({ message: "right" })', - options: [{ requireContext: true }], - errors: [{ messageId: 'missingContextCall' }], - }, - { - code: 'defineMessage({ message: "right" })', - options: [{ requireContext: true }], - errors: [{ messageId: 'missingContextCall' }], - }, - - // Tagged templates — can't carry context, must use function call form - { - code: 't`right`', - options: [{ requireContext: true }], - errors: [{ messageId: 'noContextInTaggedTemplate' }], - }, - { - code: 'msg`right`', - options: [{ requireContext: true }], - errors: [{ messageId: 'noContextInTaggedTemplate' }], - }, - { - code: 'defineMessage`right`', - options: [{ requireContext: true }], - errors: [{ messageId: 'noContextInTaggedTemplate' }], - }, - - // Trans with id but no context — two errors - { - code: 'Hello', - options: [{ requireContext: true }], - errors: [{ messageId: 'missingContextTrans' }, { messageId: 'forbiddenIdTrans' }], - }, - - // Call expression with id but no context — two errors - { - code: 't({ id: "msg.hello", message: "Hello" })', - options: [{ requireContext: true }], - errors: [{ messageId: 'missingContextCall' }, { messageId: 'forbiddenIdCall' }], - }, - - // Call expression with id and context — only id error - { - code: 't({ id: "msg.hello", message: "Hello", context: "greeting" })', - options: [{ requireContext: true }], - errors: [{ messageId: 'forbiddenIdCall' }], - }, ], }) From 791c39026358f146a0e520a2147996844bedf7aa Mon Sep 17 00:00:00 2001 From: G Wolpert Date: Fri, 17 Apr 2026 21:02:04 +0200 Subject: [PATCH 4/5] feat(rules): support ICU components in explicit/implicit id rules Extend both require-explicit-id and require-implicit-id to cover Lingui ICU JSX components (, , ). Add shared query helper, enforce/forbid id consistently, and validate patterns for explicit IDs. Update docs and tests to reflect the new coverage. --- docs/rules/require-explicit-id.md | 4 +- docs/rules/require-implicit-id.md | 4 +- src/helpers.ts | 7 +++ src/rules/require-explicit-id.ts | 60 ++++++++++----------- src/rules/require-implicit-id.ts | 9 ++++ tests/src/rules/require-explicit-id.test.ts | 41 ++++++++++++++ tests/src/rules/require-implicit-id.test.ts | 25 +++++++++ 7 files changed, 118 insertions(+), 32 deletions(-) diff --git a/docs/rules/require-explicit-id.md b/docs/rules/require-explicit-id.md index 0fdca0d..00df2ad 100644 --- a/docs/rules/require-explicit-id.md +++ b/docs/rules/require-explicit-id.md @@ -1,6 +1,6 @@ # require-explicit-id -Enforce that `` components and Lingui macro function calls (`t`, `msg`, `defineMessage`) have an explicit `id`. +Enforce that ``, ``, ``, `` components and Lingui macro function calls (`t`, `msg`, `defineMessage`) do not have an explicit `id`. Relying on auto-generated IDs eliminates the need to maintain a separate mapping of IDs to messages and ensures the catalog stays in sync with the source code. See [Benefits of Generated IDs](https://lingui.dev/guides/explicit-vs-generated-ids#benefits-of-generated-ids) in the Lingui docs for more details. @@ -9,10 +9,12 @@ Relying on auto-generated IDs eliminates the need to maintain a separate mapping ```jsx // nope ⛔️ Hello + t({ id: "msg.hello", message: "Hello" }) // ok ✅ Hello + t({ message: "Hello" }) t`Hello` ``` diff --git a/src/helpers.ts b/src/helpers.ts index 28288d4..5845f64 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -42,6 +42,13 @@ export const LinguiCallExpressionQuery = */ export const LinguiTransQuery = 'JSXElement[openingElement.name.name=Trans]' +/** + * Queries for Lingui ICU components + * , , ', + }, + // ICU component with patterns — id matches + { + code: '', + options: [{ patterns: ['^msg\\.'] }], + }, + // ICU component with expression id and patterns — silently skipped + { + code: '', + options: [{ patterns: ['^msg\\.'] }], + }, ], invalid: [ // Trans — missing id @@ -213,5 +234,25 @@ ruleTester.run(name, rule, { options: [{ patterns: ['^msg\\.'] }], errors: [{ messageId: 'invalidPattern' }], }, + + // ICU components — missing id + { + code: '', + errors: [{ messageId: 'missingIdIcu' }], + }, + { + code: '', + errors: [{ messageId: 'missingIdIcu' }], + }, + { + code: '', + }, ], invalid: [ // Trans with id — forbidden @@ -101,5 +112,19 @@ ruleTester.run(name, rule, { code: 't({ id: someVar, message: "Hello" })', errors: [{ messageId: 'forbiddenIdCall' }], }, + + // ICU components with id — forbidden + { + code: '', + errors: [{ messageId: 'forbiddenIdIcu' }], + }, + { + code: '', + errors: [{ messageId: 'forbiddenIdIcu' }], + }, + { + code: '