i18n-smell-detector 0.3.0 → 0.4.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 +134 -4
- package/package.json +12 -2
- package/src/baseline.js +75 -0
- package/src/cli.js +89 -15
- package/src/config.js +26 -0
- package/src/reporters/combined.js +66 -0
- package/src/rules/classify-hardcoded.js +1 -1
- package/src/rules/classify-identical.js +1 -1
- package/src/run-checks.js +35 -0
- package/src/types.js +4 -0
package/README.md
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
[](https://github.com/xue-moe/i18n-smell-detector/actions/workflows/ci.yml)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
|
|
7
|
-
Find
|
|
7
|
+
Find localization issues that key coverage checks miss.
|
|
8
8
|
|
|
9
9
|
`i18n-smell-detector` checks localization files for keys whose translated value is identical to the base locale. It can also scan Vue templates for hardcoded user-visible strings.
|
|
10
10
|
|
|
11
|
+
Current scope: `check-hardcoded` scans Vue templates only; JavaScript, TypeScript, and JSX strings are not scanned yet. Text classification is rule-based and conservative, so projects with many proper nouns, product codes, or non-English copy should start with `--fail-on none` and tune allowlists before enforcing CI.
|
|
12
|
+
|
|
11
13
|
## Features
|
|
12
14
|
|
|
13
15
|
- Detect values that are identical to the base locale
|
|
@@ -28,6 +30,7 @@ npm install -D i18n-smell-detector
|
|
|
28
30
|
Run the check:
|
|
29
31
|
|
|
30
32
|
```bash
|
|
33
|
+
npx i18n-smell-detector check --config i18n-smell.config.mjs
|
|
31
34
|
npx i18n-smell-detector check-identical --config i18n-smell.config.mjs
|
|
32
35
|
npx i18n-smell-detector check-hardcoded --config i18n-smell.config.mjs
|
|
33
36
|
```
|
|
@@ -39,20 +42,73 @@ npx i18n-smell-detector check-identical --format console
|
|
|
39
42
|
npx i18n-smell-detector check-identical --format json
|
|
40
43
|
npx i18n-smell-detector check-identical --format markdown
|
|
41
44
|
npx i18n-smell-detector check-hardcoded --format json
|
|
45
|
+
npx i18n-smell-detector check --format markdown --output i18n-report.md
|
|
42
46
|
```
|
|
43
47
|
|
|
48
|
+
CLI options:
|
|
49
|
+
|
|
50
|
+
| Option | Description |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `-c, --config <path>` | Use a specific config file. |
|
|
53
|
+
| `--format <format>` | Print `console`, `json`, or `markdown` output. |
|
|
54
|
+
| `--fail-on <level>` | Exit with code `1` for findings at `high`, `medium`, or `low`. Use `none` while tuning. |
|
|
55
|
+
| `--include-ignored` | Include ignored findings in the report. |
|
|
56
|
+
| `--output <path>` | Write the full report to a file and print a short summary. |
|
|
57
|
+
| `--baseline <path>` | Suppress findings stored in a baseline file. |
|
|
58
|
+
| `--update-baseline` | Write the current findings to the baseline file. |
|
|
59
|
+
| `-h, --help` | Show CLI help. |
|
|
60
|
+
|
|
44
61
|
## CI usage
|
|
45
62
|
|
|
46
63
|
Use `--fail-on` to choose the lowest severity that should make the command exit with code `1`.
|
|
47
64
|
|
|
48
65
|
```bash
|
|
49
|
-
npx i18n-smell-detector check
|
|
66
|
+
npx i18n-smell-detector check --config i18n-smell.config.mjs --fail-on high
|
|
50
67
|
```
|
|
51
68
|
|
|
52
69
|
For a new project, it is often easier to start in reporting mode while tuning allowlists:
|
|
53
70
|
|
|
54
71
|
```bash
|
|
55
|
-
npx i18n-smell-detector check
|
|
72
|
+
npx i18n-smell-detector check --config i18n-smell.config.mjs --fail-on none
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### CI report artifact
|
|
76
|
+
|
|
77
|
+
Add a workflow such as `.github/workflows/i18n-smell.yml`:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
name: i18n smell check
|
|
81
|
+
|
|
82
|
+
on:
|
|
83
|
+
pull_request:
|
|
84
|
+
push:
|
|
85
|
+
branches: [main]
|
|
86
|
+
|
|
87
|
+
jobs:
|
|
88
|
+
i18n-smell:
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
|
|
91
|
+
steps:
|
|
92
|
+
- uses: actions/checkout@v6
|
|
93
|
+
|
|
94
|
+
- uses: actions/setup-node@v6
|
|
95
|
+
with:
|
|
96
|
+
node-version: 22
|
|
97
|
+
|
|
98
|
+
- run: npm ci
|
|
99
|
+
|
|
100
|
+
- name: Run i18n checks
|
|
101
|
+
run: npx i18n-smell-detector check --config i18n-smell.config.mjs --fail-on high
|
|
102
|
+
|
|
103
|
+
- name: Write Markdown report
|
|
104
|
+
if: always()
|
|
105
|
+
run: npx i18n-smell-detector check --config i18n-smell.config.mjs --format markdown --output reports/i18n-smell.md --fail-on none
|
|
106
|
+
|
|
107
|
+
- uses: actions/upload-artifact@v4
|
|
108
|
+
if: always()
|
|
109
|
+
with:
|
|
110
|
+
name: i18n-smell-report
|
|
111
|
+
path: reports/i18n-smell.md
|
|
56
112
|
```
|
|
57
113
|
|
|
58
114
|
## Configuration
|
|
@@ -62,6 +118,10 @@ Create `i18n-smell.config.mjs`:
|
|
|
62
118
|
```js
|
|
63
119
|
export default {
|
|
64
120
|
baseLocale: 'en',
|
|
121
|
+
checks: {
|
|
122
|
+
identical: true,
|
|
123
|
+
hardcoded: true
|
|
124
|
+
},
|
|
65
125
|
source: [
|
|
66
126
|
'src/**/*.vue'
|
|
67
127
|
],
|
|
@@ -117,6 +177,8 @@ export default {
|
|
|
117
177
|
trimWhitespace: true,
|
|
118
178
|
ignoreCase: false,
|
|
119
179
|
includeIgnored: false,
|
|
180
|
+
format: 'console',
|
|
181
|
+
baseline: '.i18n-smell-baseline.json',
|
|
120
182
|
failOn: 'high'
|
|
121
183
|
};
|
|
122
184
|
```
|
|
@@ -126,6 +188,7 @@ export default {
|
|
|
126
188
|
| Option | Type | Default | Description |
|
|
127
189
|
|---|---|---:|---|
|
|
128
190
|
| `baseLocale` | `string` | `'en'` | Locale used as the comparison source. It must also be listed in `locales`. |
|
|
191
|
+
| `checks` | `{ identical?: boolean, hardcoded?: boolean }` | both enabled | Checks run by the combined `check` command. Existing single-check commands ignore this setting. |
|
|
129
192
|
| `source` | `string[]` | `['src/**/*.vue']` | Source globs used by `check-hardcoded`. `node_modules` is ignored. |
|
|
130
193
|
| `locales` | `Record<string, string>` | `{}` | Map of locale codes to JSON locale file paths. Relative paths are resolved from the config file directory. |
|
|
131
194
|
| `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. |
|
|
@@ -140,6 +203,9 @@ export default {
|
|
|
140
203
|
| `ignoreCase` | `boolean` | `false` | Compare values case-insensitively when set to `true`. |
|
|
141
204
|
| `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. |
|
|
142
205
|
| `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. |
|
|
207
|
+
| `output` | `string` | unset | Report file path. CLI `--output` takes precedence. |
|
|
208
|
+
| `baseline` | `string` | unset | Baseline file path used by `--baseline` or `--update-baseline`. |
|
|
143
209
|
|
|
144
210
|
## Example output
|
|
145
211
|
|
|
@@ -241,6 +307,69 @@ JSON output includes source locations:
|
|
|
241
307
|
}
|
|
242
308
|
```
|
|
243
309
|
|
|
310
|
+
## Running all checks
|
|
311
|
+
|
|
312
|
+
Use `check` to run every enabled check in one command:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
npx i18n-smell-detector check --config i18n-smell.config.mjs
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Disable checks during gradual rollout:
|
|
319
|
+
|
|
320
|
+
```js
|
|
321
|
+
export default {
|
|
322
|
+
checks: {
|
|
323
|
+
identical: true,
|
|
324
|
+
hardcoded: false
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Combined JSON output groups counts by check:
|
|
330
|
+
|
|
331
|
+
```json
|
|
332
|
+
{
|
|
333
|
+
"summary": {
|
|
334
|
+
"identical": {
|
|
335
|
+
"high": 2,
|
|
336
|
+
"medium": 1,
|
|
337
|
+
"low": 0,
|
|
338
|
+
"ignored": 16
|
|
339
|
+
},
|
|
340
|
+
"hardcoded": {
|
|
341
|
+
"high": 3,
|
|
342
|
+
"medium": 2,
|
|
343
|
+
"low": 0,
|
|
344
|
+
"ignored": 3
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
"issues": []
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Writing reports
|
|
352
|
+
|
|
353
|
+
Use `--output` to write the full report to a file. The CLI prints a short summary to stdout after writing.
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
npx i18n-smell-detector check --format markdown --output reports/i18n-report.md
|
|
357
|
+
npx i18n-smell-detector check --format json --output reports/i18n-report.json
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Markdown output for `check` includes a summary table and one section per check, which makes it suitable for CI artifacts.
|
|
361
|
+
|
|
362
|
+
## Gradual adoption
|
|
363
|
+
|
|
364
|
+
Use a baseline to ignore existing findings while still failing CI for new ones:
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
npx i18n-smell-detector check --baseline .i18n-smell-baseline.json --update-baseline
|
|
368
|
+
npx i18n-smell-detector check --baseline .i18n-smell-baseline.json
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The baseline stores stable issue identifiers. Updating it removes findings that no longer exist and records the current visible findings.
|
|
372
|
+
|
|
244
373
|
## Exit codes
|
|
245
374
|
|
|
246
375
|
| Code | Meaning |
|
|
@@ -312,7 +441,7 @@ Useful tuning patterns:
|
|
|
312
441
|
|
|
313
442
|
## Limitations
|
|
314
443
|
|
|
315
|
-
`check-hardcoded` currently scans Vue templates only. JavaScript, TypeScript, and JSX string detection are not included yet.
|
|
444
|
+
`check-hardcoded` currently scans Vue templates only. JavaScript, TypeScript, and JSX string detection are not included yet. Classification uses heuristic rules rather than full language detection.
|
|
316
445
|
|
|
317
446
|
## Notes
|
|
318
447
|
|
|
@@ -326,6 +455,7 @@ Useful tuning patterns:
|
|
|
326
455
|
npm install
|
|
327
456
|
npm test
|
|
328
457
|
npm run check
|
|
458
|
+
npm run example:basic
|
|
329
459
|
```
|
|
330
460
|
|
|
331
461
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-smell-detector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Find locale values copied from the base locale.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,9 +21,14 @@
|
|
|
21
21
|
"LICENSE"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"check": "
|
|
24
|
+
"check": "node ./bin/i18n-smell-detector.js check --config ./examples/vue-vite/i18n-smell.config.mjs --fail-on none",
|
|
25
25
|
"check:identical": "node ./bin/i18n-smell-detector.js check-identical --config ./examples/vue-vite/i18n-smell.config.mjs --fail-on none",
|
|
26
26
|
"check:hardcoded": "node ./bin/i18n-smell-detector.js check-hardcoded --config ./examples/vue-vite/i18n-smell.config.mjs --fail-on none",
|
|
27
|
+
"example:basic": "node ./bin/i18n-smell-detector.js check --config ./examples/basic/i18n-smell.config.mjs --fail-on none",
|
|
28
|
+
"audit": "npm audit --audit-level=moderate",
|
|
29
|
+
"check:package": "node ./scripts/check-package.js",
|
|
30
|
+
"release": "semantic-release",
|
|
31
|
+
"release:dry-run": "semantic-release --dry-run",
|
|
27
32
|
"test": "node --test"
|
|
28
33
|
},
|
|
29
34
|
"keywords": [
|
|
@@ -41,5 +46,10 @@
|
|
|
41
46
|
"@vue/compiler-dom": "^3.5.39",
|
|
42
47
|
"@vue/compiler-sfc": "^3.5.39",
|
|
43
48
|
"fast-glob": "^3.3.3"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
52
|
+
"@semantic-release/git": "^10.0.1",
|
|
53
|
+
"semantic-release": "^25.0.5"
|
|
44
54
|
}
|
|
45
55
|
}
|
package/src/baseline.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function issueId(check, issue) {
|
|
5
|
+
if (check === 'identical') return `identical:${issue.targetLocale}:${issue.key}`;
|
|
6
|
+
return `hardcoded:${issue.file}:${issue.line}:${issue.column}:${issue.value}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function loadBaseline(filePath) {
|
|
10
|
+
if (!filePath) return new Set();
|
|
11
|
+
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') return new Set();
|
|
17
|
+
throw new Error(`Failed to read baseline file: ${filePath}\nReason: ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!parsed || !Array.isArray(parsed.issues)) {
|
|
21
|
+
throw new Error(`Invalid baseline file: ${filePath}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return new Set(parsed.issues.map((issue) => issue.id).filter(Boolean));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function applyBaseline(results, ids) {
|
|
28
|
+
if (!ids.size) return results;
|
|
29
|
+
|
|
30
|
+
return results.map((result) => ({
|
|
31
|
+
...result,
|
|
32
|
+
issues: result.issues.map((issue) => {
|
|
33
|
+
const id = issueId(result.check, issue);
|
|
34
|
+
if (!ids.has(id)) return { ...issue, id };
|
|
35
|
+
return { ...issue, id, severity: 'ignored', reason: 'baseline' };
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function writeBaseline(filePath, results) {
|
|
41
|
+
const issues = [];
|
|
42
|
+
|
|
43
|
+
for (const result of results) {
|
|
44
|
+
for (const issue of result.issues) {
|
|
45
|
+
if (issue.severity === 'ignored') continue;
|
|
46
|
+
issues.push(toBaselineIssue(result.check, issue));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
51
|
+
await writeFile(filePath, `${JSON.stringify({ version: 1, issues }, null, 2)}\n`);
|
|
52
|
+
return issues.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toBaselineIssue(check, issue) {
|
|
56
|
+
const base = {
|
|
57
|
+
id: issueId(check, issue),
|
|
58
|
+
value: issue.value,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (check === 'identical') {
|
|
62
|
+
return {
|
|
63
|
+
...base,
|
|
64
|
+
key: issue.key,
|
|
65
|
+
targetLocale: issue.targetLocale,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
...base,
|
|
71
|
+
file: issue.file,
|
|
72
|
+
line: issue.line,
|
|
73
|
+
column: issue.column,
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
1
2
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
3
|
-
import { checkIdenticalTranslations } from './check-identical.js';
|
|
3
|
+
import { applyBaseline, loadBaseline, writeBaseline } from './baseline.js';
|
|
4
4
|
import { loadConfig, resolveConfigPath } from './config.js';
|
|
5
|
-
import {
|
|
5
|
+
import { flattenResults, renderCombinedReport, summarizeResults } from './reporters/combined.js';
|
|
6
6
|
import { renderReport } from './reporters/index.js';
|
|
7
|
+
import { enabledChecks, runChecks } from './run-checks.js';
|
|
7
8
|
import { failRank, severityRank } from './severity.js';
|
|
8
9
|
|
|
9
10
|
const HELP = `
|
|
@@ -18,6 +19,9 @@ Options:
|
|
|
18
19
|
-c, --config <path> Config file path
|
|
19
20
|
--format <format> console | json | markdown (default: console)
|
|
20
21
|
--fail-on <level> high | medium | low | none (default: config.failOn or high)
|
|
22
|
+
--output <path> Write the full report to a file
|
|
23
|
+
--baseline <path> Read a baseline file
|
|
24
|
+
--update-baseline Write the current issues to the baseline file
|
|
21
25
|
--include-ignored Print ignored matches as well
|
|
22
26
|
-h, --help Show help
|
|
23
27
|
|
|
@@ -29,7 +33,7 @@ Example:
|
|
|
29
33
|
function parseArgs(argv) {
|
|
30
34
|
const args = argv.slice(2);
|
|
31
35
|
const command = args[0] && !args[0].startsWith('-') ? args.shift() : 'check-identical';
|
|
32
|
-
const options = { command,
|
|
36
|
+
const options = { command, includeIgnored: false, help: false, updateBaseline: false };
|
|
33
37
|
|
|
34
38
|
for (let index = 0; index < args.length; index += 1) {
|
|
35
39
|
const arg = args[index];
|
|
@@ -42,6 +46,12 @@ function parseArgs(argv) {
|
|
|
42
46
|
options.format = readChoice(args[++index], ['console', 'json', 'markdown'], 'format');
|
|
43
47
|
} else if (arg === '--fail-on') {
|
|
44
48
|
options.failOn = readChoice(args[++index], ['none', 'low', 'medium', 'high'], 'fail-on level');
|
|
49
|
+
} else if (arg === '--output') {
|
|
50
|
+
options.output = args[++index];
|
|
51
|
+
} else if (arg === '--baseline') {
|
|
52
|
+
options.baseline = args[++index];
|
|
53
|
+
} else if (arg === '--update-baseline') {
|
|
54
|
+
options.updateBaseline = true;
|
|
45
55
|
} else if (arg === '--include-ignored') {
|
|
46
56
|
options.includeIgnored = true;
|
|
47
57
|
} else {
|
|
@@ -76,24 +86,88 @@ export async function runCli(argv) {
|
|
|
76
86
|
const effectiveConfig = {
|
|
77
87
|
...config,
|
|
78
88
|
failOn: options.failOn || config.failOn || 'high',
|
|
89
|
+
format: options.format || config.format || 'console',
|
|
90
|
+
output: options.output || config.output,
|
|
91
|
+
baseline: options.baseline || config.baseline,
|
|
79
92
|
includeIgnored: options.includeIgnored || config.includeIgnored || false,
|
|
80
93
|
};
|
|
81
94
|
|
|
82
95
|
const configDir = path.dirname(configPath);
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
const checks = commandChecks(options.command, effectiveConfig);
|
|
97
|
+
const rawResults = await runChecks(effectiveConfig, configDir, checks);
|
|
98
|
+
|
|
99
|
+
if (options.updateBaseline) {
|
|
100
|
+
if (!effectiveConfig.baseline) throw new Error('Baseline path is required for --update-baseline');
|
|
101
|
+
const baselinePath = resolveFromConfigDir(effectiveConfig.baseline, configDir);
|
|
102
|
+
const count = await writeBaseline(baselinePath, rawResults);
|
|
103
|
+
console.log(`Baseline updated: ${effectiveConfig.baseline} (${count} issues)`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
87
106
|
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
const baselineIds = await loadBaseline(resolveOptional(effectiveConfig.baseline, configDir));
|
|
108
|
+
const results = applyBaseline(rawResults, baselineIds);
|
|
109
|
+
const reportOptions = {
|
|
110
|
+
format: effectiveConfig.format,
|
|
90
111
|
includeIgnored: effectiveConfig.includeIgnored || false,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
};
|
|
113
|
+
const report = options.command === 'check'
|
|
114
|
+
? renderCombinedReport(results, reportOptions)
|
|
115
|
+
: renderSingleReport(results[0], reportOptions);
|
|
116
|
+
|
|
117
|
+
if (effectiveConfig.output) {
|
|
118
|
+
await writeOutput(resolveFromConfigDir(effectiveConfig.output, configDir), report);
|
|
119
|
+
console.log(renderWriteSummary(results, effectiveConfig.output));
|
|
120
|
+
} else {
|
|
121
|
+
console.log(report);
|
|
122
|
+
}
|
|
95
123
|
|
|
96
|
-
const visible =
|
|
124
|
+
const visible = options.command === 'check'
|
|
125
|
+
? flattenResults(results, { includeIgnored: effectiveConfig.includeIgnored || false })
|
|
126
|
+
: visibleIssues(results[0].issues, effectiveConfig.includeIgnored || false);
|
|
97
127
|
const shouldFail = visible.some((issue) => severityRank[issue.severity] >= failRank(effectiveConfig.failOn || 'high'));
|
|
98
128
|
if (shouldFail) process.exit(1);
|
|
99
129
|
}
|
|
130
|
+
|
|
131
|
+
function commandChecks(command, config) {
|
|
132
|
+
if (command === 'check-identical') return ['identical'];
|
|
133
|
+
if (command === 'check-hardcoded') return ['hardcoded'];
|
|
134
|
+
return enabledChecks(config);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderSingleReport(result, options) {
|
|
138
|
+
return renderReport(result.issues, {
|
|
139
|
+
...options,
|
|
140
|
+
title: result.title,
|
|
141
|
+
heading: result.heading,
|
|
142
|
+
emptyMessage: result.emptyMessage,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function visibleIssues(issues, includeIgnored) {
|
|
147
|
+
return includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveFromConfigDir(filePath, configDir) {
|
|
151
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function resolveOptional(filePath, configDir) {
|
|
155
|
+
return filePath ? resolveFromConfigDir(filePath, configDir) : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function writeOutput(filePath, report) {
|
|
159
|
+
try {
|
|
160
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
161
|
+
await writeFile(filePath, report);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new Error(`Failed to write report file: ${filePath}\nReason: ${error.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderWriteSummary(results, output) {
|
|
168
|
+
const summary = summarizeResults(results);
|
|
169
|
+
const counts = Object.entries(summary)
|
|
170
|
+
.map(([check, item]) => `${check}: high=${item.high} medium=${item.medium} low=${item.low} ignored=${item.ignored}`)
|
|
171
|
+
.join('; ');
|
|
172
|
+
return `Report written to ${output}\n${counts}`;
|
|
173
|
+
}
|
package/src/config.js
CHANGED
|
@@ -98,6 +98,22 @@ function validateConfig(config) {
|
|
|
98
98
|
throw new Error('Config hardcoded must be an object');
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
if ('checks' in candidate && (!candidate.checks || typeof candidate.checks !== 'object' || Array.isArray(candidate.checks))) {
|
|
102
|
+
throw new Error('Config checks must be an object');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if ('format' in candidate && !['console', 'json', 'markdown'].includes(candidate.format)) {
|
|
106
|
+
throw new Error('Config format must be console, json, or markdown');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if ('output' in candidate && typeof candidate.output !== 'string') {
|
|
110
|
+
throw new Error('Config output must be a file path string');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if ('baseline' in candidate && typeof candidate.baseline !== 'string') {
|
|
114
|
+
throw new Error('Config baseline must be a file path string');
|
|
115
|
+
}
|
|
116
|
+
|
|
101
117
|
const hardcoded = /** @type {Record<string, unknown>} */ (candidate.hardcoded || {});
|
|
102
118
|
if ('vueAttributes' in hardcoded && !isStringArray(hardcoded.vueAttributes)) {
|
|
103
119
|
throw new Error('Config hardcoded.vueAttributes must be an array of strings');
|
|
@@ -118,7 +134,17 @@ function withDefaults(config) {
|
|
|
118
134
|
ignoreCase: false,
|
|
119
135
|
includeIgnored: false,
|
|
120
136
|
failOn: 'high',
|
|
137
|
+
checks: {
|
|
138
|
+
identical: true,
|
|
139
|
+
hardcoded: true,
|
|
140
|
+
...(config.checks || {}),
|
|
141
|
+
},
|
|
121
142
|
...config,
|
|
143
|
+
checks: {
|
|
144
|
+
identical: true,
|
|
145
|
+
hardcoded: true,
|
|
146
|
+
...(config.checks || {}),
|
|
147
|
+
},
|
|
122
148
|
source: config.source || ['src/**/*.vue'],
|
|
123
149
|
hardcoded: normalizeHardcodedConfig(config.hardcoded || {}),
|
|
124
150
|
placeholderPatterns: normalizePlaceholderPatterns(config.placeholderPatterns || DEFAULT_PLACEHOLDER_PATTERNS),
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { renderConsoleReport } from './console.js';
|
|
2
|
+
import { renderMarkdownReport } from './markdown.js';
|
|
3
|
+
import { summarizeIssues } from './summary.js';
|
|
4
|
+
|
|
5
|
+
export function renderCombinedReport(results, options) {
|
|
6
|
+
if (options.format === 'json') return renderCombinedJson(results, options);
|
|
7
|
+
if (options.format === 'markdown') return renderCombinedMarkdown(results, options);
|
|
8
|
+
return renderCombinedConsole(results, options);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function flattenResults(results, options) {
|
|
12
|
+
return results.flatMap((result) => {
|
|
13
|
+
const issues = options.includeIgnored ? result.issues : result.issues.filter((issue) => issue.severity !== 'ignored');
|
|
14
|
+
return issues.map((issue) => ({ check: result.check, ...issue }));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function summarizeResults(results) {
|
|
19
|
+
return Object.fromEntries(results.map((result) => [result.check, summarizeIssues(result.issues)]));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderCombinedJson(results, options) {
|
|
23
|
+
return `${JSON.stringify({
|
|
24
|
+
summary: summarizeResults(results),
|
|
25
|
+
issues: flattenResults(results, options),
|
|
26
|
+
}, null, 2)}\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderCombinedConsole(results, options) {
|
|
30
|
+
return results.map((result) => renderConsoleReport(result.issues, {
|
|
31
|
+
...options,
|
|
32
|
+
title: result.title,
|
|
33
|
+
emptyMessage: result.emptyMessage,
|
|
34
|
+
})).join('\n\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderCombinedMarkdown(results, options) {
|
|
38
|
+
const lines = [
|
|
39
|
+
'# i18n-smell-detector report',
|
|
40
|
+
'',
|
|
41
|
+
'## Summary',
|
|
42
|
+
'',
|
|
43
|
+
'| Check | High | Medium | Low | Ignored |',
|
|
44
|
+
'|---|---:|---:|---:|---:|',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const result of results) {
|
|
48
|
+
const summary = summarizeIssues(result.issues);
|
|
49
|
+
lines.push(`| ${result.check} | ${summary.high} | ${summary.medium} | ${summary.low} | ${summary.ignored} |`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const result of results) {
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push(asSection(renderMarkdownReport(result.issues, {
|
|
55
|
+
...options,
|
|
56
|
+
heading: result.heading,
|
|
57
|
+
emptyMessage: result.emptyMessage,
|
|
58
|
+
}).trim()));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return `${lines.join('\n')}\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function asSection(markdown) {
|
|
65
|
+
return markdown.replace(/^# /, '## ');
|
|
66
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { checkHardcodedStrings } from './check-hardcoded.js';
|
|
2
|
+
import { checkIdenticalTranslations } from './check-identical.js';
|
|
3
|
+
import { loadFlattenedLocales } from './locale/load-locales.js';
|
|
4
|
+
|
|
5
|
+
const CHECK_META = {
|
|
6
|
+
identical: {
|
|
7
|
+
title: 'identical translations',
|
|
8
|
+
heading: 'Identical translations',
|
|
9
|
+
emptyMessage: 'No copied base-locale values found.',
|
|
10
|
+
},
|
|
11
|
+
hardcoded: {
|
|
12
|
+
title: 'hardcoded strings',
|
|
13
|
+
heading: 'Hardcoded strings',
|
|
14
|
+
emptyMessage: 'No hardcoded strings found.',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function runChecks(config, configDir, names) {
|
|
19
|
+
const results = [];
|
|
20
|
+
|
|
21
|
+
for (const check of names) {
|
|
22
|
+
if (check === 'identical') {
|
|
23
|
+
const locales = await loadFlattenedLocales(config, configDir);
|
|
24
|
+
results.push({ check, ...CHECK_META[check], issues: checkIdenticalTranslations(locales, config) });
|
|
25
|
+
} else if (check === 'hardcoded') {
|
|
26
|
+
results.push({ check, ...CHECK_META[check], issues: await checkHardcodedStrings(config, configDir) });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function enabledChecks(config) {
|
|
34
|
+
return ['identical', 'hardcoded'].filter((check) => config.checks[check]);
|
|
35
|
+
}
|
package/src/types.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* @property {(string | RegExp)[]=} placeholderPatterns
|
|
11
11
|
* @property {string[]=} source
|
|
12
12
|
* @property {{vueAttributes: string[], ignoreValues: (string | RegExp)[], ignorePatterns: RegExp[]}=} hardcoded
|
|
13
|
+
* @property {{identical: boolean, hardcoded: boolean}=} checks
|
|
14
|
+
* @property {ReportFormat=} format
|
|
15
|
+
* @property {string=} output
|
|
16
|
+
* @property {string=} baseline
|
|
13
17
|
* @property {boolean=} ignoreCodeLike
|
|
14
18
|
* @property {boolean=} ignoreSameLanguageFamily
|
|
15
19
|
* @property {boolean=} trimWhitespace
|