i18n-smell-detector 0.4.2 → 0.5.1

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/README.md CHANGED
@@ -2,21 +2,23 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/i18n-smell-detector.svg)](https://www.npmjs.com/package/i18n-smell-detector)
4
4
  [![CI](https://github.com/xue-moe/i18n-smell-detector/actions/workflows/ci.yml/badge.svg)](https://github.com/xue-moe/i18n-smell-detector/actions/workflows/ci.yml)
5
+ [![Socket Badge](https://badge.socket.dev/npm/package/i18n-smell-detector)](https://badge.socket.dev/npm/package/i18n-smell-detector)
5
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
7
 
7
8
  Find localization issues that key coverage checks miss.
8
9
 
9
- `i18n-smell-detector` checks localization files for keys whose translated value is identical to the base locale. It can also scan Vue templates for hardcoded user-visible strings.
10
+ `i18n-smell-detector` checks localization files for copied base-locale values and placeholder mismatches. It can also scan Vue, JavaScript, TypeScript, JSX, and TSX source for hardcoded user-visible strings.
10
11
 
11
- Current scope: `check-hardcoded` scans Vue templates only; JavaScript, TypeScript, and JSX strings are not scanned yet. Text classification is rule-based and conservative, so projects with many proper nouns, product codes, or non-English copy should start with `--fail-on none` and tune allowlists before enforcing CI.
12
+ Current scope: text classification is rule-based and conservative, so projects with many proper nouns, product codes, or non-English copy should start with `--fail-on none` and tune allowlists before enforcing CI.
12
13
 
13
14
  ## Features
14
15
 
15
16
  - Detect values that are identical to the base locale
16
- - Detect hardcoded strings in Vue template text and selected attributes
17
+ - Detect missing or extra placeholders across locale values
18
+ - Detect hardcoded strings in Vue templates, JSX/TSX text and attributes, and configured JS/TS function calls
17
19
  - Ignore expected matches by key or value
18
20
  - Classify findings as `high`, `medium`, `low`, or `ignored`
19
- - Output results as console text, JSON, or Markdown
21
+ - Output results as console text, JSON, Markdown, or SARIF
20
22
  - Fail CI based on a configurable severity level
21
23
 
22
24
  ## Install
@@ -33,6 +35,7 @@ Run the check:
33
35
  npx i18n-smell-detector check --config i18n-smell.config.mjs
34
36
  npx i18n-smell-detector check-identical --config i18n-smell.config.mjs
35
37
  npx i18n-smell-detector check-hardcoded --config i18n-smell.config.mjs
38
+ npx i18n-smell-detector check-placeholders --config i18n-smell.config.mjs
36
39
  ```
37
40
 
38
41
  Output formats:
@@ -43,6 +46,7 @@ npx i18n-smell-detector check-identical --format json
43
46
  npx i18n-smell-detector check-identical --format markdown
44
47
  npx i18n-smell-detector check-hardcoded --format json
45
48
  npx i18n-smell-detector check --format markdown --output i18n-report.md
49
+ npx i18n-smell-detector check --format sarif --output i18n-smell.sarif
46
50
  ```
47
51
 
48
52
  CLI options:
@@ -50,7 +54,7 @@ CLI options:
50
54
  | Option | Description |
51
55
  |---|---|
52
56
  | `-c, --config <path>` | Use a specific config file. |
53
- | `--format <format>` | Print `console`, `json`, or `markdown` output. |
57
+ | `--format <format>` | Print `console`, `json`, `markdown`, or `sarif` output. |
54
58
  | `--fail-on <level>` | Exit with code `1` for findings at `high`, `medium`, or `low`. Use `none` while tuning. |
55
59
  | `--include-ignored` | Include ignored findings in the report. |
56
60
  | `--output <path>` | Write the full report to a file and print a short summary. |
@@ -120,10 +124,11 @@ export default {
120
124
  baseLocale: 'en',
121
125
  checks: {
122
126
  identical: true,
123
- hardcoded: true
127
+ hardcoded: true,
128
+ placeholders: true
124
129
  },
125
130
  source: [
126
- 'src/**/*.vue'
131
+ 'src/**/*.{vue,js,jsx,ts,tsx}'
127
132
  ],
128
133
 
129
134
  locales: {
@@ -149,8 +154,8 @@ export default {
149
154
  ],
150
155
 
151
156
  placeholderPatterns: [
152
- '\\{[^}]+\\}',
153
157
  '\\{\\{[^}]+\\}\\}',
158
+ '(?<!\\{)\\{[^{}]+\\}(?!\\})',
154
159
  '%[sdif]',
155
160
  '%\\([^)]+\\)[sdif]',
156
161
  '\\$\\d+'
@@ -165,6 +170,22 @@ export default {
165
170
  'aria-label',
166
171
  'aria-description'
167
172
  ],
173
+ jsxAttributes: [
174
+ 'placeholder',
175
+ 'title',
176
+ 'alt',
177
+ 'aria-label',
178
+ 'aria-description'
179
+ ],
180
+ functions: [
181
+ 'toast.success',
182
+ 'toast.error',
183
+ 'alert',
184
+ 'confirm'
185
+ ],
186
+ ignoreFiles: [
187
+ 'src/generated/**'
188
+ ],
168
189
  ignoreValues: [
169
190
  'OK',
170
191
  'ID'
@@ -188,14 +209,17 @@ export default {
188
209
  | Option | Type | Default | Description |
189
210
  |---|---|---:|---|
190
211
  | `baseLocale` | `string` | `'en'` | Locale used as the comparison source. It must also be listed in `locales`. |
191
- | `checks` | `{ identical?: boolean, hardcoded?: boolean }` | both enabled | Checks run by the combined `check` command. Existing single-check commands ignore this setting. |
192
- | `source` | `string[]` | `['src/**/*.vue']` | Source globs used by `check-hardcoded`. `node_modules` is ignored. |
212
+ | `checks` | `{ identical?: boolean, hardcoded?: boolean, placeholders?: boolean }` | all enabled | Checks run by the combined `check` command. Existing single-check commands ignore this setting. |
213
+ | `source` | `string[]` | `['src/**/*.{vue,js,jsx,ts,tsx}']` | Source globs used by `check-hardcoded`. `node_modules` and `hardcoded.ignoreFiles` are ignored. |
193
214
  | `locales` | `Record<string, string>` | `{}` | Map of locale codes to JSON locale file paths. Relative paths are resolved from the config file directory. |
194
215
  | `allowIdenticalKeys` | `(string \| RegExp)[]` | `[]` | Key rules that are allowed to match the base locale. String rules support `*` wildcards, for example `brand.*`. `RegExp` rules are tested against the flattened key. |
195
216
  | `allowIdenticalValues` | `(string \| RegExp)[]` | `[]` | Value rules that are allowed to match the base locale. String rules are exact matches, for example `OK`, `API`, or `GET`. `RegExp` rules are tested against the full value. |
196
- | `placeholderPatterns` | `(string \| RegExp)[]` | `{...}` and `{{...}}` patterns | Placeholder patterns ignored when the whole value is only placeholders and whitespace. String entries must be valid regular expression sources. |
217
+ | `placeholderPatterns` | `(string \| RegExp)[]` | common brace, printf, named printf, and `$1` patterns | Placeholder patterns used by `check-identical` placeholder-only ignores and `check-placeholders` mismatch detection. String entries must be valid regular expression sources. |
197
218
  | `ignoreCodeLike` | `boolean` | `true` | Ignore built-in code-like values such as paths, colors, acronyms, and identifiers. Set to `false` to report them. |
198
219
  | `hardcoded.vueAttributes` | `string[]` | `placeholder`, `title`, `alt`, `aria-label`, `aria-description` | Static Vue attributes checked by `check-hardcoded`. Dynamic bindings such as `:placeholder` are ignored. |
220
+ | `hardcoded.jsxAttributes` | `string[]` | `placeholder`, `title`, `alt`, `aria-label`, `aria-description` | Static JSX/TSX attributes checked by `check-hardcoded`. Expression attributes are ignored unless they contain a static string literal. |
221
+ | `hardcoded.functions` | `string[]` | `alert`, `confirm`, `toast.success`, `toast.error` | JS/TS call expressions whose static string arguments are checked by `check-hardcoded`. |
222
+ | `hardcoded.ignoreFiles` | `string[]` | `[]` | Glob patterns excluded from `check-hardcoded`, useful for generated files or vendored code. |
199
223
  | `hardcoded.ignoreValues` | `(string \| RegExp)[]` | `[]` | Exact values or regular expressions ignored by `check-hardcoded`. |
200
224
  | `hardcoded.ignorePatterns` | `(string \| RegExp)[]` | `[]` | Regular expression patterns ignored by `check-hardcoded`. String entries are compiled as regular expressions. |
201
225
  | `failOn` | `'high' \| 'medium' \| 'low' \| 'none'` | `'high'` | Lowest severity that makes the CLI exit with code `1`. Use `none` to report without failing. |
@@ -203,7 +227,7 @@ export default {
203
227
  | `ignoreCase` | `boolean` | `false` | Compare values case-insensitively when set to `true`. |
204
228
  | `ignoreSameLanguageFamily` | `boolean` | `true` | Ignore regional variants that share the same language family, such as `en` and `en-GB`. Set to `false` to check regional variants too. |
205
229
  | `includeIgnored` | `boolean` | `false` | Include ignored findings in reports. Can also be enabled with `--include-ignored`. |
206
- | `format` | `'console' \| 'json' \| 'markdown'` | `'console'` | Default report format. CLI `--format` takes precedence. |
230
+ | `format` | `'console' \| 'json' \| 'markdown' \| 'sarif'` | `'console'` | Default report format. CLI `--format` takes precedence. |
207
231
  | `output` | `string` | unset | Report file path. CLI `--output` takes precedence. |
208
232
  | `baseline` | `string` | unset | Baseline file path used by `--baseline` or `--update-baseline`. |
209
233
 
@@ -263,12 +287,53 @@ Summary counts include all findings, including ignored findings that may be omit
263
287
  | `value` | `string` | Identical target value. |
264
288
  | `severity` | `'high' \| 'medium' \| 'low' \| 'ignored'` | Classification assigned by the detection rules. |
265
289
  | `reason` | `string` | Short explanation for the classification. |
290
+ | `id` | `string` | Stable issue identifier used by JSON, SARIF, and baselines. |
266
291
 
267
292
  Ignored issues are omitted by default. Pass `--include-ignored` to include them in JSON, console, or Markdown output.
268
293
 
294
+ ## Placeholder output
295
+
296
+ `check-placeholders` reports target locale values whose placeholder set differs from the base locale:
297
+
298
+ ```txt
299
+ i18n-smell-detector: placeholder mismatches
300
+ high=1 medium=0 low=0 ignored=0
301
+
302
+ HIGH zh.user.greeting
303
+ value: "你好"
304
+ missing: {name}
305
+ reason: missing placeholder
306
+ ```
307
+
308
+ JSON output includes `missing` and `extra` arrays:
309
+
310
+ ```json
311
+ {
312
+ "summary": {
313
+ "high": 1,
314
+ "medium": 0,
315
+ "low": 0,
316
+ "ignored": 0
317
+ },
318
+ "issues": [
319
+ {
320
+ "key": "user.greeting",
321
+ "baseLocale": "en",
322
+ "targetLocale": "zh",
323
+ "value": "你好",
324
+ "missing": ["{name}"],
325
+ "extra": [],
326
+ "severity": "high",
327
+ "reason": "missing placeholder",
328
+ "id": "placeholders:zh:user.greeting"
329
+ }
330
+ ]
331
+ }
332
+ ```
333
+
269
334
  ## Hardcoded string output
270
335
 
271
- `check-hardcoded` reports Vue template text and static user-visible attributes:
336
+ `check-hardcoded` reports Vue template text, static Vue attributes, JSX/TSX text and attributes, and configured JS/TS function call strings:
272
337
 
273
338
  ```txt
274
339
  i18n-smell-detector: hardcoded strings
@@ -301,7 +366,8 @@ JSON output includes source locations:
301
366
  "value": "Profile settings",
302
367
  "severity": "high",
303
368
  "reason": "static template text",
304
- "kind": "vue-text"
369
+ "kind": "vue-text",
370
+ "id": "hardcoded:src/components/UserPanel.vue:12:15:Profile settings"
305
371
  }
306
372
  ]
307
373
  }
@@ -321,7 +387,8 @@ Disable checks during gradual rollout:
321
387
  export default {
322
388
  checks: {
323
389
  identical: true,
324
- hardcoded: false
390
+ hardcoded: false,
391
+ placeholders: true
325
392
  }
326
393
  };
327
394
  ```
@@ -342,6 +409,12 @@ Combined JSON output groups counts by check:
342
409
  "medium": 2,
343
410
  "low": 0,
344
411
  "ignored": 3
412
+ },
413
+ "placeholders": {
414
+ "high": 1,
415
+ "medium": 0,
416
+ "low": 0,
417
+ "ignored": 0
345
418
  }
346
419
  },
347
420
  "issues": []
@@ -355,10 +428,13 @@ Use `--output` to write the full report to a file. The CLI prints a short summar
355
428
  ```bash
356
429
  npx i18n-smell-detector check --format markdown --output reports/i18n-report.md
357
430
  npx i18n-smell-detector check --format json --output reports/i18n-report.json
431
+ npx i18n-smell-detector check --format sarif --output reports/i18n-smell.sarif
358
432
  ```
359
433
 
360
434
  Markdown output for `check` includes a summary table and one section per check, which makes it suitable for CI artifacts.
361
435
 
436
+ SARIF output uses stable issue ids as `partialFingerprints.stableId`, so it can be uploaded to code scanning tools that understand SARIF 2.1.0.
437
+
362
438
  ## Gradual adoption
363
439
 
364
440
  Use a baseline to ignore existing findings while still failing CI for new ones:
@@ -416,10 +492,11 @@ export default {
416
492
  /^HTTP\/\d(\.\d)?$/
417
493
  ],
418
494
  placeholderPatterns: [
419
- '\\{[^}]+\\}',
420
495
  '\\{\\{[^}]+\\}\\}',
496
+ '(?<!\\{)\\{[^{}]+\\}(?!\\})',
421
497
  '%[sdif]',
422
- '%\\([^)]+\\)[sdif]'
498
+ '%\\([^)]+\\)[sdif]',
499
+ '\\$\\d+'
423
500
  ],
424
501
  failOn: 'high'
425
502
  };
@@ -435,13 +512,16 @@ Useful tuning patterns:
435
512
  - Set `ignoreSameLanguageFamily: false` if you want to check regional variants such as `en-GB`, `en-AU`, or `pt-BR`.
436
513
  - Set `failOn: 'none'` while tuning a new project, then raise it to `high` or `medium` once the allowlist is stable.
437
514
  - Pass `--include-ignored` when reviewing why a value was ignored.
438
- - Use `source` to choose Vue files for `check-hardcoded`.
515
+ - Use `source` to choose Vue, JS, TS, JSX, and TSX files for `check-hardcoded`.
439
516
  - Tune `hardcoded.vueAttributes` if your project uses additional static attributes for user-visible text.
517
+ - Tune `hardcoded.jsxAttributes` if your React components use additional static attributes for user-visible text.
518
+ - Tune `hardcoded.functions` to check project-specific notification, modal, or validation helpers.
519
+ - Use `hardcoded.ignoreFiles` for generated source files.
440
520
  - Use `hardcoded.ignoreValues` and `hardcoded.ignorePatterns` for intentional literals such as `OK`, version labels, or product codes.
441
521
 
442
522
  ## Limitations
443
523
 
444
- `check-hardcoded` currently scans Vue templates only. JavaScript, TypeScript, and JSX string detection are not included yet. Classification uses heuristic rules rather than full language detection.
524
+ `check-hardcoded` scans configured static strings in Vue, JS, TS, JSX, and TSX. It does not evaluate runtime expressions or follow variables. Classification uses heuristic rules rather than full language detection.
445
525
 
446
526
  ## Notes
447
527
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "i18n-smell-detector",
3
- "version": "0.4.2",
4
- "description": "Find locale values copied from the base locale.",
3
+ "version": "0.5.1",
4
+ "description": "Find copied locale values, placeholder mismatches, and hardcoded user-visible strings.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "i18n-smell-detector": "./bin/i18n-smell-detector.js"
@@ -43,8 +43,10 @@
43
43
  "node": ">=18.17"
44
44
  },
45
45
  "dependencies": {
46
+ "@babel/parser": "^7.29.7",
46
47
  "@vue/compiler-dom": "^3.5.39",
47
48
  "@vue/compiler-sfc": "^3.5.39",
49
+ "commander": "^12.1.0",
48
50
  "fast-glob": "^3.3.3"
49
51
  },
50
52
  "devDependencies": {
package/src/baseline.js CHANGED
@@ -2,7 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
4
  export function issueId(check, issue) {
5
- if (check === 'identical') return `identical:${issue.targetLocale}:${issue.key}`;
5
+ if (check === 'identical' || check === 'placeholders') return `${check}:${issue.targetLocale}:${issue.key}`;
6
6
  return `hardcoded:${issue.file}:${issue.line}:${issue.column}:${issue.value}`;
7
7
  }
8
8
 
@@ -25,8 +25,6 @@ export async function loadBaseline(filePath) {
25
25
  }
26
26
 
27
27
  export function applyBaseline(results, ids) {
28
- if (!ids.size) return results;
29
-
30
28
  return results.map((result) => ({
31
29
  ...result,
32
30
  issues: result.issues.map((issue) => {
@@ -58,7 +56,7 @@ function toBaselineIssue(check, issue) {
58
56
  value: issue.value,
59
57
  };
60
58
 
61
- if (check === 'identical') {
59
+ if (check === 'identical' || check === 'placeholders') {
62
60
  return {
63
61
  ...base,
64
62
  key: issue.key,
@@ -1,5 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fg from 'fast-glob';
3
+ import { appError } from './errors.js';
4
+ import { scanJsSource } from './source/scan-js-source.js';
3
5
  import { scanVueSfc } from './source/scan-vue-sfc.js';
4
6
 
5
7
  function compareIssues(a, b) {
@@ -15,16 +17,16 @@ export async function checkHardcodedStrings(config, baseDir) {
15
17
  cwd: baseDir,
16
18
  absolute: true,
17
19
  onlyFiles: true,
18
- ignore: ['**/node_modules/**'],
20
+ ignore: ['**/node_modules/**', ...(config.hardcoded.ignoreFiles || [])],
19
21
  });
20
22
 
21
23
  if (files.length === 0) {
22
- throw new Error(`No source files matched: ${config.source.join(', ')}`);
24
+ throw appError(`No source files matched: ${config.source.join(', ')}`, 'SOURCE_NOT_FOUND');
23
25
  }
24
26
 
25
27
  const issues = [];
26
28
  for (const file of files) {
27
- const fileIssues = await scanVueSfc(file, config);
29
+ const fileIssues = await scanSourceFile(file, config);
28
30
  for (const issue of fileIssues) {
29
31
  issues.push({
30
32
  ...issue,
@@ -35,3 +37,9 @@ export async function checkHardcodedStrings(config, baseDir) {
35
37
 
36
38
  return issues.sort(compareIssues);
37
39
  }
40
+
41
+ async function scanSourceFile(file, config) {
42
+ if (file.endsWith('.vue')) return scanVueSfc(file, config);
43
+ if (/\.[cm]?[jt]sx?$/.test(file)) return scanJsSource(file, config);
44
+ return [];
45
+ }
@@ -0,0 +1,60 @@
1
+ function compareIssues(a, b) {
2
+ const rank = { high: 3, medium: 2, low: 1, ignored: 0 };
3
+ return rank[b.severity] - rank[a.severity]
4
+ || a.targetLocale.localeCompare(b.targetLocale)
5
+ || a.key.localeCompare(b.key);
6
+ }
7
+
8
+ export function extractPlaceholders(value, patterns) {
9
+ const placeholders = new Set();
10
+ const text = String(value);
11
+
12
+ for (const pattern of patterns) {
13
+ pattern.lastIndex = 0;
14
+ for (const match of text.matchAll(pattern)) {
15
+ placeholders.add(match[0]);
16
+ }
17
+ }
18
+
19
+ return [...placeholders].sort();
20
+ }
21
+
22
+ /**
23
+ * @param {Record<string, Record<string, string>>} locales
24
+ * @param {import('./types.js').DetectorConfig} config
25
+ * @returns {import('./types.js').PlaceholderIssue[]}
26
+ */
27
+ export function checkPlaceholders(locales, config) {
28
+ const base = locales[config.baseLocale];
29
+ const issues = [];
30
+
31
+ for (const [targetLocale, target] of Object.entries(locales)) {
32
+ if (targetLocale === config.baseLocale) continue;
33
+
34
+ for (const [key, baseValue] of Object.entries(base)) {
35
+ if (!(key in target)) continue;
36
+
37
+ const basePlaceholders = extractPlaceholders(baseValue, config.placeholderPatterns);
38
+ const targetPlaceholders = extractPlaceholders(target[key], config.placeholderPatterns);
39
+ const targetSet = new Set(targetPlaceholders);
40
+ const baseSet = new Set(basePlaceholders);
41
+ const missing = basePlaceholders.filter((placeholder) => !targetSet.has(placeholder));
42
+ const extra = targetPlaceholders.filter((placeholder) => !baseSet.has(placeholder));
43
+
44
+ if (missing.length === 0 && extra.length === 0) continue;
45
+
46
+ issues.push({
47
+ key,
48
+ baseLocale: config.baseLocale,
49
+ targetLocale,
50
+ value: target[key],
51
+ missing,
52
+ extra,
53
+ severity: missing.length > 0 ? 'high' : 'medium',
54
+ reason: missing.length > 0 ? 'missing placeholder' : 'extra placeholder',
55
+ });
56
+ }
57
+ }
58
+
59
+ return issues.sort(compareIssues);
60
+ }
package/src/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { Command, InvalidArgumentError } from 'commander';
3
4
  import { applyBaseline, loadBaseline, writeBaseline } from './baseline.js';
4
5
  import { loadConfig, resolveConfigPath } from './config.js';
6
+ import { appError } from './errors.js';
5
7
  import { flattenResults, renderCombinedReport, summarizeResults } from './reporters/combined.js';
6
8
  import { renderReport } from './reporters/index.js';
7
9
  import { enabledChecks, runChecks } from './run-checks.js';
@@ -13,11 +15,12 @@ i18n-smell-detector
13
15
  Usage:
14
16
  i18n-smell-detector check-identical [options]
15
17
  i18n-smell-detector check-hardcoded [options]
18
+ i18n-smell-detector check-placeholders [options]
16
19
  i18n-smell-detector check [options]
17
20
 
18
21
  Options:
19
22
  -c, --config <path> Config file path
20
- --format <format> console | json | markdown (default: console)
23
+ --format <format> console | json | markdown | sarif (default: console)
21
24
  --fail-on <level> high | medium | low | none (default: config.failOn or high)
22
25
  --output <path> Write the full report to a file
23
26
  --baseline <path> Read a baseline file
@@ -28,47 +31,83 @@ Options:
28
31
  Example:
29
32
  i18n-smell-detector check-identical -c i18n-smell.config.mjs --format markdown
30
33
  i18n-smell-detector check-hardcoded -c i18n-smell.config.mjs --format json
34
+ i18n-smell-detector check-placeholders -c i18n-smell.config.mjs --format json
31
35
  `;
32
36
 
33
37
  function parseArgs(argv) {
34
- const args = argv.slice(2);
35
- const command = args[0] && !args[0].startsWith('-') ? args.shift() : 'check-identical';
36
- const options = { command, includeIgnored: false, help: false, updateBaseline: false };
37
-
38
- for (let index = 0; index < args.length; index += 1) {
39
- const arg = args[index];
40
-
41
- if (arg === '-h' || arg === '--help') {
42
- options.help = true;
43
- } else if (arg === '-c' || arg === '--config') {
44
- options.configPath = args[++index];
45
- } else if (arg === '--format') {
46
- options.format = readChoice(args[++index], ['console', 'json', 'markdown'], 'format');
47
- } else if (arg === '--fail-on') {
48
- options.failOn = readChoice(args[++index], ['none', 'low', 'medium', 'high'], 'fail-on level');
49
- } else if (arg === '--output') {
50
- options.output = args[++index];
51
- } else if (arg === '--baseline') {
52
- options.baseline = args[++index];
53
- } else if (arg === '--update-baseline') {
54
- options.updateBaseline = true;
55
- } else if (arg === '--include-ignored') {
56
- options.includeIgnored = true;
57
- } else {
58
- throw new Error(`Unknown option: ${arg}`);
59
- }
38
+ const program = new Command()
39
+ .name('i18n-smell-detector')
40
+ .usage('[command] [options]')
41
+ .description('Find localization issues that key coverage checks miss.')
42
+ .argument('[command]', 'check-identical | check-hardcoded | check-placeholders | check', 'check-identical')
43
+ .option('-c, --config <path>', 'Config file path')
44
+ .option('--format <format>', 'console | json | markdown | sarif', readFormat)
45
+ .option('--fail-on <level>', 'high | medium | low | none', readFailOn)
46
+ .option('--output <path>', 'Write the full report to a file')
47
+ .option('--baseline <path>', 'Read a baseline file')
48
+ .option('--update-baseline', 'Write the current issues to the baseline file', false)
49
+ .option('--include-ignored', 'Print ignored matches as well', false)
50
+ .addHelpText('after', `
51
+
52
+ Example:
53
+ i18n-smell-detector check-identical -c i18n-smell.config.mjs --format markdown
54
+ i18n-smell-detector check-hardcoded -c i18n-smell.config.mjs --format json
55
+ i18n-smell-detector check-placeholders -c i18n-smell.config.mjs --format json`)
56
+ .exitOverride()
57
+ .configureOutput({
58
+ writeOut: () => {},
59
+ writeErr: () => {},
60
+ outputError: () => {},
61
+ });
62
+
63
+ try {
64
+ program.parse(argv, { from: 'node' });
65
+ } catch (error) {
66
+ if (error.code === 'commander.helpDisplayed') return { help: true };
67
+ throw appError(formatCommanderError(error), 'CLI_USAGE');
60
68
  }
61
69
 
62
- return options;
70
+ const [command] = program.args;
71
+ if (!['check-identical', 'check-hardcoded', 'check-placeholders', 'check'].includes(command)) {
72
+ throw appError(`Unknown command: ${command}\n${HELP}`, 'CLI_USAGE');
73
+ }
74
+
75
+ return {
76
+ command,
77
+ configPath: program.opts().config,
78
+ format: program.opts().format,
79
+ failOn: program.opts().failOn,
80
+ output: program.opts().output,
81
+ baseline: program.opts().baseline,
82
+ updateBaseline: program.opts().updateBaseline,
83
+ includeIgnored: program.opts().includeIgnored,
84
+ help: false,
85
+ };
86
+ }
87
+
88
+ function readFormat(value) {
89
+ return readChoice(value, ['console', 'json', 'markdown', 'sarif'], 'format');
90
+ }
91
+
92
+ function readFailOn(value) {
93
+ return readChoice(value, ['none', 'low', 'medium', 'high'], 'fail-on level');
63
94
  }
64
95
 
65
96
  function readChoice(value, choices, name) {
66
- if (!choices.includes(value)) {
67
- throw new Error(`Unsupported ${name}: ${value}`);
68
- }
97
+ if (!choices.includes(value)) throw new InvalidArgumentError(`Unsupported ${name}: ${value}`);
69
98
  return value;
70
99
  }
71
100
 
101
+ function formatCommanderError(error) {
102
+ if (error.code === 'commander.unknownOption') return `Unknown option: ${extractQuotedValue(error.message) || error.optionName || ''}`.trim();
103
+ if (error.code === 'commander.missingArgument') return `Missing value for option: ${error.optionName?.() || ''}`.trim();
104
+ return error.message.replace(/^error: /, '');
105
+ }
106
+
107
+ function extractQuotedValue(value) {
108
+ return value.match(/'([^']+)'/)?.[1];
109
+ }
110
+
72
111
  export async function runCli(argv) {
73
112
  const options = parseArgs(argv);
74
113
 
@@ -77,7 +116,7 @@ export async function runCli(argv) {
77
116
  return;
78
117
  }
79
118
 
80
- if (!['check-identical', 'check-hardcoded', 'check'].includes(options.command)) {
119
+ if (!['check-identical', 'check-hardcoded', 'check-placeholders', 'check'].includes(options.command)) {
81
120
  throw new Error(`Unknown command: ${options.command}\n${HELP}`);
82
121
  }
83
122
 
@@ -97,7 +136,7 @@ export async function runCli(argv) {
97
136
  const rawResults = await runChecks(effectiveConfig, configDir, checks);
98
137
 
99
138
  if (options.updateBaseline) {
100
- if (!effectiveConfig.baseline) throw new Error('Baseline path is required for --update-baseline');
139
+ if (!effectiveConfig.baseline) throw appError('Baseline path is required for --update-baseline', 'CLI_USAGE');
101
140
  const baselinePath = resolveFromConfigDir(effectiveConfig.baseline, configDir);
102
141
  const count = await writeBaseline(baselinePath, rawResults);
103
142
  console.log(`Baseline updated: ${effectiveConfig.baseline} (${count} issues)`);
@@ -131,6 +170,7 @@ export async function runCli(argv) {
131
170
  function commandChecks(command, config) {
132
171
  if (command === 'check-identical') return ['identical'];
133
172
  if (command === 'check-hardcoded') return ['hardcoded'];
173
+ if (command === 'check-placeholders') return ['placeholders'];
134
174
  return enabledChecks(config);
135
175
  }
136
176
 
@@ -140,6 +180,7 @@ function renderSingleReport(result, options) {
140
180
  title: result.title,
141
181
  heading: result.heading,
142
182
  emptyMessage: result.emptyMessage,
183
+ check: result.check,
143
184
  });
144
185
  }
145
186
 
@@ -160,7 +201,7 @@ async function writeOutput(filePath, report) {
160
201
  await mkdir(path.dirname(filePath), { recursive: true });
161
202
  await writeFile(filePath, report);
162
203
  } catch (error) {
163
- throw new Error(`Failed to write report file: ${filePath}\nReason: ${error.message}`);
204
+ throw appError(`Failed to write report file: ${filePath}\nReason: ${error.message}`, 'OUTPUT_WRITE_FAILED');
164
205
  }
165
206
  }
166
207
 
package/src/config.js CHANGED
@@ -2,6 +2,7 @@ import { access } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { createRequire } from 'node:module';
5
+ import { appError } from './errors.js';
5
6
 
6
7
  const require = createRequire(import.meta.url);
7
8
 
@@ -14,7 +15,10 @@ const CONFIG_FILES = [
14
15
 
15
16
  const DEFAULT_PLACEHOLDER_PATTERNS = [
16
17
  String.raw`\{\{[^}]+\}\}`,
17
- String.raw`\{[^}]+\}`,
18
+ String.raw`(?<!\{)\{[^{}]+\}(?!\})`,
19
+ String.raw`%[sdif]`,
20
+ String.raw`%\([^)]+\)[sdif]`,
21
+ String.raw`\$\d+`,
18
22
  ];
19
23
 
20
24
  const DEFAULT_HARDCODED_ATTRIBUTES = [
@@ -25,6 +29,17 @@ const DEFAULT_HARDCODED_ATTRIBUTES = [
25
29
  'aria-description',
26
30
  ];
27
31
 
32
+ const DEFAULT_HARDCODED_FUNCTIONS = [
33
+ 'alert',
34
+ 'confirm',
35
+ 'toast.success',
36
+ 'toast.error',
37
+ ];
38
+
39
+ const DEFAULT_SOURCE_GLOBS = [
40
+ 'src/**/*.{vue,js,jsx,ts,tsx}',
41
+ ];
42
+
28
43
  async function exists(filePath) {
29
44
  try {
30
45
  await access(filePath);
@@ -37,7 +52,7 @@ async function exists(filePath) {
37
52
  export async function resolveConfigPath(explicitPath, cwd) {
38
53
  if (explicitPath) {
39
54
  const absolute = path.isAbsolute(explicitPath) ? explicitPath : path.resolve(cwd, explicitPath);
40
- if (!(await exists(absolute))) throw new Error(`Config file not found: ${absolute}`);
55
+ if (!(await exists(absolute))) throw appError(`Config file not found: ${absolute}`, 'CONFIG_NOT_FOUND');
41
56
  return absolute;
42
57
  }
43
58
 
@@ -46,7 +61,7 @@ export async function resolveConfigPath(explicitPath, cwd) {
46
61
  if (await exists(absolute)) return absolute;
47
62
  }
48
63
 
49
- throw new Error(`Config file not found. Expected one of: ${CONFIG_FILES.join(', ')}`);
64
+ throw appError(`Config file not found. Expected one of: ${CONFIG_FILES.join(', ')}`, 'CONFIG_NOT_FOUND');
50
65
  }
51
66
 
52
67
  export async function loadConfig(configPath) {
@@ -65,21 +80,21 @@ export async function loadConfig(configPath) {
65
80
 
66
81
  function validateConfig(config) {
67
82
  if (!config || typeof config !== 'object') {
68
- throw new Error('Config must export an object');
83
+ throw appError('Config must export an object', 'CONFIG_INVALID');
69
84
  }
70
85
 
71
86
  const candidate = /** @type {Record<string, unknown>} */ (config);
72
87
  if ('baseLocale' in candidate && (typeof candidate.baseLocale !== 'string' || !candidate.baseLocale)) {
73
- throw new Error('Config baseLocale must be a non-empty string');
88
+ throw appError('Config baseLocale must be a non-empty string', 'CONFIG_INVALID');
74
89
  }
75
90
 
76
91
  if ('locales' in candidate && (!candidate.locales || typeof candidate.locales !== 'object' || Array.isArray(candidate.locales))) {
77
- throw new Error('Config locales must be an object');
92
+ throw appError('Config locales must be an object', 'CONFIG_INVALID');
78
93
  }
79
94
 
80
95
  for (const [locale, value] of Object.entries(candidate.locales || {})) {
81
96
  if (typeof value !== 'string' || !value) {
82
- throw new Error(`Config locales.${locale} must be a file path string`);
97
+ throw appError(`Config locales.${locale} must be a file path string`, 'CONFIG_INVALID');
83
98
  }
84
99
  }
85
100
 
@@ -87,36 +102,45 @@ function validateConfig(config) {
87
102
  validateRuleList(candidate.allowIdenticalValues, 'allowIdenticalValues');
88
103
 
89
104
  if ('placeholderPatterns' in candidate && !Array.isArray(candidate.placeholderPatterns)) {
90
- throw new Error('Config placeholderPatterns must be an array of strings or RegExp objects');
105
+ throw appError('Config placeholderPatterns must be an array of strings or RegExp objects', 'CONFIG_INVALID');
91
106
  }
92
107
 
93
108
  if ('source' in candidate && !isStringArray(candidate.source)) {
94
- throw new Error('Config source must be an array of glob strings');
109
+ throw appError('Config source must be an array of glob strings', 'CONFIG_INVALID');
95
110
  }
96
111
 
97
112
  if ('hardcoded' in candidate && (!candidate.hardcoded || typeof candidate.hardcoded !== 'object' || Array.isArray(candidate.hardcoded))) {
98
- throw new Error('Config hardcoded must be an object');
113
+ throw appError('Config hardcoded must be an object', 'CONFIG_INVALID');
99
114
  }
100
115
 
101
116
  if ('checks' in candidate && (!candidate.checks || typeof candidate.checks !== 'object' || Array.isArray(candidate.checks))) {
102
- throw new Error('Config checks must be an object');
117
+ throw appError('Config checks must be an object', 'CONFIG_INVALID');
103
118
  }
104
119
 
105
- if ('format' in candidate && !['console', 'json', 'markdown'].includes(candidate.format)) {
106
- throw new Error('Config format must be console, json, or markdown');
120
+ if ('format' in candidate && !['console', 'json', 'markdown', 'sarif'].includes(candidate.format)) {
121
+ throw appError('Config format must be console, json, markdown, or sarif', 'CONFIG_INVALID');
107
122
  }
108
123
 
109
124
  if ('output' in candidate && typeof candidate.output !== 'string') {
110
- throw new Error('Config output must be a file path string');
125
+ throw appError('Config output must be a file path string', 'CONFIG_INVALID');
111
126
  }
112
127
 
113
128
  if ('baseline' in candidate && typeof candidate.baseline !== 'string') {
114
- throw new Error('Config baseline must be a file path string');
129
+ throw appError('Config baseline must be a file path string', 'CONFIG_INVALID');
115
130
  }
116
131
 
117
132
  const hardcoded = /** @type {Record<string, unknown>} */ (candidate.hardcoded || {});
118
133
  if ('vueAttributes' in hardcoded && !isStringArray(hardcoded.vueAttributes)) {
119
- throw new Error('Config hardcoded.vueAttributes must be an array of strings');
134
+ throw appError('Config hardcoded.vueAttributes must be an array of strings', 'CONFIG_INVALID');
135
+ }
136
+ if ('jsxAttributes' in hardcoded && !isStringArray(hardcoded.jsxAttributes)) {
137
+ throw appError('Config hardcoded.jsxAttributes must be an array of strings', 'CONFIG_INVALID');
138
+ }
139
+ if ('functions' in hardcoded && !isStringArray(hardcoded.functions)) {
140
+ throw appError('Config hardcoded.functions must be an array of strings', 'CONFIG_INVALID');
141
+ }
142
+ if ('ignoreFiles' in hardcoded && !isStringArray(hardcoded.ignoreFiles)) {
143
+ throw appError('Config hardcoded.ignoreFiles must be an array of glob strings', 'CONFIG_INVALID');
120
144
  }
121
145
  validateRuleList(hardcoded.ignoreValues, 'hardcoded.ignoreValues');
122
146
  validateRuleList(hardcoded.ignorePatterns, 'hardcoded.ignorePatterns');
@@ -134,18 +158,14 @@ function withDefaults(config) {
134
158
  ignoreCase: false,
135
159
  includeIgnored: false,
136
160
  failOn: 'high',
137
- checks: {
138
- identical: true,
139
- hardcoded: true,
140
- ...(config.checks || {}),
141
- },
142
161
  ...config,
143
162
  checks: {
144
163
  identical: true,
145
164
  hardcoded: true,
165
+ placeholders: true,
146
166
  ...(config.checks || {}),
147
167
  },
148
- source: config.source || ['src/**/*.vue'],
168
+ source: config.source || DEFAULT_SOURCE_GLOBS,
149
169
  hardcoded: normalizeHardcodedConfig(config.hardcoded || {}),
150
170
  placeholderPatterns: normalizePlaceholderPatterns(config.placeholderPatterns || DEFAULT_PLACEHOLDER_PATTERNS),
151
171
  ignoreCodeLike: config.ignoreCodeLike ?? true,
@@ -158,11 +178,11 @@ function isStringArray(value) {
158
178
 
159
179
  function validateRuleList(rules, name) {
160
180
  if (rules === undefined) return;
161
- if (!Array.isArray(rules)) throw new Error(`Config ${name} must be an array`);
181
+ if (!Array.isArray(rules)) throw appError(`Config ${name} must be an array`, 'CONFIG_INVALID');
162
182
 
163
183
  for (const rule of rules) {
164
184
  if (typeof rule !== 'string' && !(rule instanceof RegExp)) {
165
- throw new Error(`Config ${name} entries must be strings or RegExp objects`);
185
+ throw appError(`Config ${name} entries must be strings or RegExp objects`, 'CONFIG_INVALID');
166
186
  }
167
187
  }
168
188
  }
@@ -172,20 +192,23 @@ function normalizePlaceholderPatterns(patterns) {
172
192
  if (pattern instanceof RegExp) return withGlobalFlag(pattern);
173
193
 
174
194
  if (typeof pattern !== 'string') {
175
- throw new Error('Config placeholderPatterns entries must be strings or RegExp objects');
195
+ throw appError('Config placeholderPatterns entries must be strings or RegExp objects', 'CONFIG_INVALID');
176
196
  }
177
197
 
178
198
  try {
179
199
  return new RegExp(pattern, 'g');
180
200
  } catch (error) {
181
- throw new Error(`Invalid placeholder pattern "${pattern}": ${error.message}`);
201
+ throw appError(`Invalid placeholder pattern "${pattern}": ${error.message}`, 'CONFIG_INVALID');
182
202
  }
183
- });
203
+ }).sort((a, b) => b.source.length - a.source.length);
184
204
  }
185
205
 
186
206
  function normalizeHardcodedConfig(config) {
187
207
  return {
188
208
  vueAttributes: config.vueAttributes || DEFAULT_HARDCODED_ATTRIBUTES,
209
+ jsxAttributes: config.jsxAttributes || DEFAULT_HARDCODED_ATTRIBUTES,
210
+ functions: config.functions || DEFAULT_HARDCODED_FUNCTIONS,
211
+ ignoreFiles: config.ignoreFiles || [],
189
212
  ignoreValues: config.ignoreValues || [],
190
213
  ignorePatterns: normalizeRegexRules(config.ignorePatterns || [], 'hardcoded.ignorePatterns'),
191
214
  };
@@ -195,13 +218,13 @@ function normalizeRegexRules(rules, name) {
195
218
  return rules.map((rule) => {
196
219
  if (rule instanceof RegExp) return rule;
197
220
  if (typeof rule !== 'string') {
198
- throw new Error(`Config ${name} entries must be strings or RegExp objects`);
221
+ throw appError(`Config ${name} entries must be strings or RegExp objects`, 'CONFIG_INVALID');
199
222
  }
200
223
 
201
224
  try {
202
225
  return new RegExp(rule);
203
226
  } catch (error) {
204
- throw new Error(`Invalid ${name} pattern "${rule}": ${error.message}`);
227
+ throw appError(`Invalid ${name} pattern "${rule}": ${error.message}`, 'CONFIG_INVALID');
205
228
  }
206
229
  });
207
230
  }
package/src/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ export class AppError extends Error {
2
+ constructor(message, code = 'APP_ERROR') {
3
+ super(message);
4
+ this.name = 'AppError';
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ export function appError(message, code) {
10
+ return new AppError(message, code);
11
+ }
@@ -1,10 +1,12 @@
1
1
  import { renderConsoleReport } from './console.js';
2
2
  import { renderMarkdownReport } from './markdown.js';
3
+ import { renderSarifReport } from './sarif.js';
3
4
  import { summarizeIssues } from './summary.js';
4
5
 
5
6
  export function renderCombinedReport(results, options) {
6
7
  if (options.format === 'json') return renderCombinedJson(results, options);
7
8
  if (options.format === 'markdown') return renderCombinedMarkdown(results, options);
9
+ if (options.format === 'sarif') return renderSarifReport(results, options);
8
10
  return renderCombinedConsole(results, options);
9
11
  }
10
12
 
@@ -34,6 +34,9 @@ export function renderConsoleReport(issues, options) {
34
34
  for (const issue of visible) {
35
35
  lines.push(`${label(issue.severity)} ${formatLocation(issue)}`);
36
36
  lines.push(` value: "${preview(issue.value)}"`);
37
+ for (const detail of formatDetails(issue)) {
38
+ lines.push(` ${detail}`);
39
+ }
37
40
  lines.push(` reason: ${issue.reason}`);
38
41
  }
39
42
 
@@ -44,3 +47,10 @@ function formatLocation(issue) {
44
47
  if (issue.file) return `${issue.file}:${issue.line}:${issue.column}`;
45
48
  return `${issue.targetLocale}.${issue.key}`;
46
49
  }
50
+
51
+ function formatDetails(issue) {
52
+ const details = [];
53
+ if (issue.missing?.length) details.push(`missing: ${issue.missing.join(', ')}`);
54
+ if (issue.extra?.length) details.push(`extra: ${issue.extra.join(', ')}`);
55
+ return details;
56
+ }
@@ -1,9 +1,19 @@
1
1
  import { renderConsoleReport } from './console.js';
2
2
  import { renderJsonReport } from './json.js';
3
3
  import { renderMarkdownReport } from './markdown.js';
4
+ import { renderSarifReport } from './sarif.js';
4
5
 
5
6
  export function renderReport(issues, options) {
6
7
  if (options.format === 'json') return renderJsonReport(issues, options);
7
8
  if (options.format === 'markdown') return renderMarkdownReport(issues, options);
9
+ if (options.format === 'sarif') {
10
+ return renderSarifReport([
11
+ {
12
+ check: options.check || 'custom',
13
+ heading: options.heading,
14
+ issues,
15
+ },
16
+ ], options);
17
+ }
8
18
  return renderConsoleReport(issues, options);
9
19
  }
@@ -22,6 +22,9 @@ export function renderMarkdownReport(issues, options) {
22
22
  if (visible.some((issue) => issue.file)) {
23
23
  lines.push('| Level | Location | Value | Reason |');
24
24
  lines.push('|---|---|---|---|');
25
+ } else if (visible.some((issue) => issue.missing || issue.extra)) {
26
+ lines.push('| Level | Locale | Key | Value | Missing | Extra | Reason |');
27
+ lines.push('|---|---|---|---|---|---|---|');
25
28
  } else {
26
29
  lines.push('| Level | Locale | Key | Value | Reason |');
27
30
  lines.push('|---|---|---|---|---|');
@@ -33,9 +36,15 @@ export function renderMarkdownReport(issues, options) {
33
36
  `| ${escapeCell(issue.severity)} | ${escapeCell(formatLocation(issue))} | ${escapeCell(issue.value)} | ${escapeCell(issue.reason)} |`
34
37
  );
35
38
  } else {
36
- lines.push(
37
- `| ${escapeCell(issue.severity)} | ${escapeCell(issue.targetLocale)} | ${escapeCell(issue.key)} | ${escapeCell(issue.value)} | ${escapeCell(issue.reason)} |`
38
- );
39
+ if (issue.missing || issue.extra) {
40
+ lines.push(
41
+ `| ${escapeCell(issue.severity)} | ${escapeCell(issue.targetLocale)} | ${escapeCell(issue.key)} | ${escapeCell(issue.value)} | ${escapeCell((issue.missing || []).join(', '))} | ${escapeCell((issue.extra || []).join(', '))} | ${escapeCell(issue.reason)} |`
42
+ );
43
+ } else {
44
+ lines.push(
45
+ `| ${escapeCell(issue.severity)} | ${escapeCell(issue.targetLocale)} | ${escapeCell(issue.key)} | ${escapeCell(issue.value)} | ${escapeCell(issue.reason)} |`
46
+ );
47
+ }
39
48
  }
40
49
  }
41
50
 
@@ -0,0 +1,105 @@
1
+ export function renderSarifReport(results, options) {
2
+ const visible = results.flatMap((result) => {
3
+ const issues = options.includeIgnored ? result.issues : result.issues.filter((issue) => issue.severity !== 'ignored');
4
+ return issues.map((issue) => ({ check: result.check, title: result.heading, ...issue }));
5
+ });
6
+ const rules = new Map();
7
+
8
+ for (const issue of visible) {
9
+ const ruleId = ruleIdFor(issue);
10
+ if (!rules.has(ruleId)) {
11
+ rules.set(ruleId, {
12
+ id: ruleId,
13
+ name: issue.reason,
14
+ shortDescription: { text: issue.reason },
15
+ fullDescription: { text: `${issue.title || issue.check}: ${issue.reason}` },
16
+ defaultConfiguration: { level: sarifLevel(issue.severity) },
17
+ properties: {
18
+ check: issue.check,
19
+ severity: issue.severity,
20
+ },
21
+ });
22
+ }
23
+ }
24
+
25
+ return `${JSON.stringify({
26
+ version: '2.1.0',
27
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
28
+ runs: [
29
+ {
30
+ tool: {
31
+ driver: {
32
+ name: 'i18n-smell-detector',
33
+ informationUri: 'https://github.com/xue-moe/i18n-smell-detector',
34
+ rules: [...rules.values()],
35
+ },
36
+ },
37
+ results: visible.map(toSarifResult),
38
+ },
39
+ ],
40
+ }, null, 2)}\n`;
41
+ }
42
+
43
+ function toSarifResult(issue) {
44
+ const result = {
45
+ ruleId: ruleIdFor(issue),
46
+ level: sarifLevel(issue.severity),
47
+ message: { text: messageFor(issue) },
48
+ properties: {
49
+ id: issue.id,
50
+ check: issue.check,
51
+ severity: issue.severity,
52
+ reason: issue.reason,
53
+ key: issue.key,
54
+ baseLocale: issue.baseLocale,
55
+ targetLocale: issue.targetLocale,
56
+ value: issue.value,
57
+ missing: issue.missing,
58
+ extra: issue.extra,
59
+ },
60
+ };
61
+
62
+ if (issue.file) {
63
+ result.locations = [
64
+ {
65
+ physicalLocation: {
66
+ artifactLocation: { uri: issue.file },
67
+ region: {
68
+ startLine: issue.line,
69
+ startColumn: issue.column,
70
+ },
71
+ },
72
+ },
73
+ ];
74
+ }
75
+
76
+ result.partialFingerprints = {
77
+ stableId: issue.id,
78
+ };
79
+
80
+ return result;
81
+ }
82
+
83
+ function ruleIdFor(issue) {
84
+ return `i18n-smell/${issue.check}/${slug(issue.reason)}`;
85
+ }
86
+
87
+ function sarifLevel(severity) {
88
+ if (severity === 'high') return 'error';
89
+ if (severity === 'medium') return 'warning';
90
+ if (severity === 'low') return 'note';
91
+ return 'none';
92
+ }
93
+
94
+ function messageFor(issue) {
95
+ if (issue.file) return `${issue.reason}: ${issue.value}`;
96
+ const details = [];
97
+ if (issue.missing?.length) details.push(`missing ${issue.missing.join(', ')}`);
98
+ if (issue.extra?.length) details.push(`extra ${issue.extra.join(', ')}`);
99
+ const suffix = details.length ? ` (${details.join('; ')})` : '';
100
+ return `${issue.targetLocale}.${issue.key}: ${issue.reason}${suffix}`;
101
+ }
102
+
103
+ function slug(value) {
104
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'issue';
105
+ }
@@ -21,9 +21,7 @@ function isCodeLike(value) {
21
21
  function isPlaceholderOnly(value, patterns) {
22
22
  let text = value.trim();
23
23
 
24
- const orderedPatterns = [...(patterns || [])].sort((a, b) => b.source.length - a.source.length);
25
-
26
- for (const pattern of orderedPatterns) {
24
+ for (const pattern of patterns || []) {
27
25
  pattern.lastIndex = 0;
28
26
  text = text.replace(pattern, '');
29
27
  }
package/src/run-checks.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { checkHardcodedStrings } from './check-hardcoded.js';
2
2
  import { checkIdenticalTranslations } from './check-identical.js';
3
+ import { checkPlaceholders } from './check-placeholders.js';
3
4
  import { loadFlattenedLocales } from './locale/load-locales.js';
4
5
 
5
6
  const CHECK_META = {
@@ -13,15 +14,24 @@ const CHECK_META = {
13
14
  heading: 'Hardcoded strings',
14
15
  emptyMessage: 'No hardcoded strings found.',
15
16
  },
17
+ placeholders: {
18
+ title: 'placeholder mismatches',
19
+ heading: 'Placeholder mismatches',
20
+ emptyMessage: 'No placeholder mismatches found.',
21
+ },
16
22
  };
17
23
 
18
24
  export async function runChecks(config, configDir, names) {
19
25
  const results = [];
26
+ let locales;
20
27
 
21
28
  for (const check of names) {
22
29
  if (check === 'identical') {
23
- const locales = await loadFlattenedLocales(config, configDir);
30
+ locales ||= await loadFlattenedLocales(config, configDir);
24
31
  results.push({ check, ...CHECK_META[check], issues: checkIdenticalTranslations(locales, config) });
32
+ } else if (check === 'placeholders') {
33
+ locales ||= await loadFlattenedLocales(config, configDir);
34
+ results.push({ check, ...CHECK_META[check], issues: checkPlaceholders(locales, config) });
25
35
  } else if (check === 'hardcoded') {
26
36
  results.push({ check, ...CHECK_META[check], issues: await checkHardcodedStrings(config, configDir) });
27
37
  }
@@ -31,5 +41,5 @@ export async function runChecks(config, configDir, names) {
31
41
  }
32
42
 
33
43
  export function enabledChecks(config) {
34
- return ['identical', 'hardcoded'].filter((check) => config.checks[check]);
44
+ return ['identical', 'hardcoded', 'placeholders'].filter((check) => config.checks[check]);
35
45
  }
@@ -0,0 +1,148 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parse } from '@babel/parser';
3
+ import { classifyHardcoded } from '../rules/classify-hardcoded.js';
4
+
5
+ const SKIP_KEYS = new Set([
6
+ 'comments',
7
+ 'errors',
8
+ 'extra',
9
+ 'leadingComments',
10
+ 'loc',
11
+ 'range',
12
+ 'start',
13
+ 'end',
14
+ 'trailingComments',
15
+ ]);
16
+
17
+ function normalizeText(value) {
18
+ return value.replace(/\s+/g, ' ').trim();
19
+ }
20
+
21
+ function makeIssue({ file, loc, value, kind, baseReason, config }) {
22
+ const text = normalizeText(value);
23
+ if (!text) return null;
24
+
25
+ const classification = classifyHardcoded(text, config);
26
+ return {
27
+ file,
28
+ line: loc.start.line,
29
+ column: loc.start.column + 1,
30
+ value: text,
31
+ severity: classification.severity,
32
+ reason: classification.severity === 'ignored' ? classification.reason : baseReason,
33
+ kind,
34
+ };
35
+ }
36
+
37
+ export async function scanJsSource(file, config) {
38
+ const source = await readFile(file, 'utf8');
39
+ let ast;
40
+
41
+ try {
42
+ ast = parse(source, {
43
+ sourceType: 'unambiguous',
44
+ plugins: ['jsx', 'typescript'],
45
+ errorRecovery: false,
46
+ });
47
+ } catch (error) {
48
+ throw new Error(`Malformed JavaScript or TypeScript file ${file}: ${error.message}`);
49
+ }
50
+
51
+ const issues = [];
52
+ const functions = new Set(config.hardcoded.functions || []);
53
+ const jsxAttributes = new Set(config.hardcoded.jsxAttributes || []);
54
+
55
+ walk(ast, (node) => {
56
+ if (node.type === 'CallExpression' && functions.has(calleeName(node.callee))) {
57
+ for (const argument of node.arguments || []) {
58
+ const value = staticString(argument);
59
+ if (value === null) continue;
60
+
61
+ const issue = makeIssue({
62
+ file,
63
+ loc: argument.loc,
64
+ value,
65
+ kind: `js-call:${calleeName(node.callee)}`,
66
+ baseReason: `static string passed to ${calleeName(node.callee)}`,
67
+ config,
68
+ });
69
+ if (issue) issues.push(issue);
70
+ }
71
+ return;
72
+ }
73
+
74
+ if (node.type === 'JSXText') {
75
+ const issue = makeIssue({
76
+ file,
77
+ loc: node.loc,
78
+ value: node.value,
79
+ kind: 'jsx-text',
80
+ baseReason: 'static JSX text',
81
+ config,
82
+ });
83
+ if (issue) issues.push(issue);
84
+ return;
85
+ }
86
+
87
+ if (node.type === 'JSXAttribute' && jsxAttributes.has(jsxName(node.name))) {
88
+ const value = staticString(node.value);
89
+ if (value === null) return;
90
+
91
+ const issue = makeIssue({
92
+ file,
93
+ loc: node.value.loc,
94
+ value,
95
+ kind: `jsx-attribute:${jsxName(node.name)}`,
96
+ baseReason: `static ${jsxName(node.name)} attribute`,
97
+ config,
98
+ });
99
+ if (issue) issues.push(issue);
100
+ }
101
+ });
102
+
103
+ return issues;
104
+ }
105
+
106
+ function walk(node, visitor) {
107
+ if (!node || typeof node.type !== 'string') return;
108
+ visitor(node);
109
+
110
+ for (const [key, value] of Object.entries(node)) {
111
+ if (SKIP_KEYS.has(key)) continue;
112
+ if (Array.isArray(value)) {
113
+ for (const child of value) walk(child, visitor);
114
+ } else if (value && typeof value.type === 'string') {
115
+ walk(value, visitor);
116
+ }
117
+ }
118
+ }
119
+
120
+ function calleeName(node) {
121
+ if (!node) return '';
122
+ if (node.type === 'Identifier') return node.name;
123
+ if (node.type === 'ThisExpression') return 'this';
124
+ if (node.type === 'Super') return 'super';
125
+ if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
126
+ const object = calleeName(node.object);
127
+ const property = node.computed ? staticString(node.property) : calleeName(node.property);
128
+ return object && property ? `${object}.${property}` : '';
129
+ }
130
+ return '';
131
+ }
132
+
133
+ function jsxName(node) {
134
+ if (!node) return '';
135
+ if (node.type === 'JSXIdentifier') return node.name;
136
+ if (node.type === 'JSXNamespacedName') return `${jsxName(node.namespace)}:${jsxName(node.name)}`;
137
+ return '';
138
+ }
139
+
140
+ function staticString(node) {
141
+ if (!node) return null;
142
+ if (node.type === 'StringLiteral') return node.value;
143
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
144
+ return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join('');
145
+ }
146
+ if (node.type === 'JSXExpressionContainer') return staticString(node.expression);
147
+ return null;
148
+ }
package/src/types.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @typedef {'ignored' | 'low' | 'medium' | 'high'} Severity
3
- * @typedef {'console' | 'json' | 'markdown'} ReportFormat
3
+ * @typedef {'console' | 'json' | 'markdown' | 'sarif'} ReportFormat
4
4
  *
5
5
  * @typedef {Object} DetectorConfig
6
6
  * @property {string} baseLocale
@@ -9,8 +9,8 @@
9
9
  * @property {(string | RegExp)[]=} allowIdenticalValues
10
10
  * @property {(string | RegExp)[]=} placeholderPatterns
11
11
  * @property {string[]=} source
12
- * @property {{vueAttributes: string[], ignoreValues: (string | RegExp)[], ignorePatterns: RegExp[]}=} hardcoded
13
- * @property {{identical: boolean, hardcoded: boolean}=} checks
12
+ * @property {{vueAttributes: string[], jsxAttributes: string[], functions: string[], ignoreFiles: string[], ignoreValues: (string | RegExp)[], ignorePatterns: RegExp[]}=} hardcoded
13
+ * @property {{identical: boolean, hardcoded: boolean, placeholders: boolean}=} checks
14
14
  * @property {ReportFormat=} format
15
15
  * @property {string=} output
16
16
  * @property {string=} baseline
@@ -37,6 +37,16 @@
37
37
  * @property {Severity} severity
38
38
  * @property {string} reason
39
39
  * @property {string} kind
40
+ *
41
+ * @typedef {Object} PlaceholderIssue
42
+ * @property {string} key
43
+ * @property {string} baseLocale
44
+ * @property {string} targetLocale
45
+ * @property {string} value
46
+ * @property {string[]} missing
47
+ * @property {string[]} extra
48
+ * @property {Severity} severity
49
+ * @property {string} reason
40
50
  */
41
51
 
42
52
  export {};