i18n-smell-detector 0.4.1 → 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 +99 -19
- package/package.json +3 -2
- package/src/baseline.js +2 -4
- package/src/check-hardcoded.js +9 -2
- package/src/check-placeholders.js +60 -0
- package/src/cli.js +7 -3
- package/src/config.js +32 -4
- 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 +13 -4
- package/src/reporters/sarif.js +105 -0
- 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.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
|
|
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,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
|
|
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
|
|
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 ||
|
|
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
|
|
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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { summarizeIssues } from './summary.js';
|
|
2
2
|
|
|
3
3
|
function escapeCell(value) {
|
|
4
|
-
return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
|
4
|
+
return String(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export function renderMarkdownReport(issues, options) {
|
|
@@ -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
|
+
}
|
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 {};
|