i18n-smell-detector 0.4.2 → 0.5.0

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.0",
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,6 +43,7 @@
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",
48
49
  "fast-glob": "^3.3.3"
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,6 @@
1
1
  import path from 'node:path';
2
2
  import fg from 'fast-glob';
3
+ import { scanJsSource } from './source/scan-js-source.js';
3
4
  import { scanVueSfc } from './source/scan-vue-sfc.js';
4
5
 
5
6
  function compareIssues(a, b) {
@@ -15,7 +16,7 @@ export async function checkHardcodedStrings(config, baseDir) {
15
16
  cwd: baseDir,
16
17
  absolute: true,
17
18
  onlyFiles: true,
18
- ignore: ['**/node_modules/**'],
19
+ ignore: ['**/node_modules/**', ...(config.hardcoded.ignoreFiles || [])],
19
20
  });
20
21
 
21
22
  if (files.length === 0) {
@@ -24,7 +25,7 @@ export async function checkHardcodedStrings(config, baseDir) {
24
25
 
25
26
  const issues = [];
26
27
  for (const file of files) {
27
- const fileIssues = await scanVueSfc(file, config);
28
+ const fileIssues = await scanSourceFile(file, config);
28
29
  for (const issue of fileIssues) {
29
30
  issues.push({
30
31
  ...issue,
@@ -35,3 +36,9 @@ export async function checkHardcodedStrings(config, baseDir) {
35
36
 
36
37
  return issues.sort(compareIssues);
37
38
  }
39
+
40
+ async function scanSourceFile(file, config) {
41
+ if (file.endsWith('.vue')) return scanVueSfc(file, config);
42
+ if (/\.[cm]?[jt]sx?$/.test(file)) return scanJsSource(file, config);
43
+ return [];
44
+ }
@@ -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
@@ -13,11 +13,12 @@ i18n-smell-detector
13
13
  Usage:
14
14
  i18n-smell-detector check-identical [options]
15
15
  i18n-smell-detector check-hardcoded [options]
16
+ i18n-smell-detector check-placeholders [options]
16
17
  i18n-smell-detector check [options]
17
18
 
18
19
  Options:
19
20
  -c, --config <path> Config file path
20
- --format <format> console | json | markdown (default: console)
21
+ --format <format> console | json | markdown | sarif (default: console)
21
22
  --fail-on <level> high | medium | low | none (default: config.failOn or high)
22
23
  --output <path> Write the full report to a file
23
24
  --baseline <path> Read a baseline file
@@ -28,6 +29,7 @@ Options:
28
29
  Example:
29
30
  i18n-smell-detector check-identical -c i18n-smell.config.mjs --format markdown
30
31
  i18n-smell-detector check-hardcoded -c i18n-smell.config.mjs --format json
32
+ i18n-smell-detector check-placeholders -c i18n-smell.config.mjs --format json
31
33
  `;
32
34
 
33
35
  function parseArgs(argv) {
@@ -43,7 +45,7 @@ function parseArgs(argv) {
43
45
  } else if (arg === '-c' || arg === '--config') {
44
46
  options.configPath = args[++index];
45
47
  } else if (arg === '--format') {
46
- options.format = readChoice(args[++index], ['console', 'json', 'markdown'], 'format');
48
+ options.format = readChoice(args[++index], ['console', 'json', 'markdown', 'sarif'], 'format');
47
49
  } else if (arg === '--fail-on') {
48
50
  options.failOn = readChoice(args[++index], ['none', 'low', 'medium', 'high'], 'fail-on level');
49
51
  } else if (arg === '--output') {
@@ -77,7 +79,7 @@ export async function runCli(argv) {
77
79
  return;
78
80
  }
79
81
 
80
- if (!['check-identical', 'check-hardcoded', 'check'].includes(options.command)) {
82
+ if (!['check-identical', 'check-hardcoded', 'check-placeholders', 'check'].includes(options.command)) {
81
83
  throw new Error(`Unknown command: ${options.command}\n${HELP}`);
82
84
  }
83
85
 
@@ -131,6 +133,7 @@ export async function runCli(argv) {
131
133
  function commandChecks(command, config) {
132
134
  if (command === 'check-identical') return ['identical'];
133
135
  if (command === 'check-hardcoded') return ['hardcoded'];
136
+ if (command === 'check-placeholders') return ['placeholders'];
134
137
  return enabledChecks(config);
135
138
  }
136
139
 
@@ -140,6 +143,7 @@ function renderSingleReport(result, options) {
140
143
  title: result.title,
141
144
  heading: result.heading,
142
145
  emptyMessage: result.emptyMessage,
146
+ check: result.check,
143
147
  });
144
148
  }
145
149
 
package/src/config.js CHANGED
@@ -14,7 +14,10 @@ const CONFIG_FILES = [
14
14
 
15
15
  const DEFAULT_PLACEHOLDER_PATTERNS = [
16
16
  String.raw`\{\{[^}]+\}\}`,
17
- String.raw`\{[^}]+\}`,
17
+ String.raw`(?<!\{)\{[^{}]+\}(?!\})`,
18
+ String.raw`%[sdif]`,
19
+ String.raw`%\([^)]+\)[sdif]`,
20
+ String.raw`\$\d+`,
18
21
  ];
19
22
 
20
23
  const DEFAULT_HARDCODED_ATTRIBUTES = [
@@ -25,6 +28,17 @@ const DEFAULT_HARDCODED_ATTRIBUTES = [
25
28
  'aria-description',
26
29
  ];
27
30
 
31
+ const DEFAULT_HARDCODED_FUNCTIONS = [
32
+ 'alert',
33
+ 'confirm',
34
+ 'toast.success',
35
+ 'toast.error',
36
+ ];
37
+
38
+ const DEFAULT_SOURCE_GLOBS = [
39
+ 'src/**/*.{vue,js,jsx,ts,tsx}',
40
+ ];
41
+
28
42
  async function exists(filePath) {
29
43
  try {
30
44
  await access(filePath);
@@ -102,8 +116,8 @@ function validateConfig(config) {
102
116
  throw new Error('Config checks must be an object');
103
117
  }
104
118
 
105
- if ('format' in candidate && !['console', 'json', 'markdown'].includes(candidate.format)) {
106
- throw new Error('Config format must be console, json, or markdown');
119
+ if ('format' in candidate && !['console', 'json', 'markdown', 'sarif'].includes(candidate.format)) {
120
+ throw new Error('Config format must be console, json, markdown, or sarif');
107
121
  }
108
122
 
109
123
  if ('output' in candidate && typeof candidate.output !== 'string') {
@@ -118,6 +132,15 @@ function validateConfig(config) {
118
132
  if ('vueAttributes' in hardcoded && !isStringArray(hardcoded.vueAttributes)) {
119
133
  throw new Error('Config hardcoded.vueAttributes must be an array of strings');
120
134
  }
135
+ if ('jsxAttributes' in hardcoded && !isStringArray(hardcoded.jsxAttributes)) {
136
+ throw new Error('Config hardcoded.jsxAttributes must be an array of strings');
137
+ }
138
+ if ('functions' in hardcoded && !isStringArray(hardcoded.functions)) {
139
+ throw new Error('Config hardcoded.functions must be an array of strings');
140
+ }
141
+ if ('ignoreFiles' in hardcoded && !isStringArray(hardcoded.ignoreFiles)) {
142
+ throw new Error('Config hardcoded.ignoreFiles must be an array of glob strings');
143
+ }
121
144
  validateRuleList(hardcoded.ignoreValues, 'hardcoded.ignoreValues');
122
145
  validateRuleList(hardcoded.ignorePatterns, 'hardcoded.ignorePatterns');
123
146
  }
@@ -137,15 +160,17 @@ function withDefaults(config) {
137
160
  checks: {
138
161
  identical: true,
139
162
  hardcoded: true,
163
+ placeholders: true,
140
164
  ...(config.checks || {}),
141
165
  },
142
166
  ...config,
143
167
  checks: {
144
168
  identical: true,
145
169
  hardcoded: true,
170
+ placeholders: true,
146
171
  ...(config.checks || {}),
147
172
  },
148
- source: config.source || ['src/**/*.vue'],
173
+ source: config.source || DEFAULT_SOURCE_GLOBS,
149
174
  hardcoded: normalizeHardcodedConfig(config.hardcoded || {}),
150
175
  placeholderPatterns: normalizePlaceholderPatterns(config.placeholderPatterns || DEFAULT_PLACEHOLDER_PATTERNS),
151
176
  ignoreCodeLike: config.ignoreCodeLike ?? true,
@@ -186,6 +211,9 @@ function normalizePlaceholderPatterns(patterns) {
186
211
  function normalizeHardcodedConfig(config) {
187
212
  return {
188
213
  vueAttributes: config.vueAttributes || DEFAULT_HARDCODED_ATTRIBUTES,
214
+ jsxAttributes: config.jsxAttributes || DEFAULT_HARDCODED_ATTRIBUTES,
215
+ functions: config.functions || DEFAULT_HARDCODED_FUNCTIONS,
216
+ ignoreFiles: config.ignoreFiles || [],
189
217
  ignoreValues: config.ignoreValues || [],
190
218
  ignorePatterns: normalizeRegexRules(config.ignorePatterns || [], 'hardcoded.ignorePatterns'),
191
219
  };
@@ -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
+ }
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 {};