react-i18next-scanner 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ // src/core/actions/writePossiblyDynamicallyUsedReport.ts
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { addKey } from "../utils/addKey.js";
5
+ import { sortObject } from "../utils/sortObject.js";
6
+ const DEFAULT_OUTPUT_DIR = path.resolve(process.cwd(), "reports-dynamic");
7
+ export async function writePossiblyDynamicallyUsedReport(keysByFile, jsonFiles, outputDir = DEFAULT_OUTPUT_DIR) {
8
+ const reportDataByFile = {};
9
+ for (const file of jsonFiles) {
10
+ const possibleKeys = keysByFile[file.filePath] ?? [];
11
+ if (possibleKeys.length === 0) {
12
+ continue;
13
+ }
14
+ const nestedData = {};
15
+ for (const key of possibleKeys) {
16
+ const value = getValueByPath(file.data, key);
17
+ if (value !== undefined) {
18
+ addKey(nestedData, key, value);
19
+ }
20
+ }
21
+ const relativeTranslationFilePath = path
22
+ .relative(process.cwd(), file.filePath)
23
+ .replace(/\\/g, "/");
24
+ reportDataByFile[relativeTranslationFilePath] = sortObject(nestedData);
25
+ }
26
+ if (Object.keys(reportDataByFile).length === 0) {
27
+ return;
28
+ }
29
+ await fs.mkdir(outputDir, { recursive: true });
30
+ const reportPath = path.join(outputDir, "possibly-dynamically-used.json");
31
+ await fs.writeFile(reportPath, `${JSON.stringify(sortObject(reportDataByFile), null, 2)}\n`, "utf-8");
32
+ console.log(`📝 Possibly dynamically used report created: ${reportPath}`);
33
+ }
34
+ function getValueByPath(obj, keyPath) {
35
+ const parts = keyPath.split(".");
36
+ let current = obj;
37
+ for (const part of parts) {
38
+ if (typeof current !== "object" ||
39
+ current === null ||
40
+ Array.isArray(current) ||
41
+ !(part in current)) {
42
+ return undefined;
43
+ }
44
+ current = current[part];
45
+ }
46
+ return current;
47
+ }
@@ -2,18 +2,17 @@ import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import { addKey } from "../utils/addKey.js";
4
4
  import { sortObject } from "../utils/sortObject.js";
