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 +99 -19
- package/package.json +4 -2
- package/src/baseline.js +2 -4
- package/src/check-hardcoded.js +11 -3
- package/src/check-placeholders.js +60 -0
- package/src/cli.js +75 -34
- package/src/config.js +52 -29
- package/src/errors.js +11 -0
- package/src/reporters/combined.js +2 -0
- package/src/reporters/console.js +10 -0
- package/src/reporters/index.js +10 -0
- package/src/reporters/markdown.js +12 -3
- package/src/reporters/sarif.js +105 -0
- package/src/rules/classify-identical.js +1 -3
- package/src/run-checks.js +12 -2
- package/src/source/scan-js-source.js +148 -0
- package/src/types.js +13 -3
package/README.md
CHANGED
|
@@ -2,21 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/i18n-smell-detector)
|
|
4
4
|
[](https://github.com/xue-moe/i18n-smell-detector/actions/workflows/ci.yml)
|
|
5
|
+
[](https://badge.socket.dev/npm/package/i18n-smell-detector)
|
|
5
6
|
[](./LICENSE)
|
|
6
7
|
|
|
7
8
|
Find localization issues that key coverage checks miss.
|
|
8
9
|
|
|
9
|
-
`i18n-smell-detector` checks localization files for
|
|
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:
|
|
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
|
|
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
|
|
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 `
|
|
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 }` |
|
|
192
|
-
| `source` | `string[]` | `['src/**/*.vue']` | Source globs used by `check-hardcoded`. `node_modules`
|
|
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)[]` |
|
|
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
|
|
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`
|
|
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
|
-
"description": "Find locale values
|
|
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
|
|
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,
|
package/src/check-hardcoded.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ||
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
227
|
+
throw appError(`Invalid ${name} pattern "${rule}": ${error.message}`, 'CONFIG_INVALID');
|
|
205
228
|
}
|
|
206
229
|
});
|
|
207
230
|
}
|
package/src/errors.js
ADDED
|
@@ -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
|
|
package/src/reporters/console.js
CHANGED
|
@@ -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
|
+
}
|
package/src/reporters/index.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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 {};
|