react-i18next-scanner 0.1.5 → 0.1.7

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,12 @@
1
+ // src/core/actions/writeResolvedImportedObjectUsagesReport.ts
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ export async function writeResolvedImportedObjectUsagesReport(usages, outputDir) {
5
+ if (usages.length === 0) {
6
+ return;
7
+ }
8
+ const reportDir = outputDir ?? "reports";
9
+ await fs.mkdir(reportDir, { recursive: true });
10
+ const reportPath = path.join(reportDir, "resolved-imported-object-usages.json");
11
+ await fs.writeFile(reportPath, JSON.stringify(usages, null, 2), "utf-8");
12
+ }
@@ -20,6 +20,15 @@ function isProbablyTranslationKey(value) {
20
20
  !normalized.includes("\r") &&
21
21
  (normalized.includes(".") || normalized.includes(":")));
22
22
  }
23
+ function getPropertyName(name) {
24
+ if (ts.isIdentifier(name)) {
25
+ return name.text;
26
+ }
27
+ if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
28
+ return name.text;
29
+ }
30
+ return null;
31
+ }
23
32
  function flattenObjectLiteral(node, prefix = "", result = new Map()) {
24
33
  for (const property of node.properties) {
25
34
  if (!ts.isPropertyAssignment(property)) {
@@ -45,15 +54,6 @@ function flattenObjectLiteral(node, prefix = "", result = new Map()) {
45
54
  }
46
55
  return result;
47
56
  }
48
- function getPropertyName(name) {
49
- if (ts.isIdentifier(name)) {
50
- return name.text;
51
- }
52
- if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
53
- return name.text;
54
- }
55
- return null;
56
- }
57
57
  function hasExportModifier(node) {
58
58
  return !!node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
59
59
  }
@@ -103,7 +103,10 @@ function collectImportBindings(sourceFile) {
103
103
  const importedName = element.propertyName
104
104
  ? element.propertyName.text
105
105
  : element.name.text;
106
- bindings.set(localName, { importedName, source });
106
+ bindings.set(localName, {
107
+ importedName,
108
+ source,
109
+ });
107
110
  }
108
111
  }
109
112
  });
@@ -154,39 +157,93 @@ function getJsxAttribute(attributes, attributeName) {
154
157
  }
155
158
  return undefined;
156
159
  }
