diff --git a/README.md b/README.md
index 8862d9d..d76f6fb 100644
--- a/README.md
+++ b/README.md
@@ -107,4 +107,5 @@ Alternatively, add `lingui` to the plugins section, and configure the rules you
- [text-restrictions](docs/rules/text-restrictions.md)
- [consistent-plural-format](docs/rules/consistent-plural-format.md)
- [no-plural-inside-trans](docs/rules/no-plural-inside-trans.md)
+- [require-explicit-comment](docs/rules/require-explicit-comment.md)
- [require-explicit-id](docs/rules/require-explicit-id.md)
diff --git a/docs/rules/require-explicit-comment.md b/docs/rules/require-explicit-comment.md
new file mode 100644
index 0000000..6c1969e
--- /dev/null
+++ b/docs/rules/require-explicit-comment.md
@@ -0,0 +1,39 @@
+# require-explicit-comment
+
+Enforce that Lingui message declarations provide an explicit `comment` for translators, unless `context` is already provided.
+
+Translator comments improve translation quality by giving additional intent about where and how a string is used.
+
+Tagged template literals (`` t`Hello` ``) don't support `comment` - use the function call form instead.
+
+```jsx
+// nope ⛔️
+Hello
+
+t`Hello`
+t({ message: "Hello" })
+
+// ok ✅
+Hello
+
+
+
+t({ comment: "Homepage greeting", message: "Hello" })
+
+// also ok ✅ (context exempts comment)
+Hello
+t({ context: "homepage", message: "Hello" })
+```
+
+## What this rule checks
+
+- `t({...})`, `msg({...})`, `defineMessage({...})`:
+ - require `comment` if `context` is not present
+- ``, ``, ``, ``:
+ - require `comment` prop if `context` prop is not present
+- `` t`...` ``, `` msg`...` ``, `` defineMessage`...` ``:
+ - always invalid because tagged template form cannot carry `comment`
+
+## Notes
+
+- `comment` validation is presence-only: any value type is accepted (including expressions).
diff --git a/src/helpers.ts b/src/helpers.ts
index f50d8af..af87523 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -52,6 +52,11 @@ export const LinguiCallExpressionPluralQuery = 'CallExpression[callee.name=plura
export const LinguiPluralComponentQuery = 'JSXElement[openingElement.name.name=Plural]'
+export const LinguiSelectComponentQuery = 'JSXElement[openingElement.name.name=Select]'
+
+export const LinguiSelectOrdinalComponentQuery =
+ 'JSXElement[openingElement.name.name=SelectOrdinal]'
+
export function isNativeDOMTag(str: string) {
return DOM_TAGS.includes(str)
}
diff --git a/src/index.ts b/src/index.ts
index ea53a81..c4aa1ba 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 requireExplicitCommentRule from './rules/require-explicit-comment'
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,
+ [requireExplicitCommentRule.name]: requireExplicitCommentRule.rule,
}
type RuleKey = keyof typeof rules
diff --git a/src/rules/require-explicit-comment.ts b/src/rules/require-explicit-comment.ts
new file mode 100644
index 0000000..f549688
--- /dev/null
+++ b/src/rules/require-explicit-comment.ts
@@ -0,0 +1,128 @@
+import { TSESTree } from '@typescript-eslint/utils'
+import { createRule } from '../create-rule'
+import {
+ LinguiCallExpressionQuery,
+ LinguiPluralComponentQuery,
+ LinguiSelectComponentQuery,
+ LinguiSelectOrdinalComponentQuery,
+ LinguiTaggedTemplateExpressionMessageQuery,
+ LinguiTransQuery,
+} from '../helpers'
+
+export const name = 'require-explicit-comment'
+export const rule = createRule<[], string>({
+ name,
+ meta: {
+ docs: {
+ description:
+ "enforce 'comment' property or attribute for Lingui macros, unless 'context' is provided",
+ recommended: 'error',
+ },
+ messages: {
+ missingCommentJsx:
+ '{{ component }} requires an explicit `comment` prop when `context` is absent',
+ missingCommentCall:
+ "Macro function call requires an explicit 'comment' property when 'context' is absent",
+ noCommentInTaggedTemplate:
+ "Tagged template literal doesn't support 'comment'. Use {{ fn }}({ comment: '...', message: '...' }) or provide `context` instead",
+ },
+ schema: [],
+ type: 'problem' as const,
+ },
+
+ defaultOptions: [],
+
+ create: function (context) {
+ const getJSXProp = (node: TSESTree.JSXElement, propName: string) =>
+ 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 === propName,
+ )
+
+ const getObjectProp = (node: TSESTree.ObjectExpression, propName: string) =>
+ node.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)),
+ )
+
+ const checkJSXComment = (node: TSESTree.JSXElement) => {
+ const hasContext = Boolean(getJSXProp(node, 'context'))
+ if (hasContext) {
+ return
+ }
+
+ const hasComment = Boolean(getJSXProp(node, 'comment'))
+ if (hasComment) {
+ return
+ }
+
+ const component =
+ node.openingElement.name.type === TSESTree.AST_NODE_TYPES.JSXIdentifier
+ ? node.openingElement.name.name
+ : 'Component'
+
+ context.report({
+ node,
+ messageId: 'missingCommentJsx',
+ data: { component },
+ })
+ }
+
+ return {
+ [LinguiTransQuery](node: TSESTree.JSXElement) {
+ checkJSXComment(node)
+ },
+
+ [LinguiPluralComponentQuery](node: TSESTree.JSXElement) {
+ checkJSXComment(node)
+ },
+
+ [LinguiSelectComponentQuery](node: TSESTree.JSXElement) {
+ checkJSXComment(node)
+ },
+
+ [LinguiSelectOrdinalComponentQuery](node: TSESTree.JSXElement) {
+ checkJSXComment(node)
+ },
+
+ [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'
+
+ context.report({
+ node: parent,
+ messageId: 'noCommentInTaggedTemplate',
+ data: { fn },
+ })
+ },
+
+ [LinguiCallExpressionQuery](node: TSESTree.CallExpression) {
+ const arg = node.arguments[0]
+
+ if (!arg || arg.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
+ return
+ }
+
+ const hasContext = Boolean(getObjectProp(arg, 'context'))
+ if (hasContext) {
+ return
+ }
+
+ const hasComment = Boolean(getObjectProp(arg, 'comment'))
+ if (hasComment) {
+ return
+ }
+
+ context.report({
+ node,
+ messageId: 'missingCommentCall',
+ })
+ },
+ }
+ },
+})
diff --git a/tests/src/rules/require-explicit-comment.test.ts b/tests/src/rules/require-explicit-comment.test.ts
new file mode 100644
index 0000000..37fad39
--- /dev/null
+++ b/tests/src/rules/require-explicit-comment.test.ts
@@ -0,0 +1,131 @@
+import { rule, name } from '../../../src/rules/require-explicit-comment'
+import { RuleTester } from '@typescript-eslint/rule-tester'
+
+describe('', () => {})
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+})
+
+ruleTester.run(name, rule, {
+ valid: [
+ // Call expressions with explicit comment
+ {
+ code: 't({ comment: "Greeting", message: "Hello" })',
+ },
+ {
+ code: 'msg({ comment: "Greeting", message: "Hello" })',
+ },
+ {
+ code: 'defineMessage({ comment: "Greeting", message: "Hello" })',
+ },
+ // Presence-only: non-literal comment values are accepted
+ {
+ code: 't({ comment: commentHint, message: "Hello" })',
+ },
+ {
+ code: 't({ comment: getHint(), message: "Hello" })',
+ },
+ // context allows omitting comment
+ {
+ code: 't({ context: "homepage", message: "Hello" })',
+ },
+ {
+ code: 'defineMessage({ context: "navigation.link", message: "About us" })',
+ },
+ // non-object and no-arg calls are intentionally ignored
+ {
+ code: 't()',
+ },
+ {
+ code: 't("Hello")',
+ },
+
+ // Trans and React macros with explicit comment
+ {
+ code: 'Hello',
+ },
+ {
+ code: '',
+ },
+ {
+ code: '',
+ },
+ {
+ code: '',
+ },
+ // Presence-only: non-literal JSX comment values are accepted
+ {
+ code: 'Hello',
+ },
+ {
+ code: '',
+ },
+ // context allows omitting comment in JSX macros
+ {
+ code: 'Hello',
+ },
+ {
+ code: '',
+ },
+ {
+ code: '',
+ },
+ {
+ code: '',
+ },
+ ],
+ invalid: [
+ // Tagged template literals must switch to object form for comment/context
+ {
+ code: 't`Hello`',
+ errors: [{ messageId: 'noCommentInTaggedTemplate' }],
+ },
+ {
+ code: 'msg`Hello`',
+ errors: [{ messageId: 'noCommentInTaggedTemplate' }],
+ },
+ {
+ code: 'defineMessage`Hello`',
+ errors: [{ messageId: 'noCommentInTaggedTemplate' }],
+ },
+
+ // Call expressions missing both comment and context
+ {
+ code: 't({ message: "Hello" })',
+ errors: [{ messageId: 'missingCommentCall' }],
+ },
+ {
+ code: 'msg({ id: "msg.hello", message: "Hello" })',
+ errors: [{ messageId: 'missingCommentCall' }],
+ },
+ {
+ code: 'defineMessage({ id: "msg.hello", message: "Hello" })',
+ errors: [{ messageId: 'missingCommentCall' }],
+ },
+
+ // JSX macros missing both comment and context
+ {
+ code: 'Hello',
+ errors: [{ messageId: 'missingCommentJsx' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingCommentJsx' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingCommentJsx' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingCommentJsx' }],
+ },
+ ],
+})