@@ -53,6 +53,9 @@ const DEFAULT_IGNORE_NAMES = ["__DEV__", "NODE_ENV"]
5353// Lingui Context Detection
5454// ============================================================================
5555
56+ /** Known Lingui package prefixes */
57+ const LINGUI_PACKAGES = [ "@lingui/macro" , "@lingui/react" , "@lingui/core" ]
58+
5659/** Tagged template macros from Lingui */
5760const LINGUI_TAGGED_TEMPLATES = new Set ( [ "t" ] )
5861
@@ -62,16 +65,90 @@ const LINGUI_JSX_COMPONENTS = new Set(["Trans", "Plural", "Select", "SelectOrdin
6265/** Function-style macros from Lingui */
6366const LINGUI_FUNCTION_MACROS = new Set ( [ "msg" , "defineMessage" , "plural" , "select" , "selectOrdinal" ] )
6467
68+ /**
69+ * Checks if a symbol originates from a Lingui package using TypeScript's type system.
70+ *
71+ * This is more reliable than just checking the name because:
72+ * - It works with re-exports
73+ * - It doesn't match unrelated functions with the same name
74+ * - It handles aliased imports
75+ *
76+ * Returns:
77+ * - true: Definitely from Lingui
78+ * - false: Definitely NOT from Lingui (different module) or unknown
79+ * - null: No type info available (use name-based fallback)
80+ */
81+ function isLinguiSymbol (
82+ node : TSESTree . Node ,
83+ typeChecker : ts . TypeChecker ,
84+ parserServices : ReturnType < typeof ESLintUtils . getParserServices >
85+ ) : boolean | null {
86+ try {
87+ const tsNode = parserServices . esTreeNodeToTSNodeMap . get ( node )
88+ const symbol = typeChecker . getSymbolAtLocation ( tsNode )
89+
90+ if ( symbol === undefined ) {
91+ // No symbol info - use name-based fallback
92+ return null
93+ }
94+
95+ // Follow aliases to get the original declaration
96+ const resolvedSymbol = symbol . flags & 2097152 ? typeChecker . getAliasedSymbol ( symbol ) : symbol
97+
98+ const declarations = resolvedSymbol . getDeclarations ( )
99+ if ( declarations === undefined || declarations . length === 0 ) {
100+ // No declarations - use name-based fallback
101+ return null
102+ }
103+
104+ // Check if any declaration comes from a Lingui package
105+ for ( const decl of declarations ) {
106+ const sourceFile = decl . getSourceFile ( )
107+ const fileName = sourceFile . fileName
108+
109+ if ( LINGUI_PACKAGES . some ( ( pkg ) => fileName . includes ( `node_modules/${ pkg } /` ) ) ) {
110+ return true
111+ }
112+ }
113+
114+ // Has declarations but none from Lingui - this is NOT a Lingui symbol
115+ // However, if declarations are only from the current file (no module info),
116+ // we should fallback to name matching
117+ const hasExternalDeclaration = declarations . some ( ( decl ) => {
118+ const fileName = decl . getSourceFile ( ) . fileName
119+ return fileName . includes ( "node_modules/" )
120+ } )
121+
122+ if ( hasExternalDeclaration ) {
123+ // Definitely from a different package
124+ return false
125+ }
126+
127+ // Local declaration or ambient - use name-based fallback
128+ return null
129+ } catch {
130+ // Type checking can fail, fall back to name-based matching
131+ return null
132+ }
133+ }
134+
65135/**
66136 * Checks if a node is inside any Lingui localization context.
67137 *
138+ * Uses TypeScript type information to verify that macros actually come from Lingui,
139+ * with a fallback to name-based matching for edge cases.
140+ *
68141 * This includes:
69142 * - Tagged templates: t`Hello`
70143 * - JSX components: <Trans>, <Plural>, <Select>, <SelectOrdinal>
71144 * - Function macros: msg(), defineMessage(), plural(), select(), selectOrdinal()
72145 * - Runtime API: i18n.t(), i18n._()
73146 */
74- function isInsideLinguiContext ( node : TSESTree . Node ) : boolean {
147+ function isInsideLinguiContext (
148+ node : TSESTree . Node ,
149+ typeChecker : ts . TypeChecker ,
150+ parserServices : ReturnType < typeof ESLintUtils . getParserServices >
151+ ) : boolean {
75152 let current : TSESTree . Node | undefined = node . parent ?? undefined
76153
77154 while ( current !== undefined ) {
@@ -81,7 +158,11 @@ function isInsideLinguiContext(node: TSESTree.Node): boolean {
81158 current . tag . type === AST_NODE_TYPES . Identifier &&
82159 LINGUI_TAGGED_TEMPLATES . has ( current . tag . name )
83160 ) {
84- return true
161+ // Verify it's actually from Lingui (or use name-based fallback)
162+ const isLingui = isLinguiSymbol ( current . tag , typeChecker , parserServices )
163+ if ( isLingui === true || isLingui === null ) {
164+ return true
165+ }
85166 }
86167
87168 // JSX components: <Trans>Hello</Trans>, <Plural value={n} ... />
@@ -90,7 +171,11 @@ function isInsideLinguiContext(node: TSESTree.Node): boolean {
90171 current . openingElement . name . type === AST_NODE_TYPES . JSXIdentifier &&
91172 LINGUI_JSX_COMPONENTS . has ( current . openingElement . name . name )
92173 ) {
93- return true
174+ // Verify it's actually from Lingui (or use name-based fallback)
175+ const isLingui = isLinguiSymbol ( current . openingElement . name , typeChecker , parserServices )
176+ if ( isLingui === true || isLingui === null ) {
177+ return true
178+ }
94179 }
95180
96181 // Function macros: msg({ message: "Hello" }), plural(n, {...})
@@ -99,19 +184,36 @@ function isInsideLinguiContext(node: TSESTree.Node): boolean {
99184 current . callee . type === AST_NODE_TYPES . Identifier &&
100185 LINGUI_FUNCTION_MACROS . has ( current . callee . name )
101186 ) {
102- return true
187+ // Verify it's actually from Lingui (or use name-based fallback)
188+ const isLingui = isLinguiSymbol ( current . callee , typeChecker , parserServices )
189+ if ( isLingui === true || isLingui === null ) {
190+ return true
191+ }
103192 }
104193
105194 // Runtime API: i18n.t({ message: "Hello" }), i18n._("Hello")
106195 if (
107196 current . type === AST_NODE_TYPES . CallExpression &&
108197 current . callee . type === AST_NODE_TYPES . MemberExpression &&
109198 current . callee . object . type === AST_NODE_TYPES . Identifier &&
110- current . callee . object . name === "i18n" &&
111199 current . callee . property . type === AST_NODE_TYPES . Identifier &&
112200 ( current . callee . property . name === "t" || current . callee . property . name === "_" )
113201 ) {
114- return true
202+ // Check if the object is of type I18n from Lingui
203+ try {
204+ const objectTsNode = parserServices . esTreeNodeToTSNodeMap . get ( current . callee . object )
205+ const objectType = typeChecker . getTypeAtLocation ( objectTsNode )
206+ const typeName = typeChecker . typeToString ( objectType )
207+ if ( typeName === "I18n" ) {
208+ return true
209+ }
210+ } catch {
211+ // Type info not available
212+ }
213+ // Fallback: check if variable is named "i18n"
214+ if ( current . callee . object . name === "i18n" ) {
215+ return true
216+ }
115217 }
116218
117219 current = current . parent ?? undefined
@@ -678,7 +780,7 @@ export const noUnlocalizedStrings = createRule<[Options], MessageId>({
678780 }
679781
680782 // Already inside Lingui localization
681- if ( isInsideLinguiContext ( node ) ) {
783+ if ( isInsideLinguiContext ( node , typeChecker , parserServices ) ) {
682784 return
683785 }
684786
@@ -765,7 +867,7 @@ export const noUnlocalizedStrings = createRule<[Options], MessageId>({
765867 return
766868 }
767869
768- if ( isInsideLinguiContext ( node ) ) {
870+ if ( isInsideLinguiContext ( node , typeChecker , parserServices ) ) {
769871 return
770872 }
771873
0 commit comments