intor-cli 0.0.1 → 0.0.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intor-cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Intor CLI",
5
5
  "author": "Yiming Liao",
6
6
  "homepage": "https://github.com/yiming-liao/intor-cli#readme",
@@ -66,7 +66,6 @@
66
66
  "typescript-eslint": "8.51.0",
67
67
  "vitest": "4.0.16"
68
68
  },
69
- "peerDependencies": {},
70
69
  "publishConfig": {
71
70
  "access": "public"
72
71
  }
@@ -4,6 +4,7 @@ import type { InferredSchemas } from "../infer-schema/types";
4
4
  import { enforceMissingReplacements } from "./rules/enforce-missing-replacements";
5
5
  import { enforceMissingRich } from "./rules/enforce-missing-rich";
6
6
  import { keyEmpty, keyNotFound } from "./rules/key";
7
+ import { preKeyNotFound } from "./rules/pre-key";
7
8
  import {
8
9
  replacementsNotAllowed,
9
10
  replacementsMissing,
@@ -18,13 +19,18 @@ export function collectDiagnostics(
18
19
  ) {
19
20
  const diagnostics: Diagnostic[] = [];
20
21
 
22
+ // PreKey
23
+ for (const usage of usages.preKey) {
24
+ diagnostics.push(...preKeyNotFound(usage, messagesSchema));
25
+ }
26
+
21
27
  // Key
22
- for (const usage of usages.keys) {
28
+ for (const usage of usages.key) {
23
29
  diagnostics.push(...keyNotFound(usage, messagesSchema), ...keyEmpty(usage));
24
30
  }
25
31
 
26
- // Replacements
27
- for (const usage of usages.replacements) {
32
+ // Replacement
33
+ for (const usage of usages.replacement) {
28
34
  diagnostics.push(
29
35
  ...replacementsNotAllowed(usage, replacementsSchema),
30
36
  ...replacementsMissing(usage, replacementsSchema),
@@ -42,9 +48,9 @@ export function collectDiagnostics(
42
48
  }
43
49
 
44
50
  // Ensure required replacements / rich tags are detected even when no usage provides them
45
- const replacementIndex = indexUsagesByKey(usages.replacements);
51
+ const replacementIndex = indexUsagesByKey(usages.replacement);
46
52
  const richIndex = indexUsagesByKey(usages.rich);
47
- for (const usage of usages.keys) {
53
+ for (const usage of usages.key) {
48
54
  diagnostics.push(
49
55
  ...enforceMissingReplacements(
50
56
  usage,
@@ -1,20 +1,38 @@
1
1
  /* eslint-disable unicorn/no-array-sort */
2
2
  import type { Diagnostic, DiagnosticGroup } from "./types";
3
3
 
4
+ /**
5
+ * Group diagnostics (by file + messageKey + method).
6
+ */
4
7
  export function groupDiagnostics(diagnostics: Diagnostic[]): DiagnosticGroup[] {
5
8
  const map = new Map<string, DiagnosticGroup>();
6
9
 
7
10
  for (const diagnostic of diagnostics) {
8
- const { severity, method, message, messageKey, file, line, column } =
9
- diagnostic;
10
-
11
+ const {
12
+ severity,
13
+ factory,
14
+ method,
15
+ message,
16
+ messageKey,
17
+ file,
18
+ line,
19
+ column,
20
+ } = diagnostic;
21
+
22
+ // --------------------------------------------------
23
+ // Grouping key
24
+ // - Prefer semantic key (messageKey + method)
25
+ // - Fallback to exact source location
26
+ // --------------------------------------------------
11
27
  const groupId = messageKey
12
28
  ? `${file}::${messageKey}::${diagnostic.method}`
13
29
  : `${file}::${line}:${column}`;
14
30
 
31
+ // Initialize group if not exists
15
32
  if (!map.has(groupId)) {
16
33
  map.set(groupId, {
17
34
  severity,
35
+ factory,
18
36
  method,
19
37
  messageKey,
20
38
  problems: [],
@@ -24,15 +42,17 @@ export function groupDiagnostics(diagnostics: Diagnostic[]): DiagnosticGroup[] {
24
42
  }
25
43
  const group = map.get(groupId)!;
26
44
 
45
+ // Aggregate messages & lines
27
46
  group.problems.push(message);
28
47
  group.lines.push(line);
29
48
 
30
- // severity upgrade (error > warn)
49
+ // Severity escalation (error > warn)
31
50
  if (severity === "error") {
32
51
  group.severity = "error";
33
52
  }
34
53
  }
35
54
 
55
+ // Normalize line numbers (unique + sorted)
36
56
  for (const group of map.values()) {
37
57
  group.lines = [...new Set(group.lines)].sort((a, b) => a - b);
38
58
  }
@@ -1,15 +1,23 @@
1
1
  export const DIAGNOSTIC_MESSAGES = {
2
+ // --------------------------------------------------
3
+ // PreKey
4
+ // --------------------------------------------------
5
+ PRE_KEY_NOT_FOUND: {
6
+ code: "INTOR_PRE_KEY_NOT_FOUND",
7
+ message: () => "preKey does not exist",
8
+ },
9
+
2
10
  // --------------------------------------------------
3
11
  // Message key
4
12
  // --------------------------------------------------
5
13
  KEY_NOT_FOUND: {
6
14
  code: "INTOR_KEY_NOT_FOUND",
7
- message: () => "does not exist",
15
+ message: () => "key does not exist",
8
16
  },
9
17
 
10
18
  KEY_EMPTY: {
11
19
  code: "INTOR_KEY_EMPTY",
12
- message: () => "translation key cannot be empty",
20
+ message: () => "key cannot be empty",
13
21
  },
14
22
 
15
23
  // --------------------------------------------------
@@ -17,18 +25,18 @@ export const DIAGNOSTIC_MESSAGES = {
17
25
  // --------------------------------------------------
18
26
  REPLACEMENTS_NOT_ALLOWED: {
19
27
  code: "INTOR_REPLACEMENTS_NOT_ALLOWED",
20
- message: () => "does not accept replacements",
28
+ message: () => "replacements are not allowed",
21
29
  },
22
30
 
23
31
  REPLACEMENTS_MISSING: {
24
32
  code: "INTOR_REPLACEMENTS_MISSING",
25
33
  message: (missing: string[]) =>
26
- `missing replacements: ${missing.join(", ")}`,
34
+ `replacements missing: ${missing.join(", ")}`,
27
35
  },
28
36
 
29
37
  REPLACEMENTS_UNUSED: {
30
38
  code: "INTOR_REPLACEMENTS_UNUSED",
31
- message: (extra: string[]) => `unused replacements: ${extra.join(", ")}`,
39
+ message: (extra: string[]) => `replacements unused: ${extra.join(", ")}`,
32
40
  },
33
41
 
34
42
  // --------------------------------------------------
@@ -36,16 +44,16 @@ export const DIAGNOSTIC_MESSAGES = {
36
44
  // --------------------------------------------------
37
45
  RICH_NOT_ALLOWED: {
38
46
  code: "INTOR_RICH_NOT_ALLOWED",
39
- message: () => "does not accept rich tags",
47
+ message: () => "rich tags are not allowed",
40
48
  },
41
49
 
42
50
  RICH_MISSING: {
43
51
  code: "INTOR_RICH_MISSING",
44
- message: (missing: string[]) => `missing rich tags: ${missing.join(", ")}`,
52
+ message: (missing: string[]) => `rich tags missing: ${missing.join(", ")}`,
45
53
  },
46
54
 
47
55
  RICH_UNUSED: {
48
56
  code: "INTOR_RICH_UNUSED",
49
- message: (extra: string[]) => `unused rich tags: ${extra.join(", ")}`,
57
+ message: (extra: string[]) => `rich tags unused: ${extra.join(", ")}`,
50
58
  },
51
59
  } as const;
@@ -0,0 +1 @@
1
+ export { preKeyNotFound } from "./not-found";
@@ -0,0 +1,41 @@
1
+ import type { PreKeyUsage } from "../../../extract-usages";
2
+ import type { InferNode } from "../../../infer-schema";
3
+ import type { Diagnostic } from "../../types";
4
+ import { DIAGNOSTIC_MESSAGES } from "../../messages";
5
+ import { getSchemaNodeAtPath } from "../../utils/get-schema-node-at-path";
6
+
7
+ /**
8
+ * @example
9
+ * ```ts
10
+ * // Expected:
11
+ * useTranslator("hello")
12
+ *
13
+ * // Received:
14
+ * useTranslator("missing")
15
+ * ```
16
+ */
17
+ export function preKeyNotFound(
18
+ usage: PreKeyUsage,
19
+ messagesSchema: InferNode,
20
+ ): Diagnostic[] {
21
+ const { factory, preKey, file, line, column } = usage;
22
+
23
+ if (!preKey) return [];
24
+
25
+ if (!getSchemaNodeAtPath(messagesSchema, preKey)) {
26
+ return [
27
+ {
28
+ severity: "warn",
29
+ factory,
30
+ messageKey: preKey,
31
+ code: DIAGNOSTIC_MESSAGES.PRE_KEY_NOT_FOUND.code,
32
+ message: DIAGNOSTIC_MESSAGES.PRE_KEY_NOT_FOUND.message(),
33
+ file,
34
+ line,
35
+ column,
36
+ },
37
+ ];
38
+ }
39
+
40
+ return [];
41
+ }
@@ -1,8 +1,9 @@
1
- import type { TranslatorMethod } from "../extract-usages";
1
+ import type { TranslatorFactory, TranslatorMethod } from "../extract-usages";
2
2
 
3
3
  export interface Diagnostic {
4
4
  severity: "error" | "warn";
5
- method: TranslatorMethod;
5
+ factory?: TranslatorFactory;
6
+ method?: TranslatorMethod;
6
7
  messageKey: string;
7
8
  code: string;
8
9
  message: string;
@@ -13,7 +14,8 @@ export interface Diagnostic {
13
14
 
14
15
  export interface DiagnosticGroup {
15
16
  severity: "error" | "warn";
16
- method: TranslatorMethod;
17
+ factory?: TranslatorFactory;
18
+ method?: TranslatorMethod;
17
19
  messageKey: string;
18
20
  problems: string[]; // list of bullet messages
19
21
  file: string;
@@ -4,6 +4,7 @@ import fg from "fast-glob";
4
4
  import { type IntorResolvedConfig } from "intor";
5
5
  import { createLogger } from "../scan-logger";
6
6
  import { isIntorResolvedConfig } from "./is-intor-resolved-config";
7
+ import { loadModule } from "./load-module";
7
8
 
8
9
  const DEFAULT_PATTERNS = ["**/*.{ts,js}"];
9
10
  const DEFAULT_IGNORE = [
@@ -58,7 +59,7 @@ export async function discoverConfigs(debug?: boolean): Promise<ConfigEntry[]> {
58
59
  // Dynamic import & export inspection
59
60
  // ----------------------------------------------------------------------
60
61
  try {
61
- const moduleExports = await import(absPath);
62
+ const moduleExports = await loadModule(absPath);
62
63
  let matched = false;
63
64
 
64
65
  for (const module of Object.values(moduleExports)) {
@@ -0,0 +1,3 @@
1
+ export async function loadModule(filePath: string) {
2
+ return import(filePath);
3
+ }
@@ -1,4 +1,4 @@
1
- import type { PreKeyMap, TranslatorBindingMap } from "../types";
1
+ import type { PreKeyMap, PreKeyUsage, TranslatorBindingMap } from "../types";
2
2
  import { SyntaxKind, type SourceFile } from "ts-morph";
3
3
  import { getObjectArg } from "./utils/get-object-arg";
4
4
  import { isStaticStringLiteral } from "./utils/is-static-string-literal";
@@ -6,6 +6,11 @@ import { walkTranslatorBindings } from "./utils/walk-translator-bindings";
6
6
 
7
7
  const PREKEY_PROPERTY_NAME = "preKey";
8
8
 
9
+ export interface CollectPreKeysResult {
10
+ preKeyMap: PreKeyMap;
11
+ usages: PreKeyUsage[];
12
+ }
13
+
9
14
  /**
10
15
  * Collect static preKey values associated with translator bindings
11
16
  * within a single source file.
@@ -13,14 +18,19 @@ const PREKEY_PROPERTY_NAME = "preKey";
13
18
  export function collectPreKeys(
14
19
  sourceFile: SourceFile,
15
20
  translatorBindingMap: TranslatorBindingMap,
16
- ): PreKeyMap {
17
- const preKeyMap = new Map<string, string>();
21
+ ): CollectPreKeysResult {
22
+ const preKeyMap: PreKeyMap = new Map();
23
+ const usages: PreKeyUsage[] = [];
18
24
 
19
25
  walkTranslatorBindings(sourceFile, ({ call, binding }) => {
20
26
  // Iterate over destructured translator binding elements (e.g. { t, hasKey, ... })
21
27
  for (const el of binding.getElements()) {
22
28
  const localName = el.getNameNode().getText(); // `t` from `const { t } = useTranslator`
23
29
  if (!translatorBindingMap.has(localName)) continue;
30
+ const translatorUsage = translatorBindingMap.get(localName);
31
+ if (!translatorUsage) return;
32
+
33
+ let preKey: string | undefined;
24
34
 
25
35
  // -----------------------------------------------------------------------
26
36
  // Resolve static preKey from translator factory arguments
@@ -28,31 +38,51 @@ export function collectPreKeys(
28
38
  // 1. From the first positional string argument (e.g. `useTranslator("preKey")`)
29
39
  const firstArg = call.getArguments()[0];
30
40
  if (isStaticStringLiteral(firstArg)) {
31
- preKeyMap.set(localName, firstArg.getLiteralText());
32
- continue;
41
+ preKey = firstArg.getLiteralText();
33
42
  }
34
43
 
35
44
  // 2. From the last options object (e.g. `getTranslator(_, { preKey })`)
36
- const lastArg = getObjectArg(call, "last");
37
- if (!lastArg) continue;
38
-
39
- // Extract the `preKey` property from the options object
40
- const prop = lastArg.getProperty(PREKEY_PROPERTY_NAME);
41
- if (!prop || !prop.isKind(SyntaxKind.PropertyAssignment)) continue;
42
-
43
- // Only accept static string initializers as preKey values
44
- const value = prop.getInitializer();
45
- if (!value) continue;
46
- if (
47
- !value.isKind(SyntaxKind.StringLiteral) &&
48
- !value.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
49
- ) {
50
- continue;
45
+ if (!preKey) {
46
+ const lastArg = getObjectArg(call, "last");
47
+ if (!lastArg) continue;
48
+
49
+ // Extract the `preKey` property from the options object
50
+ const prop = lastArg.getProperty(PREKEY_PROPERTY_NAME);
51
+ if (!prop || !prop.isKind(SyntaxKind.PropertyAssignment)) continue;
52
+
53
+ // Only accept static string initializers as preKey values
54
+ const value = prop.getInitializer();
55
+ if (!value) continue;
56
+ if (
57
+ !value.isKind(SyntaxKind.StringLiteral) &&
58
+ !value.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
59
+ ) {
60
+ continue;
61
+ }
62
+ preKey = value.getLiteralText();
51
63
  }
52
64
 
53
- preKeyMap.set(localName, value.getLiteralText());
65
+ if (!preKey) continue;
66
+
67
+ // ---------------------------------------------------------------------
68
+ // Record
69
+ // ---------------------------------------------------------------------
70
+ preKeyMap.set(localName, preKey);
71
+
72
+ // Resolve source location for diagnostics
73
+ const pos = sourceFile.getLineAndColumnAtPos(call.getStart());
74
+
75
+ usages.push({
76
+ factory: translatorUsage.factory,
77
+ configKey: translatorUsage.configKey,
78
+ localName,
79
+ preKey,
80
+ file: sourceFile.getFilePath(),
81
+ line: pos.line,
82
+ column: pos.column,
83
+ });
54
84
  }
55
85
  });
56
86
 
57
- return preKeyMap;
87
+ return { preKeyMap, usages };
58
88
  }
@@ -0,0 +1,72 @@
1
+ import type { ExtractedUsages, PreKeyMap } from "./types";
2
+ import type { SourceFile } from "ts-morph";
3
+ import {
4
+ collectTranslatorBindings,
5
+ collectKeyUsages,
6
+ collectReplacementUsages,
7
+ collectRichUsages,
8
+ collectPreKeys,
9
+ } from "./collectors";
10
+
11
+ function attachPreKey<T extends { localName: string; preKey?: string }>(
12
+ usages: T[],
13
+ preKeyMap: PreKeyMap,
14
+ ) {
15
+ for (const usage of usages) usage.preKey = preKeyMap.get(usage.localName);
16
+ }
17
+
18
+ /**
19
+ * Extract all static translator usages from a single source file.
20
+ *
21
+ * This is a pure, file-level extractor suitable for editor extensions
22
+ * and incremental analysis.
23
+ */
24
+ export function extractUsagesFromSourceFile(
25
+ sourceFile: SourceFile,
26
+ ): ExtractedUsages {
27
+ // -----------------------------------------------------------------------
28
+ // Translator binding
29
+ // -----------------------------------------------------------------------
30
+ const translatorBindingMap = collectTranslatorBindings(sourceFile);
31
+ if (translatorBindingMap.size === 0) {
32
+ return { preKey: [], key: [], replacement: [], rich: [] };
33
+ }
34
+
35
+ // -----------------------------------------------------------------------
36
+ // Key usages
37
+ // -----------------------------------------------------------------------
38
+ const keyUsages = collectKeyUsages(sourceFile, translatorBindingMap);
39
+
40
+ // -----------------------------------------------------------------------
41
+ // Replacement usages
42
+ // -----------------------------------------------------------------------
43
+ const replacementUsages = collectReplacementUsages(
44
+ sourceFile,
45
+ translatorBindingMap,
46
+ );
47
+
48
+ // -----------------------------------------------------------------------
49
+ // Rich usages
50
+ // -----------------------------------------------------------------------
51
+ const richUsages = collectRichUsages(sourceFile, translatorBindingMap);
52
+
53
+ // -----------------------------------------------------------------------
54
+ // PreKey values
55
+ // -----------------------------------------------------------------------
56
+ const { preKeyMap, usages } = collectPreKeys(
57
+ sourceFile,
58
+ translatorBindingMap,
59
+ );
60
+
61
+ // Attach preKey to all usages that support it
62
+ attachPreKey(keyUsages, preKeyMap);
63
+ attachPreKey(replacementUsages, preKeyMap);
64
+ attachPreKey(richUsages, preKeyMap);
65
+
66
+ return {
67
+ preKey: usages,
68
+ key: keyUsages,
69
+ replacement: replacementUsages,
70
+ rich: richUsages,
71
+ };
72
+ }
@@ -1,15 +1,13 @@
1
1
  import type { ExtractedUsages } from "./types";
2
2
  import pc from "picocolors";
3
3
  import { createLogger } from "../scan-logger";
4
- import {
5
- collectTranslatorBindings,
6
- collectKeyUsages,
7
- collectReplacementUsages,
8
- collectRichUsages,
9
- collectPreKeys,
10
- } from "./collectors";
4
+ import { extractUsagesFromSourceFile } from "./extract-usages-from-source-file";
11
5
  import { loadSourceFilesFromTsconfig } from "./load-source-files-from-tscofnig";
12
6
 
7
+ /** Check whether a file-level extraction produced any meaningful usage */
8
+ const isEmpty = (u: ExtractedUsages) =>
9
+ u.key.length === 0 && u.replacement.length === 0 && u.rich.length === 0;
10
+
13
11
  export interface ExtractUsagesOptions {
14
12
  tsconfigPath?: string;
15
13
  debug?: boolean;
@@ -23,11 +21,13 @@ export function extractUsages(options?: ExtractUsagesOptions): ExtractedUsages {
23
21
  const log = createLogger(debug);
24
22
 
25
23
  const result: ExtractedUsages = {
26
- keys: [],
27
- replacements: [],
24
+ preKey: [],
25
+ key: [],
26
+ replacement: [],
28
27
  rich: [],
29
28
  };
30
29
 
30
+ // Debug counters
31
31
  let scannedFiles = 0;
32
32
  let matchedFiles = 0;
33
33
 
@@ -38,46 +38,23 @@ export function extractUsages(options?: ExtractUsagesOptions): ExtractedUsages {
38
38
  scannedFiles++;
39
39
  log("scan", sourceFile.getFilePath());
40
40
 
41
- // -----------------------------------------------------------------------
42
- // Translator binding
43
- // -----------------------------------------------------------------------
44
- const translatorBindingMap = collectTranslatorBindings(sourceFile);
45
- if (translatorBindingMap.size === 0) continue;
41
+ // ---------------------------------------------------------------------------
42
+ // File-level extraction (pure analysis, no side effects)
43
+ // ---------------------------------------------------------------------------
44
+ const partial = extractUsagesFromSourceFile(sourceFile);
45
+ if (isEmpty(partial)) continue;
46
46
  matchedFiles++;
47
47
 
48
- // -----------------------------------------------------------------------
49
- // Key usages
50
- // -----------------------------------------------------------------------
51
- const keyUsages = collectKeyUsages(sourceFile, translatorBindingMap);
52
-
53
- // -----------------------------------------------------------------------
54
- // Replacement usages
55
- // -----------------------------------------------------------------------
56
- const replacementUsages = collectReplacementUsages(
57
- sourceFile,
58
- translatorBindingMap,
59
- );
60
-
61
- // -----------------------------------------------------------------------
62
- // Rich usages
63
- // -----------------------------------------------------------------------
64
- const richUsages = collectRichUsages(sourceFile, translatorBindingMap);
65
-
66
- // -----------------------------------------------------------------------
67
- // PreKey values
68
- // -----------------------------------------------------------------------
69
- const preKeyMap = collectPreKeys(sourceFile, translatorBindingMap);
70
-
71
- // Attach preKey to all usages that support it
72
- for (const usage of [...keyUsages, ...replacementUsages, ...richUsages]) {
73
- usage.preKey = preKeyMap.get(usage.localName);
74
- }
75
-
76
- result.keys.push(...keyUsages);
77
- result.replacements.push(...replacementUsages);
78
- result.rich.push(...richUsages);
48
+ // ---------------------------------------------------------------------------
49
+ // Merge file-level results into the project-level result
50
+ // ---------------------------------------------------------------------------
51
+ result.preKey.push(...partial.preKey);
52
+ result.key.push(...partial.key);
53
+ result.replacement.push(...partial.replacement);
54
+ result.rich.push(...partial.rich);
79
55
  }
80
56
 
57
+ // Debug summary
81
58
  if (debug) {
82
59
  console.log(
83
60
  pc.dim(` › Scanned ${scannedFiles} files, matched ${matchedFiles} \n`),
@@ -2,6 +2,7 @@ export { extractUsages } from "./extract-usages";
2
2
 
3
3
  export type {
4
4
  ExtractedUsages,
5
+ PreKeyUsage,
5
6
  KeyUsage,
6
7
  ReplacementUsage,
7
8
  RichUsage,
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import pc from "picocolors";
4
3
  import { Project, type SourceFile } from "ts-morph";
4
+ import { createLogger } from "../scan-logger";
5
5
 
6
6
  /**
7
7
  * Load source files from a tsconfig.
@@ -20,6 +20,7 @@ export function loadSourceFilesFromTsconfig(
20
20
  tsconfigPath: string,
21
21
  debug: boolean,
22
22
  ): SourceFile[] {
23
+ const log = createLogger(debug);
23
24
  // ---------------------------------------------------------------------------
24
25
  // 1. Try loading source files directly from the given tsconfig
25
26
  // ---------------------------------------------------------------------------
@@ -35,13 +36,7 @@ export function loadSourceFilesFromTsconfig(
35
36
  const references: { path: string }[] = rawConfig.references ?? [];
36
37
 
37
38
  if (references.length === 0) return [];
38
-
39
- if (debug) {
40
- console.log(
41
- pc.dim(" (info)") +
42
- pc.gray(" no source files found, following project references"),
43
- );
44
- }
39
+ log("info", `no source files found, following project references`);
45
40
 
46
41
  // ---------------------------------------------------------------------------
47
42
  // 3. Load source files from each referenced tsconfig
@@ -53,20 +48,11 @@ export function loadSourceFilesFromTsconfig(
53
48
 
54
49
  // Skip missing referenced tsconfig files
55
50
  if (!fs.existsSync(refPath)) {
56
- if (debug) {
57
- console.log(
58
- pc.dim(" (warn)") +
59
- pc.gray(` referenced tsconfig not found: ${refPath}`),
60
- );
61
- }
51
+ log("warn", `referenced tsconfig not found: ${refPath}`);
62
52
  continue;
63
53
  }
64
54
 
65
- if (debug) {
66
- console.log(
67
- pc.dim(" (ref)") + pc.gray(` ${path.relative(process.cwd(), refPath)}`),
68
- );
69
- }
55
+ log("ref ", `${path.relative(process.cwd(), refPath)}`);
70
56
 
71
57
  const refProject = new Project({ tsConfigFilePath: refPath });
72
58
  collected.push(...refProject.getSourceFiles());
@@ -22,6 +22,13 @@ interface SourceLocation {
22
22
  column: number;
23
23
  }
24
24
 
25
+ /** Static translation prreKey usage extracted from a source file. */
26
+ export interface PreKeyUsage
27
+ extends Omit<TranslatorBinding, "method">, SourceLocation {
28
+ localName: string;
29
+ preKey?: string;
30
+ }
31
+
25
32
  /** Static translation key usage extracted from a source file. */
26
33
  export interface KeyUsage extends TranslatorBinding, SourceLocation {
27
34
  localName: string; // local binding name (e.g. `t`, `hasKey`)
@@ -47,7 +54,8 @@ export interface RichUsage extends TranslatorBinding, SourceLocation {
47
54
 
48
55
  /** Aggregated static translator usages extracted from a project. */
49
56
  export interface ExtractedUsages {
50
- keys: KeyUsage[];
51
- replacements: ReplacementUsage[];
57
+ preKey: PreKeyUsage[];
58
+ key: KeyUsage[];
59
+ replacement: ReplacementUsage[];
52
60
  rich: RichUsage[];
53
61
  }
@@ -9,6 +9,7 @@ import {
9
9
  } from "../../core";
10
10
  import { printTitle } from "../../features/print-title";
11
11
  import { spinner } from "../spinner";
12
+ import { dedupePreKeyUsages } from "./dedupe-pre-key-usages";
12
13
  import { printSummary } from "./print-summary";
13
14
 
14
15
  function resolveConfigKey(
@@ -50,10 +51,16 @@ export async function check(
50
51
 
51
52
  // per-config usages
52
53
  const scopedUsages = {
53
- keys: usages.keys.filter(
54
+ preKey: dedupePreKeyUsages(
55
+ usages.preKey.filter(
56
+ (u) =>
57
+ resolveConfigKey(u.configKey, defaultConfigKey) === configKey,
58
+ ),
59
+ ),
60
+ key: usages.key.filter(
54
61
  (u) => resolveConfigKey(u.configKey, defaultConfigKey) === configKey,
55
62
  ),
56
- replacements: usages.replacements.filter(
63
+ replacement: usages.replacement.filter(
57
64
  (u) => resolveConfigKey(u.configKey, defaultConfigKey) === configKey,
58
65
  ),
59
66
  rich: usages.rich.filter(
@@ -0,0 +1,25 @@
1
+ import type { PreKeyUsage } from "../../core/extract-usages";
2
+
3
+ /**
4
+ * Deduplicate preKey usages from the same translator factory call.
5
+ *
6
+ * A single factory call (e.g. `useTranslator("home")`) may expose
7
+ * multiple methods (`t`, `tRich`, ...).
8
+ *
9
+ * PreKey validation should happen once per factory call,
10
+ * not once per method, otherwise diagnostics would be duplicated.
11
+ */
12
+ export function dedupePreKeyUsages(usages: PreKeyUsage[]): PreKeyUsage[] {
13
+ const seen = new Set<string>();
14
+
15
+ return usages.filter((u) => {
16
+ const sig = [u.factory, u.configKey, u.preKey, u.file, u.line].join("|");
17
+
18
+ if (seen.has(sig)) {
19
+ return false;
20
+ } else {
21
+ seen.add(sig);
22
+ return true;
23
+ }
24
+ });
25
+ }
@@ -13,9 +13,9 @@ export function printSummary(configId: string, grouped: DiagnosticGroup[]) {
13
13
 
14
14
  // Log problems
15
15
  for (const group of grouped) {
16
- const { method, messageKey, problems, file, lines } = group;
16
+ const { factory, method, messageKey, problems, file, lines } = group;
17
17
 
18
- const header = `${messageKey} ${`(${method})`}\n`;
18
+ const header = `${messageKey} ${`(${method ?? factory})`}\n`;
19
19
 
20
20
  const problemsLine = [
21
21
  ...problems.map((p) => pc.gray(` - ${p}`)),