160
+ function unwrapExpression(expression) {
161
+ let current = expression;
162
+ while (true) {
163
+ if (ts.isParenthesizedExpression(current)) {
164
+ current = current.expression;
165
+ continue;
166
+ }
167
+ if (ts.isAsExpression(current)) {
168
+ current = current.expression;
169
+ continue;
170
+ }
171
+ if (ts.isTypeAssertionExpression(current)) {
172
+ current = current.expression;
173
+ continue;
174
+ }
175
+ if (ts.isNonNullExpression(current)) {
176
+ current = current.expression;
177
+ continue;
178
+ }
179
+ if (ts.isCallExpression(current) &&
180
+ ts.isIdentifier(current.expression) &&
181
+ current.expression.text === "String" &&
182
+ current.arguments[0]) {
183
+ current = current.arguments[0];
184
+ continue;
185
+ }
186
+ return current;
187
+ }
188
+ }
157
189
  function extractPropertyChain(expression) {
158
- if (ts.isIdentifier(expression)) {
190
+ const unwrapped = unwrapExpression(expression);
191
+ if (ts.isIdentifier(unwrapped)) {
159
192
  return {
160
- root: expression.text,
193
+ root: unwrapped.text,
161
194
  path: [],
162
195
  };
163
196
  }
164
- if (ts.isPropertyAccessExpression(expression)) {
165
- const left = extractPropertyChain(expression.expression);
197
+ if (ts.isPropertyAccessExpression(unwrapped)) {
198
+ const left = extractPropertyChain(unwrapped.expression);
166
199
  if (!left) {
167
200
  return null;
168
201
  }
169
202
  return {
170
203
  root: left.root,
171
- path: [...left.path, expression.name.text],
204
+ path: [...left.path, unwrapped.name.text],
172
205
  };
173
206
  }
174
- if (ts.isElementAccessExpression(expression)) {
175
- const left = extractPropertyChain(expression.expression);
176
- if (!left || !expression.argumentExpression) {
207
+ if (ts.isElementAccessExpression(unwrapped)) {
208
+ const left = extractPropertyChain(unwrapped.expression);
209
+ if (!left) {
177
210
  return null;
178
211
  }
179
- if (ts.isStringLiteral(expression.argumentExpression) ||
180
- ts.isNoSubstitutionTemplateLiteral(expression.argumentExpression)) {
212
+ const argument = unwrapped.argumentExpression;
213
+ if (argument &&
214
+ (ts.isStringLiteral(argument) ||
215
+ ts.isNoSubstitutionTemplateLiteral(argument))) {
181
216
  return {
182
217
  root: left.root,
183
- path: [...left.path, expression.argumentExpression.text],
218
+ path: [...left.path, argument.text],
184
219
  };
185
220
  }
221
+ return {
222
+ root: left.root,
223
+ path: [...left.path, "*"],
224
+ };
186
225
  }
187
226
  return null;
188
227
  }
189
- function resolvePropertyChainToTranslationKey(expression, currentFilePath, importBindings, objectRegistry, availableFiles) {
228
+ function matchObjectPath(pattern, actual) {
229
+ if (pattern.length !== actual.length) {
230
+ return false;
231
+ }
232
+ return pattern.every((part, index) => {
233
+ return part === "*" || part === actual[index];
234
+ });
235
+ }
236
+ function resolveObjectValuesByPath(objectMap, pathPattern) {
237
+ const result = new Set();
238
+ for (const [objectPath, translationKey] of objectMap.entries()) {
239
+ const actualPath = objectPath.split(".");
240
+ if (matchObjectPath(pathPattern, actualPath)) {
241
+ result.add(translationKey);
242
+ }
243
+ }
244
+ return result;
245
+ }
246
+ function resolveImportedObjectAccess(expression, currentFilePath, importBindings, objectRegistry, availableFiles) {
190
247
  const chain = extractPropertyChain(expression);
191
248
  if (!chain) {
192
249
  return null;
@@ -203,67 +260,171 @@ function resolvePropertyChainToTranslationKey(expression, currentFilePath, impor
203
260
  if (!objectMap) {
204
261
  return null;
205
262
  }
206
- const propertyPath = chain.path.join(".");
207
- if (!propertyPath) {
208
- return null;
263
+ return {
264
+ importedFilePath,
265
+ importedName: binding.importedName,
266
+ path: chain.path,
267
+ };
268
+ }
269
+ function resolvePropertyChainToTranslationKeys(expression, currentFilePath, importBindings, objectRegistry, availableFiles) {
270
+ const access = resolveImportedObjectAccess(expression, currentFilePath, importBindings, objectRegistry, availableFiles);
271
+ if (!access) {
272
+ return new Set();
273
+ }
274
+ const objectMap = objectRegistry.get(`${access.importedFilePath}::${access.importedName}`);
275
+ if (!objectMap || access.path.length === 0) {
276
+ return new Set();
209
277
  }
210
- return objectMap.get(propertyPath) ?? null;
278
+ return resolveObjectValuesByPath(objectMap, access.path);
211
279
  }
212
280
  function collectLocalAliases(sourceFile, currentFilePath, importBindings, objectRegistry, availableFiles) {
213
- const aliases = new Map();
281
+ const objectAliases = new Map();
282
+ const keyAliases = new Map();
214
283
  function visit(node) {
215
284
  if (ts.isVariableDeclaration(node) &&
216
285
  ts.isIdentifier(node.name) &&
217
286
  node.initializer) {
218
- const resolvedKey = resolvePropertyChainToTranslationKey(node.initializer, currentFilePath, importBindings, objectRegistry, availableFiles);
219
- if (resolvedKey) {
220
- aliases.set(node.name.text, resolvedKey);
287
+ const initializer = unwrapExpression(node.initializer);
288
+ const importedAccess = resolveImportedObjectAccess(initializer, currentFilePath, importBindings, objectRegistry, availableFiles);
289
+ if (importedAccess) {
290
+ const keys = resolvePropertyChainToTranslationKeys(initializer, currentFilePath, importBindings, objectRegistry, availableFiles);
291
+ if (keys.size > 0) {
292
+ keyAliases.set(node.name.text, {
293
+ keys,
294
+ access: importedAccess,
295
+ });
296
+ }
297
+ else {
298
+ objectAliases.set(node.name.text, importedAccess);
299
+ }
221
300
  }
222
301
  }
223
302
  ts.forEachChild(node, visit);
224
303
  }
225
304
  visit(sourceFile);
226
- return aliases;
305
+ return {
306
+ objectAliases,
307
+ keyAliases,
308
+ };
309
+ }
310
+ function resolveAliasPropertyAccessToTranslationKeys(expression, objectAliases, keyAliases, objectRegistry) {
311
+ const unwrapped = unwrapExpression(expression);
312
+ if (ts.isIdentifier(unwrapped)) {
313
+ return keyAliases.get(unwrapped.text)?.keys ?? new Set();
314
+ }
315
+ const chain = extractPropertyChain(unwrapped);
316
+ if (!chain) {
317
+ return new Set();
318
+ }
319
+ const objectAlias = objectAliases.get(chain.root);
320
+ if (!objectAlias) {
321
+ return new Set();
322
+ }
323
+ const objectMap = objectRegistry.get(`${objectAlias.importedFilePath}::${objectAlias.importedName}`);
324
+ if (!objectMap) {
325
+ return new Set();
326
+ }
327
+ const fullPath = [...objectAlias.path, ...chain.path];
328
+ return resolveObjectValuesByPath(objectMap, fullPath);
329
+ }
330
+ function resolveAliasObjectAccess(expression, objectAliases, keyAliases) {
331
+ const unwrapped = unwrapExpression(expression);
332
+ if (ts.isIdentifier(unwrapped)) {
333
+ return keyAliases.get(unwrapped.text)?.access ?? null;
334
+ }
335
+ const chain = extractPropertyChain(unwrapped);
336
+ if (!chain) {
337
+ return null;
338
+ }
339
+ const objectAlias = objectAliases.get(chain.root);
340
+ if (!objectAlias) {
341
+ return null;
342
+ }
343
+ return {
344
+ importedFilePath: objectAlias.importedFilePath,
345
+ importedName: objectAlias.importedName,
346
+ path: [...objectAlias.path, ...chain.path],
347
+ };
348
+ }
349
+ function formatAccessPath(importedName, pathParts) {
350
+ if (pathParts.length === 0) {
351
+ return importedName;
352
+ }
353
+ return `${importedName}.${pathParts.join(".")}`;
354
+ }
355
+ function addResolvedUsages(usages, usageIds, keys, params) {
356
+ for (const key of keys) {
357
+ const usageId = [
358
+ key,
359
+ params.sourceFilePath,
360
+ params.importedFilePath,
361
+ params.importedObjectName,
362
+ params.accessPath,
363
+ params.usageKind,
364
+ ].join("::");
365
+ if (usageIds.has(usageId)) {
366
+ continue;
367
+ }
368
+ usageIds.add(usageId);
369
+ usages.push({
370
+ key,
371
+ sourceFilePath: params.sourceFilePath,
372
+ importedFilePath: params.importedFilePath,
373
+ importedObjectName: params.importedObjectName,
374
+ accessPath: params.accessPath,
375
+ usageKind: params.usageKind,
376
+ });
377
+ }
227
378
  }
228
379
  export async function collectResolvedImportedObjectTranslationKeys(filePaths) {
380
+ const normalizedFilePaths = filePaths.map((filePath) => path.resolve(filePath));
229
381
  const sourceFiles = new Map();
230
- const availableFiles = new Set(filePaths);
382
+ const availableFiles = new Set(normalizedFilePaths);
231
383
  const resolvedKeys = new Set();
232
- for (const filePath of filePaths) {
384
+ const usages = [];
385
+ const usageIds = new Set();
386
+ for (const filePath of normalizedFilePaths) {
233
387
  const content = await fs.readFile(filePath, "utf-8");
234
388
  sourceFiles.set(filePath, createSourceFile(filePath, content));
235
389
  }
236
- const objectRegistry = buildObjectRegistry(filePaths, sourceFiles);
237
- for (const filePath of filePaths) {
390
+ const objectRegistry = buildObjectRegistry(normalizedFilePaths, sourceFiles);
391
+ for (const filePath of normalizedFilePaths) {
238
392
  const sourceFile = sourceFiles.get(filePath);
239
393
  if (!sourceFile) {
240
394
  continue;
241
395
  }
242
396
  const importBindings = collectImportBindings(sourceFile);
243
- const localAliases = collectLocalAliases(sourceFile, filePath, importBindings, objectRegistry, availableFiles);
244
- function visit(node) {
245
- if (ts.isCallExpression(node) && isTranslationCall(node)) {
246
- const firstArg = node.arguments[0];
247
- if (firstArg) {
248
- const directResolved = resolvePropertyChainToTranslationKey(firstArg, filePath, importBindings, objectRegistry, availableFiles);
249
- if (directResolved) {
250
- resolvedKeys.add(directResolved);
251
- }
252
- if (ts.isIdentifier(firstArg)) {
253
- const aliasResolved = localAliases.get(firstArg.text);
254
- if (aliasResolved) {
255
- resolvedKeys.add(aliasResolved);
256
- }
257
- }
258
- }
397
+ const { objectAliases, keyAliases } = collectLocalAliases(sourceFile, filePath, importBindings, objectRegistry, availableFiles);
398
+ function addKeys(keys) {
399
+ for (const key of keys) {
400
+ resolvedKeys.add(key);
259
401
  }
260
- if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
261
- collectFromTrans(node.attributes);
402
+ }
403
+ function collectFromExpression(expression, usageKind) {
404
+ const directResolved = resolvePropertyChainToTranslationKeys(expression, filePath, importBindings, objectRegistry, availableFiles);
405
+ addKeys(directResolved);
406
+ const directAccess = resolveImportedObjectAccess(expression, filePath, importBindings, objectRegistry, availableFiles);
407
+ if (directAccess && directResolved.size > 0) {
408
+ addResolvedUsages(usages, usageIds, directResolved, {
409
+ sourceFilePath: filePath,
410
+ importedFilePath: directAccess.importedFilePath,
411
+ importedObjectName: directAccess.importedName,
412
+ accessPath: formatAccessPath(directAccess.importedName, directAccess.path),
413
+ usageKind,
414
+ });
262
415
  }
263
- if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
264
- collectFromTrans(node.attributes);
416
+ const aliasResolved = resolveAliasPropertyAccessToTranslationKeys(expression, objectAliases, keyAliases, objectRegistry);
417
+ addKeys(aliasResolved);
418
+ const aliasAccess = resolveAliasObjectAccess(expression, objectAliases, keyAliases);
419
+ if (aliasAccess && aliasResolved.size > 0) {
420
+ addResolvedUsages(usages, usageIds, aliasResolved, {
421
+ sourceFilePath: filePath,
422
+ importedFilePath: aliasAccess.importedFilePath,
423
+ importedObjectName: aliasAccess.importedName,
424
+ accessPath: formatAccessPath(aliasAccess.importedName, aliasAccess.path),
425
+ usageKind,
426
+ });
265
427
  }
266
- ts.forEachChild(node, visit);
267
428
  }
268
429
  function collectFromTrans(attributes) {
269
430
  const i18nKeyAttr = getJsxAttribute(attributes, "i18nKey");
@@ -272,19 +433,27 @@ export async function collectResolvedImportedObjectTranslationKeys(filePaths) {
272
433
  !i18nKeyAttr.initializer.expression) {
273
434
  return;
274
435
  }
275
- const expr = i18nKeyAttr.initializer.expression;
276
- const directResolved = resolvePropertyChainToTranslationKey(expr, filePath, importBindings, objectRegistry, availableFiles);
277
- if (directResolved) {
278
- resolvedKeys.add(directResolved);
279
- }
280
- if (ts.isIdentifier(expr)) {
281
- const aliasResolved = localAliases.get(expr.text);
282
- if (aliasResolved) {
283
- resolvedKeys.add(aliasResolved);
436
+ collectFromExpression(i18nKeyAttr.initializer.expression, "Trans");
437
+ }
438
+ function visit(node) {
439
+ if (ts.isCallExpression(node) && isTranslationCall(node)) {
440
+ const firstArg = node.arguments[0];
441
+ if (firstArg) {
442
+ collectFromExpression(firstArg, "t");
284
443
  }
285
444
  }
445
+ if (ts.isJsxSelfClosingElement(node) && isTransTagName(node.tagName)) {
446
+ collectFromTrans(node.attributes);
447
+ }
448
+ if (ts.isJsxOpeningElement(node) && isTransTagName(node.tagName)) {
449
+ collectFromTrans(node.attributes);
450
+ }
451
+ ts.forEachChild(node, visit);
286
452
  }
287
453
  visit(sourceFile);
288
454
  }
289
- return resolvedKeys;
455
+ return {
456
+ keys: resolvedKeys,
457
+ usages,
458
+ };
290
459
  }
@@ -246,7 +246,9 @@ export async function deepVerifyUnusedKeys(sourceFiles, unusedKeysByFile) {
246
246
  const definitelyUnusedByFile = {};
247
247
  const possiblyDynamicallyUsedByFile = {};
248
248
  const evidenceList = await Promise.all(sourceFiles.map((file) => collectFileEvidence(file)));
249
- const resolvedImportedObjectKeys = await collectResolvedImportedObjectTranslationKeys(sourceFiles);
249
+ const resolvedImportedObjectResult = await collectResolvedImportedObjectTranslationKeys(sourceFiles);
250
+ const resolvedImportedObjectKeys = resolvedImportedObjectResult.keys;
251
+ const resolvedImportedObjectUsages = resolvedImportedObjectResult.usages;
250
252
  for (const [translationFilePath, unusedKeys] of Object.entries(unusedKeysByFile)) {
251
253
  const definitelyUnused = [];
252
254
  const possiblyDynamic = [];
@@ -264,5 +266,6 @@ export async function deepVerifyUnusedKeys(sourceFiles, unusedKeysByFile) {
264
266
  return {
265
267
  definitelyUnusedByFile,
266
268
  possiblyDynamicallyUsedByFile,
269
+ resolvedImportedObjectUsages,
267
270
  };
268
271
  }
@@ -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 { writeResolvedImportedObjectUsagesReport } from "./actions/writeResolvedImportedObjectUsageReport.js";
10
11
  import { deepVerifyUnusedKeys } from "./analysis/deepVerifyUnusedKeys.js";
11
12
  export async function runScanner(config) {
12
13
  const files = await getFiles(config.srcPaths);
@@ -16,6 +17,7 @@ export async function runScanner(config) {
16
17
  const jsonData = await loadJsonKeys(config.jsonDir);
17
18
  const result = analyzeKeys(usedKeys, jsonData);
18
19
  const verification = await deepVerifyUnusedKeys(files, result.unusedKeysByFile);
20
+ await writeResolvedImportedObjectUsagesReport(verification.resolvedImportedObjectUsages ?? [], config.outputDir);
19
21
  await writeUnusedTranslationsReport(verification.definitelyUnusedByFile, jsonData, config.outputDir);
20
22
  if (config.syncMissing) {
21
23
  await syncMissingTranslations(result, jsonData, config.sortJson);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-i18next-scanner",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "CLI tool to scan React projects for missing and unused react-i18next translation keys",
5
5
  "bin": "./dist/cli/cli.js",
6
6
  "scripts": {