5
- export async function writeUnusedTranslationsReport(result, jsonFiles, outputDir) {
6
- const { unusedKeysByFile } = result;
5
+ export async function writeUnusedTranslationsReport(keysByFile, jsonFiles, outputDir) {
7
6
  const filesWithUnused = jsonFiles.filter((file) => {
8
- const unusedKeys = unusedKeysByFile[file.filePath] ?? [];
9
- return unusedKeys.length > 0;
7
+ const keys = keysByFile[file.filePath] ?? [];
8
+ return keys.length > 0;
10
9
  });
11
10
  if (filesWithUnused.length === 0) {
12
11
  return;
13
12
  }
14
13
  await fs.mkdir(outputDir, { recursive: true });
15
14
  for (const file of filesWithUnused) {
16
- const unusedKeys = unusedKeysByFile[file.filePath] ?? [];
15
+ const unusedKeys = keysByFile[file.filePath] ?? [];
17
16
  const reportData = {};
18
17
  for (const key of unusedKeys) {
19
18
  const value = getValueByPath(file.data, key);
@@ -22,15 +21,12 @@ export async function writeUnusedTranslationsReport(result, jsonFiles, outputDir
22
21
  }
23
22
  }
24
23
  const sorted = sortObject(reportData);
25
- const reportFileName = getUnusedReportFileName(file.filePath);
24
+ const reportFileName = `unused.${path.basename(file.filePath)}`;
26
25
  const reportPath = path.join(outputDir, reportFileName);
27
26
  await fs.writeFile(reportPath, JSON.stringify(sorted, null, 2), "utf-8");
28
27
  console.log(`📝 Unused report created: ${reportPath}`);
29
28
  }
30
29
  }
31
- function getUnusedReportFileName(filePath) {
32
- return `unused.${path.basename(filePath)}`;
33
- }
34
30
  function getValueByPath(obj, keyPath) {
35
31
  const parts = keyPath.split(".");
36
32
  let current = obj;
@@ -0,0 +1,266 @@
1
+ import fs from "fs/promises";
2
+ import ts from "typescript";
3
+ const KEY_REGEX = /^[A-Za-z0-9_-]+(?:[.:][A-Za-z0-9_-]+)*$/;
4
+ function getScriptKind(filePath) {
5
+ if (filePath.endsWith(".tsx"))
6
+ return ts.ScriptKind.TSX;
7
+ if (filePath.endsWith(".ts"))
8
+ return ts.ScriptKind.TS;
9
+ if (filePath.endsWith(".jsx"))
10
+ return ts.ScriptKind.JSX;
11
+ return ts.ScriptKind.JS;
12
+ }
13
+ function createSourceFile(filePath, content) {
14
+ return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
15
+ }
16
+ function isTranslationCall(node) {
17
+ if (ts.isIdentifier(node.expression)) {
18
+ return node.expression.text === "t";
19
+ }
20
+ if (ts.isPropertyAccessExpression(node.expression)) {
21
+ return node.expression.name.text === "t";
22
+ }
23
+ return false;
24
+ }
25
+ function isDirectLiteral(node) {
26
+ return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
27
+ }
28
+ function isTransTagName(name) {
29
+ return ts.isIdentifier(name) && name.text === "Trans";
30
+ }
31
+ function getJsxAttribute(attributes, attributeName) {
32
+ for (const prop of attributes.properties) {
33
+ if (ts.isJsxAttribute(prop) &&
34
+ ts.isIdentifier(prop.name) &&
35
+ prop.name.text === attributeName) {
36
+ return prop;
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+ function getJsxLiteralAttributeValue(attribute) {
42
+ if (!attribute?.initializer) {
43
+ return undefined;
44
+ }
45
+ if (ts.isStringLiteral(attribute.initializer)) {
46
+ return attribute.initializer.text;
47
+ }
48
+ if (ts.isJsxExpression(attribute.initializer) &&
49
+ attribute.initializer.expression &&
50
+ (ts.isStringLiteral(attribute.initializer.expression) ||
51
+ ts.isNoSubstitutionTemplateLiteral(attribute.initializer.expression))) {
52
+ return attribute.initializer.expression.text;
53
+ }
54
+ return undefined;
55
+ }
56
+ function splitKey(fullKey) {
57
+ const firstDotIndex = fullKey.indexOf(".");
58
+ if (firstDotIndex === -1) {
59
+ return { localKey: fullKey };
60
+ }
61
+ return {
62
+ namespace: fullKey.slice(0, firstDotIndex),
63
+ localKey: fullKey.slice(firstDotIndex + 1),
64
+ };
65
+ }
66
+ function extractNamespaces(sourceFile) {
67
+ const namespaces = new Set();
68
+ function visit(node) {
69
+ if (ts.isCallExpression(node) &&
70
+ ts.isIdentifier(node.expression) &&
71
+ node.expression.text === "useTranslation") {
72
+ const firstArg = node.arguments[0];
73
+ if (firstArg) {
74
+ if (ts.isStringLiteral(firstArg) ||
75
+ ts.isNoSubstitutionTemplateLiteral(firstArg)) {
76
+ namespaces.add(firstArg.text);
77
+ }
78
+ if (ts.isArrayLiteralExpression(firstArg)) {
79
+ for (const element of firstArg.elements) {
80
+ if (ts.isStringLiteral(element) ||
81
+ ts.isNoSubstitutionTemplateLiteral(element)) {
82
+ namespaces.add(element.text);
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
89
+ const nsValue = getJsxLiteralAttributeValue(getJsxAttribute(node.attributes, "ns"));
90
+ if (nsValue) {
91
+ namespaces.add(nsValue);
92
+ }
93
+ }
94
+ if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
95
+ const nsValue = getJsxLiteralAttributeValue(getJsxAttribute(node.attributes, "ns"));
96
+ if (nsValue) {
97
+ namespaces.add(nsValue);
98
+ }
99
+ }
100
+ ts.forEachChild(node, visit);
101
+ }
102
+ visit(sourceFile);
103
+ return namespaces;
104
+ }
105
+ function collectLiteralKeys(sourceFile) {
106
+ const keys = new Set();
107
+ function visit(node) {
108
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
109
+ const value = node.text.trim();
110
+ if (KEY_REGEX.test(value)) {
111
+ keys.add(value);
112
+ }
113
+ }
114
+ ts.forEachChild(node, visit);
115
+ }
116
+ visit(sourceFile);
117
+ return keys;
118
+ }
119
+ function collectTemplatePrefixes(sourceFile) {
120
+ const prefixes = new Set();
121
+ function addPrefix(prefix) {
122
+ const normalized = prefix.trim();
123
+ if (!normalized) {
124
+ return;
125
+ }
126
+ prefixes.add(normalized);
127
+ }
128
+ function visit(node) {
129
+ if (ts.isCallExpression(node) && isTranslationCall(node)) {
130
+ const firstArg = node.arguments[0];
131
+ if (firstArg && ts.isTemplateExpression(firstArg)) {
132
+ addPrefix(firstArg.head.text);
133
+ }
134
+ }
135
+ if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
136
+ const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
137
+ if (i18nKeyAttr?.initializer &&
138
+ ts.isJsxExpression(i18nKeyAttr.initializer) &&
139
+ i18nKeyAttr.initializer.expression &&
140
+ ts.isTemplateExpression(i18nKeyAttr.initializer.expression)) {
141
+ addPrefix(i18nKeyAttr.initializer.expression.head.text);
142
+ }
143
+ }
144
+ if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
145
+ const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
146
+ if (i18nKeyAttr?.initializer &&
147
+ ts.isJsxExpression(i18nKeyAttr.initializer) &&
148
+ i18nKeyAttr.initializer.expression &&
149
+ ts.isTemplateExpression(i18nKeyAttr.initializer.expression)) {
150
+ addPrefix(i18nKeyAttr.initializer.expression.head.text);
151
+ }
152
+ }
153
+ ts.forEachChild(node, visit);
154
+ }
155
+ visit(sourceFile);
156
+ return [...prefixes];
157
+ }
158
+ function detectDynamicTranslationUsage(sourceFile) {
159
+ let found = false;
160
+ function visit(node) {
161
+ if (found) {
162
+ return;
163
+ }
164
+ if (ts.isCallExpression(node) && isTranslationCall(node)) {
165
+ const firstArg = node.arguments[0];
166
+ if (firstArg && !isDirectLiteral(firstArg)) {
167
+ found = true;
168
+ return;
169
+ }
170
+ }
171
+ if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
172
+ const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
173
+ if (i18nKeyAttr?.initializer &&
174
+ ts.isJsxExpression(i18nKeyAttr.initializer) &&
175
+ i18nKeyAttr.initializer.expression) {
176
+ const expr = i18nKeyAttr.initializer.expression;
177
+ if (!isDirectLiteral(expr)) {
178
+ found = true;
179
+ return;
180
+ }
181
+ }
182
+ }
183
+ if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
184
+ const i18nKeyAttr = getJsxAttribute(node.attributes, "i18nKey");
185
+ if (i18nKeyAttr?.initializer &&
186
+ ts.isJsxExpression(i18nKeyAttr.initializer) &&
187
+ i18nKeyAttr.initializer.expression) {
188
+ const expr = i18nKeyAttr.initializer.expression;
189
+ if (!isDirectLiteral(expr)) {
190
+ found = true;
191
+ return;
192
+ }
193
+ }
194
+ }
195
+ ts.forEachChild(node, visit);
196
+ }
197
+ visit(sourceFile);
198
+ return found;
199
+ }
200
+ async function collectFileEvidence(filePath) {
201
+ const content = await fs.readFile(filePath, "utf-8");
202
+ const sourceFile = createSourceFile(filePath, content);
203
+ return {
204
+ namespaces: extractNamespaces(sourceFile),
205
+ hasDynamicTranslationUsage: detectDynamicTranslationUsage(sourceFile),
206
+ literalKeys: collectLiteralKeys(sourceFile),
207
+ templatePrefixes: collectTemplatePrefixes(sourceFile),
208
+ };
209
+ }
210
+ function keyMatchesTemplatePrefix(fullKey, localKey, namespace, evidence) {
211
+ for (const prefix of evidence.templatePrefixes) {
212
+ if (fullKey.startsWith(prefix)) {
213
+ return true;
214
+ }
215
+ if (namespace &&
216
+ evidence.namespaces.has(namespace) &&
217
+ localKey.startsWith(prefix)) {
218
+ return true;
219
+ }
220
+ }
221
+ return false;
222
+ }
223
+ function keyIsPossiblyDynamic(fullKey, evidenceList) {
224
+ const { namespace, localKey } = splitKey(fullKey);
225
+ for (const evidence of evidenceList) {
226
+ // case 1: full key exists as a string literal somewhere in code/object
227
+ if (evidence.literalKeys.has(fullKey)) {
228
+ return true;
229
+ }
230
+ // case 2: local key exists and file uses matching namespace + dynamic t/Trans usage
231
+ if (namespace &&
232
+ evidence.hasDynamicTranslationUsage &&
233
+ evidence.namespaces.has(namespace) &&
234
+ evidence.literalKeys.has(localKey)) {
235
+ return true;
236
+ }
237
+ // case 3: template literal prefix
238
+ if (keyMatchesTemplatePrefix(fullKey, localKey, namespace, evidence)) {
239
+ return true;
240
+ }
241
+ }
242
+ return false;
243
+ }
244
+ export async function deepVerifyUnusedKeys(sourceFiles, unusedKeysByFile) {
245
+ const definitelyUnusedByFile = {};
246
+ const possiblyDynamicallyUsedByFile = {};
247
+ const evidenceList = await Promise.all(sourceFiles.map((file) => collectFileEvidence(file)));
248
+ for (const [translationFilePath, unusedKeys] of Object.entries(unusedKeysByFile)) {
249
+ const definitelyUnused = [];
250
+ const possiblyDynamic = [];
251
+ for (const key of unusedKeys) {
252
+ if (keyIsPossiblyDynamic(key, evidenceList)) {
253
+ possiblyDynamic.push(key);
254
+ }
255
+ else {
256
+ definitelyUnused.push(key);
257
+ }
258
+ }
259
+ definitelyUnusedByFile[translationFilePath] = definitelyUnused;
260
+ possiblyDynamicallyUsedByFile[translationFilePath] = possiblyDynamic;
261
+ }
262
+ return {
263
+ definitelyUnusedByFile,
264
+ possiblyDynamicallyUsedByFile,
265
+ };
266
+ }
@@ -1,94 +1,180 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
- function extractNamespaces(content) {
4
- const namespaces = new Set();
5
- const nsRegex = /useTranslation\s*\(\s*(?:\[\s*([^\]]+)\s*\]|['"`]([^'"`]+)['"`])?/g;
6
- let match;
7
- while ((match = nsRegex.exec(content)) !== null) {
8
- if (match[1]) {
9
- const values = match[1]
10
- .split(",")
11
- .map((value) => value.replace(/['"`]/g, "").trim())
12
- .filter(Boolean);
13
- values.forEach((value) => namespaces.add(value));
3
+ import ts from "typescript";
4
+ function getScriptKind(filePath) {
5
+ if (filePath.endsWith(".tsx"))
6
+ return ts.ScriptKind.TSX;
7
+ if (filePath.endsWith(".ts"))
8
+ return ts.ScriptKind.TS;
9
+ if (filePath.endsWith(".jsx"))
10
+ return ts.ScriptKind.JSX;
11
+ return ts.ScriptKind.JS;
12
+ }
13
+ function createSourceFile(filePath, content) {
14
+ return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, getScriptKind(filePath));
15
+ }
16
+ function extractImportMap(sourceFile) {
17
+ const imports = new Map();
18
+ sourceFile.forEachChild((node) => {
19
+ if (!ts.isImportDeclaration(node))
20
+ return;
21
+ if (!node.importClause || !node.moduleSpecifier)
22
+ return;
23
+ if (!ts.isStringLiteral(node.moduleSpecifier))
24
+ return;
25
+ const modulePath = node.moduleSpecifier.text;
26
+ const importClause = node.importClause;
27
+ if (importClause.name) {
28
+ imports.set(importClause.name.text, modulePath);
14
29
  }
15
- if (match[2]) {
16
- namespaces.add(match[2]);
30
+ if (importClause.namedBindings &&
31
+ ts.isNamedImports(importClause.namedBindings)) {
32
+ for (const element of importClause.namedBindings.elements) {
33
+ const localName = element.name.text;
34
+ imports.set(localName, modulePath);
35
+ }
17
36
  }
18
- }
19
- return [...namespaces];
37
+ });
38
+ return imports;
20
39
  }
21
- function extractDynamicTExpressions(content) {
22
- const expressions = new Set();
23
- const dynamicTRegex = /(^|[^\w$.])t\s*\(\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\([^()]*\))?)/g;
24
- let match;
25
- while ((match = dynamicTRegex.exec(content)) !== null) {
26
- const expression = match[2]?.trim();
27
- if (!expression) {
28
- continue;
40
+ function extractNamespaces(sourceFile) {
41
+ const namespaces = new Set();
42
+ function visit(node) {
43
+ if (ts.isCallExpression(node) &&
44
+ ts.isIdentifier(node.expression) &&
45
+ node.expression.text === "useTranslation") {
46
+ const firstArg = node.arguments[0];
47
+ if (!firstArg) {
48
+ return;
49
+ }
50
+ if (ts.isStringLiteral(firstArg) ||
51
+ ts.isNoSubstitutionTemplateLiteral(firstArg)) {
52
+ namespaces.add(firstArg.text);
53
+ }
54
+ if (ts.isArrayLiteralExpression(firstArg)) {
55
+ for (const element of firstArg.elements) {
56
+ if (ts.isStringLiteral(element) ||
57
+ ts.isNoSubstitutionTemplateLiteral(element)) {
58
+ namespaces.add(element.text);
59
+ }
60
+ }
61
+ }
29
62
  }
30
- expressions.add(expression);
63
+ ts.forEachChild(node, visit);
31
64
  }
32
- return [...expressions];
65
+ visit(sourceFile);
66
+ return [...namespaces];
33
67
  }
34
- function extractImportedIdentifiers(content) {
35
- const identifiers = new Set();
36
- const importRegex = /import\s+(?:\{([^}]+)\}|([A-Za-z_$][\w$]*))\s+from\s+['"`][^'"`]+['"`]/g;
37
- let match;
38
- while ((match = importRegex.exec(content)) !== null) {
39
- if (match[1]) {
40
- const namedImports = match[1]
41
- .split(",")
42
- .map((value) => value.trim())
43
- .map((value) => value.split(" as ")[1]?.trim() ?? value.split(" as ")[0]?.trim())
44
- .filter(Boolean);
45
- namedImports.forEach((value) => identifiers.add(value));
46
- }
47
- if (match[2]) {
48
- identifiers.add(match[2]);
49
- }
68
+ function isTranslationCall(node) {
69
+ if (ts.isIdentifier(node.expression)) {
70
+ return node.expression.text === "t";
71
+ }
72
+ if (ts.isPropertyAccessExpression(node.expression)) {
73
+ return node.expression.name.text === "t";
50
74
  }
51
- return [...identifiers];
75
+ return false;
52
76
  }
53
- function detectObjectBasedUsageHints(content, importedIdentifiers) {
54
- const hints = new Set();
55
- for (const identifier of importedIdentifiers) {
56
- const objectAccessRegex = new RegExp(`${identifier}\\s*\\[[^\\]]+\\]\\.(description|label|title|placeholder|message)|${identifier}\\.[A-Za-z_$][\\w$]*\\.(description|label|title|placeholder|message)`, "g");
57
- if (objectAccessRegex.test(content)) {
58
- hints.add(identifier);
77
+ function isDirectLiteralTranslationArg(node) {
78
+ return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
79
+ }
80
+ function getExpressionKind(node) {
81
+ if (ts.isTemplateExpression(node))
82
+ return "template-literal";
83
+ if (ts.isIdentifier(node))
84
+ return "identifier";
85
+ if (ts.isCallExpression(node))
86
+ return "function-call";
87
+ if (ts.isPropertyAccessExpression(node))
88
+ return "property-access";
89
+ if (ts.isElementAccessExpression(node))
90
+ return "element-access";
91
+ if (ts.isBinaryExpression(node))
92
+ return "binary-expression";
93
+ if (ts.isConditionalExpression(node))
94
+ return "conditional-expression";
95
+ return "expression";
96
+ }
97
+ function findRelatedImports(expressionText, importMap) {
98
+ const related = [];
99
+ for (const [localName, source] of importMap.entries()) {
100
+ const identifierRegex = new RegExp(`\\b${localName}\\b`);
101
+ if (identifierRegex.test(expressionText)) {
102
+ related.push(`${localName} -> ${source}`);
59
103
  }
60
104
  }
61
- return [...hints];
105
+ return related;
62
106
  }
63
- function buildFinding(namespaces, expression, objectBasedHints) {
64
- const isObjectBased = objectBasedHints.length > 0;
107
+ function buildFinding(namespaces, expression, kind, importHints) {
108
+ let reason = "Non-literal translation key";
109
+ let suggestion = "Translation key is not passed as a direct string literal. Manual or deeper analysis may be required.";
110
+ if (kind === "template-literal") {
111
+ reason = "Template-literal translation key";
112
+ suggestion =
113
+ "Translation key uses a template literal with dynamic interpolation. Manual or deeper analysis may be required.";
114
+ }
115
+ if (kind === "function-call") {
116
+ reason = "Function-based translation key";
117
+ suggestion =
118
+ "Translation key is returned from a function call. Manual or deeper analysis may be required.";
119
+ }
120
+ if (kind === "property-access" ||
121
+ kind === "element-access" ||
122
+ kind === "identifier") {
123
+ reason = "Object-based non-literal translation key";
124
+ suggestion =
125
+ "Translation key may come from an object, variable, or imported map. Manual or deeper analysis may be required.";
126
+ }
127
+ if (importHints.length > 0) {
128
+ suggestion += ` Related imports: ${importHints.join(", ")}.`;
129
+ }
65
130
  return {
66
131
  namespaces,
67
132
  expression,
68
- reason: isObjectBased
69
- ? "Object-based non-literal translation key"
70
- : "Non-literal translation key",
71
- suggestion: isObjectBased
72
- ? `Translation key may come from imported object(s): ${objectBasedHints.join(", ")}. Manual or deeper analysis may be required.`
73
- : "Translation key may come from a variable or helper function. Manual or deeper analysis may be required.",
133
+ reason,
134
+ suggestion,
74
135
  };
75
136
  }
137
+ function collectDynamicFromExpression(expression, namespaces, importMap, sourceFile, bucket) {
138
+ if (isDirectLiteralTranslationArg(expression)) {
139
+ return;
140
+ }
141
+ if (ts.isConditionalExpression(expression)) {
142
+ collectDynamicFromExpression(expression.whenTrue, namespaces, importMap, sourceFile, bucket);
143
+ collectDynamicFromExpression(expression.whenFalse, namespaces, importMap, sourceFile, bucket);
144
+ return;
145
+ }
146
+ const expressionText = expression.getText(sourceFile).trim();
147
+ const kind = getExpressionKind(expression);
148
+ const importHints = findRelatedImports(expressionText, importMap);
149
+ bucket.push(buildFinding(namespaces, expressionText, kind, importHints));
150
+ }
76
151
  export async function detectDynamicTranslationUsages(files) {
77
152
  const report = {};
78
153
  for (const file of files) {
79
154
  const content = await fs.readFile(file, "utf-8");
80
- if (!content.includes("useTranslation") || !content.includes("t(")) {
155
+ if (!content.includes("t(")) {
81
156
  continue;
82
157
  }
83
- const namespaces = extractNamespaces(content);
84
- const expressions = extractDynamicTExpressions(content);
85
- if (expressions.length === 0) {
86
- continue;
158
+ const sourceFile = createSourceFile(file, content);
159
+ const namespaces = extractNamespaces(sourceFile);
160
+ const importMap = extractImportMap(sourceFile);
161
+ const findings = [];
162
+ function visit(node) {
163
+ if (ts.isCallExpression(node) && isTranslationCall(node)) {
164
+ const firstArg = node.arguments[0];
165
+ if (firstArg) {
166
+ collectDynamicFromExpression(firstArg, namespaces, importMap, sourceFile, findings);
167
+ }
168
+ }
169
+ ts.forEachChild(node, visit);
170
+ }
171
+ visit(sourceFile);
172
+ if (findings.length > 0) {
173
+ const relativePath = path
174
+ .relative(process.cwd(), file)
175
+ .replace(/\\/g, "/");
176
+ report[relativePath] = findings;
87
177
  }
88
- const importedIdentifiers = extractImportedIdentifiers(content);
89
- const objectBasedHints = detectObjectBasedUsageHints(content, importedIdentifiers);
90
- const relativePath = path.relative(process.cwd(), file).replace(/\\/g, "/");
91
- report[relativePath] = expressions.map((expression) => buildFinding(namespaces, expression, objectBasedHints));
92
178
  }
93
179
  return report;
94
180
  }
@@ -7,6 +7,7 @@ import { writeDynamicTranslationReport } from "./actions/writeDynamicTranslation
7
7
  import { syncMissingTranslations } from "./actions/syncMissingTranslations.js";
8
8
  import { removeUnusedTranslations } from "./actions/removeUnusedTranslations.js";
9
9
  import { writeUnusedTranslationsReport } from "./actions/writeUnusedTranslationsReport.js";
10
+ import { deepVerifyUnusedKeys } from "./analysis/deepVerifyUnusedKeys.js";
10
11
  export async function runScanner(config) {
11
12
  const files = await getFiles(config.srcPaths);
12
13
  const dynamicUsages = await detectDynamicTranslationUsages(files);
@@ -14,12 +15,17 @@ export async function runScanner(config) {
14
15
  const usedKeys = await extractKeysFromFiles(files);
15
16
  const jsonData = await loadJsonKeys(config.jsonDir);
16
17
  const result = analyzeKeys(usedKeys, jsonData);
17
- await writeUnusedTranslationsReport(result, jsonData, config.outputDir);
18
+ const verification = await deepVerifyUnusedKeys(files, result.unusedKeysByFile);
19
+ await writeUnusedTranslationsReport(verification.definitelyUnusedByFile, jsonData, config.outputDir);
18
20
  if (config.syncMissing) {
19
21
  await syncMissingTranslations(result, jsonData, config.sortJson);
20
22
  }
21
23
  if (config.removeUnused) {
22
- await removeUnusedTranslations(result, jsonData, config.sortJson);
24
+ const filteredResult = {
25
+ ...result,
26
+ unusedKeysByFile: verification.definitelyUnusedByFile,
27
+ };
28
+ await removeUnusedTranslations(filteredResult, jsonData, config.sortJson);
23
29
  }
24
30
  return result;
25
31
  }
package/package.json CHANGED
@@ -1,10 +1,8 @@
1
1
  {
2
2
  "name": "react-i18next-scanner",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "CLI tool to scan React projects for missing and unused react-i18next translation keys",
5
- "bin": {
6
- "react-i18next-scanner": "./dist/cli/cli.js"
7
- },
5
+ "bin": "./dist/cli/cli.js",
8
6
  "scripts": {
9
7
  "build": "tsc",
10
8
  "dev": "tsx src/cli/cli.ts",
@@ -26,12 +24,12 @@
26
24
  ],
27
25
  "devDependencies": {
28
26
  "@types/node": "^24.6.2",
29
- "tsx": "^4.22.4",
30
- "typescript": "^5.9.3"
27
+ "tsx": "^4.22.4"
31
28
  },
32
29
  "dependencies": {
33
30
  "chalk": "^5.6.2",
34
31
  "commander": "^15.0.0",
35
- "fast-glob": "^3.3.3"
32
+ "fast-glob": "^3.3.3",
33
+ "typescript": "^6.0.3"
36
34
  }
37
35
  }