i18n-smell-detector 0.5.2 → 0.6.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 +54 -2
- package/package.json +1 -1
- package/src/cli.js +115 -4
- package/src/config.js +27 -0
- package/src/source/scan-js-source.js +18 -3
- package/src/source/scan-vue-sfc.js +21 -7
package/README.md
CHANGED
|
@@ -27,11 +27,61 @@ Current scope: text classification is rule-based and conservative, so projects w
|
|
|
27
27
|
npm install -D i18n-smell-detector
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
Installing the package only adds the CLI. Generate a starter config in your project root:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx i18n-smell-detector init
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This creates `i18n-smell.config.mjs`. A typical config looks like this:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
export default {
|
|
42
|
+
baseLocale: 'en',
|
|
43
|
+
locales: {
|
|
44
|
+
en: './src/locales/en.json',
|
|
45
|
+
zh: './src/locales/zh.json',
|
|
46
|
+
'zh-Hans': './src/locales/zh-Hans.json'
|
|
47
|
+
},
|
|
48
|
+
source: [
|
|
49
|
+
'src/**/*.{vue,js,jsx,ts,tsx}'
|
|
50
|
+
],
|
|
51
|
+
failOn: 'none'
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
When possible, `init` discovers locale JSON files in common project directories and writes them into `locales` automatically. It checks the first matching directory from this list:
|
|
56
|
+
|
|
57
|
+
- `src/locales`
|
|
58
|
+
- `src/i18n`
|
|
59
|
+
- `locales`
|
|
60
|
+
- `i18n`
|
|
61
|
+
|
|
62
|
+
For example, if `src/locales/en.json` and `src/locales/ja.json` exist, `init` writes both entries and uses `en` as `baseLocale`. If no locale JSON files are found in those directories, it falls back to a starter `en` and `zh` config. Locale keys that contain `-`, such as `'zh-Hans'`, must be quoted as shown above.
|
|
63
|
+
|
|
64
|
+
Adjust `locales` and `source` if needed, then run:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx i18n-smell-detector check --config i18n-smell.config.mjs --fail-on none
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
If your config file lives somewhere else, pass that path:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx i18n-smell-detector init --config ./config/i18n-smell.config.mjs
|
|
74
|
+
npx i18n-smell-detector check --config ./config/i18n-smell.config.mjs
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`init` refuses to overwrite an existing config. Use `--force` only when you intentionally want to replace it. Locale and source paths inside the config are resolved relative to the config file. Locale discovery is intentionally conservative: it only checks direct `.json` files in the project directories listed above, and it does not scan unrelated JSON files elsewhere.
|
|
78
|
+
|
|
30
79
|
## Usage
|
|
31
80
|
|
|
32
81
|
Run the check:
|
|
33
82
|
|
|
34
83
|
```bash
|
|
84
|
+
npx i18n-smell-detector init
|
|
35
85
|
npx i18n-smell-detector check --config i18n-smell.config.mjs
|
|
36
86
|
npx i18n-smell-detector check-identical --config i18n-smell.config.mjs
|
|
37
87
|
npx i18n-smell-detector check-hardcoded --config i18n-smell.config.mjs
|
|
@@ -117,7 +167,7 @@ jobs:
|
|
|
117
167
|
|
|
118
168
|
## Configuration
|
|
119
169
|
|
|
120
|
-
Create `i18n-smell.config.mjs
|
|
170
|
+
Create `i18n-smell.config.mjs` and adjust `locales` and `source` to match your project:
|
|
121
171
|
|
|
122
172
|
```js
|
|
123
173
|
export default {
|
|
@@ -134,6 +184,7 @@ export default {
|
|
|
134
184
|
locales: {
|
|
135
185
|
en: './src/locales/en.json',
|
|
136
186
|
zh: './src/locales/zh.json',
|
|
187
|
+
'zh-Hans': './src/locales/zh-Hans.json',
|
|
137
188
|
ja: './src/locales/ja.json'
|
|
138
189
|
},
|
|
139
190
|
|
|
@@ -478,7 +529,8 @@ export default {
|
|
|
478
529
|
baseLocale: 'en',
|
|
479
530
|
locales: {
|
|
480
531
|
en: './src/locales/en.json',
|
|
481
|
-
zh: './src/locales/zh.json'
|
|
532
|
+
zh: './src/locales/zh.json',
|
|
533
|
+
'zh-Hans': './src/locales/zh-Hans.json'
|
|
482
534
|
},
|
|
483
535
|
allowIdenticalKeys: [
|
|
484
536
|
'brand.*',
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { access, mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Command, InvalidArgumentError } from 'commander';
|
|
4
4
|
import { applyBaseline, loadBaseline, writeBaseline } from './baseline.js';
|
|
@@ -17,6 +17,7 @@ Usage:
|
|
|
17
17
|
i18n-smell-detector check-hardcoded [options]
|
|
18
18
|
i18n-smell-detector check-placeholders [options]
|
|
19
19
|
i18n-smell-detector check [options]
|
|
20
|
+
i18n-smell-detector init [options]
|
|
20
21
|
|
|
21
22
|
Options:
|
|
22
23
|
-c, --config <path> Config file path
|
|
@@ -26,20 +27,44 @@ Options:
|
|
|
26
27
|
--baseline <path> Read a baseline file
|
|
27
28
|
--update-baseline Write the current issues to the baseline file
|
|
28
29
|
--include-ignored Print ignored matches as well
|
|
30
|
+
--force Overwrite an existing config file with init
|
|
29
31
|
-h, --help Show help
|
|
30
32
|
|
|
31
33
|
Example:
|
|
32
34
|
i18n-smell-detector check-identical -c i18n-smell.config.mjs --format markdown
|
|
33
35
|
i18n-smell-detector check-hardcoded -c i18n-smell.config.mjs --format json
|
|
34
36
|
i18n-smell-detector check-placeholders -c i18n-smell.config.mjs --format json
|
|
37
|
+
i18n-smell-detector init
|
|
35
38
|
`;
|
|
36
39
|
|
|
40
|
+
const DEFAULT_CONFIG_PATH = 'i18n-smell.config.mjs';
|
|
41
|
+
|
|
42
|
+
const DEFAULT_CONFIG_TEMPLATE = `export default {
|
|
43
|
+
baseLocale: 'en',
|
|
44
|
+
locales: {
|
|
45
|
+
en: './src/locales/en.json',
|
|
46
|
+
zh: './src/locales/zh.json'
|
|
47
|
+
},
|
|
48
|
+
source: [
|
|
49
|
+
'src/**/*.{vue,js,jsx,ts,tsx}'
|
|
50
|
+
],
|
|
51
|
+
failOn: 'none'
|
|
52
|
+
};
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const LOCALE_DIR_CANDIDATES = [
|
|
56
|
+
'src/locales',
|
|
57
|
+
'src/i18n',
|
|
58
|
+
'locales',
|
|
59
|
+
'i18n',
|
|
60
|
+
];
|
|
61
|
+
|
|
37
62
|
function parseArgs(argv) {
|
|
38
63
|
const program = new Command()
|
|
39
64
|
.name('i18n-smell-detector')
|
|
40
65
|
.usage('[command] [options]')
|
|
41
66
|
.description('Find localization issues that key coverage checks miss.')
|
|
42
|
-
.argument('[command]', 'check-identical | check-hardcoded | check-placeholders | check', 'check-identical')
|
|
67
|
+
.argument('[command]', 'init | check-identical | check-hardcoded | check-placeholders | check', 'check-identical')
|
|
43
68
|
.option('-c, --config <path>', 'Config file path')
|
|
44
69
|
.option('--format <format>', 'console | json | markdown | sarif', readFormat)
|
|
45
70
|
.option('--fail-on <level>', 'high | medium | low | none', readFailOn)
|
|
@@ -47,12 +72,14 @@ function parseArgs(argv) {
|
|
|
47
72
|
.option('--baseline <path>', 'Read a baseline file')
|
|
48
73
|
.option('--update-baseline', 'Write the current issues to the baseline file', false)
|
|
49
74
|
.option('--include-ignored', 'Print ignored matches as well', false)
|
|
75
|
+
.option('--force', 'Overwrite an existing config file with init', false)
|
|
50
76
|
.addHelpText('after', `
|
|
51
77
|
|
|
52
78
|
Example:
|
|
53
79
|
i18n-smell-detector check-identical -c i18n-smell.config.mjs --format markdown
|
|
54
80
|
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
|
|
81
|
+
i18n-smell-detector check-placeholders -c i18n-smell.config.mjs --format json
|
|
82
|
+
i18n-smell-detector init`)
|
|
56
83
|
.exitOverride()
|
|
57
84
|
.configureOutput({
|
|
58
85
|
writeOut: () => {},
|
|
@@ -68,7 +95,7 @@ Example:
|
|
|
68
95
|
}
|
|
69
96
|
|
|
70
97
|
const [command] = program.args;
|
|
71
|
-
if (!['check-identical', 'check-hardcoded', 'check-placeholders', 'check'].includes(command)) {
|
|
98
|
+
if (!['help', 'init', 'check-identical', 'check-hardcoded', 'check-placeholders', 'check'].includes(command)) {
|
|
72
99
|
throw appError(`Unknown command: ${command}\n${HELP}`, 'CLI_USAGE');
|
|
73
100
|
}
|
|
74
101
|
|
|
@@ -81,6 +108,7 @@ Example:
|
|
|
81
108
|
baseline: program.opts().baseline,
|
|
82
109
|
updateBaseline: program.opts().updateBaseline,
|
|
83
110
|
includeIgnored: program.opts().includeIgnored,
|
|
111
|
+
force: program.opts().force,
|
|
84
112
|
help: false,
|
|
85
113
|
};
|
|
86
114
|
}
|
|
@@ -116,6 +144,11 @@ export async function runCli(argv) {
|
|
|
116
144
|
return;
|
|
117
145
|
}
|
|
118
146
|
|
|
147
|
+
if (options.command === 'init') {
|
|
148
|
+
await writeDefaultConfig(options.configPath || DEFAULT_CONFIG_PATH, process.cwd(), options.force);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
119
152
|
const configPath = await resolveConfigPath(options.configPath, process.cwd());
|
|
120
153
|
const config = await loadConfig(configPath);
|
|
121
154
|
const effectiveConfig = {
|
|
@@ -201,6 +234,27 @@ async function writeOutput(filePath, report) {
|
|
|
201
234
|
}
|
|
202
235
|
}
|
|
203
236
|
|
|
237
|
+
async function writeDefaultConfig(configPath, cwd, force) {
|
|
238
|
+
const target = path.isAbsolute(configPath) ? configPath : path.resolve(cwd, configPath);
|
|
239
|
+
|
|
240
|
+
if (!force && await exists(target)) {
|
|
241
|
+
throw appError(`Config file already exists: ${target}\nUse --force to overwrite it.`, 'CONFIG_EXISTS');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
245
|
+
await writeFile(target, await createConfigTemplate(path.dirname(target)));
|
|
246
|
+
console.log(`Created ${path.relative(cwd, target) || target}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function exists(filePath) {
|
|
250
|
+
try {
|
|
251
|
+
await access(filePath);
|
|
252
|
+
return true;
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
204
258
|
function renderWriteSummary(results, output) {
|
|
205
259
|
const summary = summarizeResults(results);
|
|
206
260
|
const counts = Object.entries(summary)
|
|
@@ -208,3 +262,60 @@ function renderWriteSummary(results, output) {
|
|
|
208
262
|
.join('; ');
|
|
209
263
|
return `Report written to ${output}\n${counts}`;
|
|
210
264
|
}
|
|
265
|
+
|
|
266
|
+
async function createConfigTemplate(baseDir) {
|
|
267
|
+
const locales = await discoverLocaleFiles(baseDir);
|
|
268
|
+
if (locales.length === 0) return DEFAULT_CONFIG_TEMPLATE;
|
|
269
|
+
|
|
270
|
+
const baseLocale = locales.some((locale) => locale.name === 'en') ? 'en' : locales[0].name;
|
|
271
|
+
const localeLines = locales.map((locale, index) => {
|
|
272
|
+
const suffix = index === locales.length - 1 ? '' : ',';
|
|
273
|
+
return ` ${quoteProperty(locale.name)}: '${locale.path}'${suffix}`;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return `export default {
|
|
277
|
+
baseLocale: '${baseLocale}',
|
|
278
|
+
locales: {
|
|
279
|
+
${localeLines.join('\n')}
|
|
280
|
+
},
|
|
281
|
+
source: [
|
|
282
|
+
'src/**/*.{vue,js,jsx,ts,tsx}'
|
|
283
|
+
],
|
|
284
|
+
failOn: 'none'
|
|
285
|
+
};
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function discoverLocaleFiles(baseDir) {
|
|
290
|
+
for (const dir of LOCALE_DIR_CANDIDATES) {
|
|
291
|
+
const absoluteDir = path.resolve(baseDir, dir);
|
|
292
|
+
let entries;
|
|
293
|
+
try {
|
|
294
|
+
entries = await readdir(absoluteDir, { withFileTypes: true });
|
|
295
|
+
} catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const locales = entries
|
|
300
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
301
|
+
.map((entry) => {
|
|
302
|
+
const localeName = entry.name.slice(0, -'.json'.length);
|
|
303
|
+
const relativePath = toConfigPath(path.relative(baseDir, path.join(absoluteDir, entry.name)));
|
|
304
|
+
return { name: localeName, path: relativePath };
|
|
305
|
+
})
|
|
306
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
307
|
+
|
|
308
|
+
if (locales.length > 0) return locales;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function toConfigPath(relativePath) {
|
|
315
|
+
const normalized = relativePath.split(path.sep).join('/');
|
|
316
|
+
return normalized.startsWith('.') ? normalized : `./${normalized}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function quoteProperty(name) {
|
|
320
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
|
|
321
|
+
}
|
package/src/config.js
CHANGED
|
@@ -40,6 +40,16 @@ const DEFAULT_SOURCE_GLOBS = [
|
|
|
40
40
|
'src/**/*.{vue,js,jsx,ts,tsx}',
|
|
41
41
|
];
|
|
42
42
|
|
|
43
|
+
const FAIL_ON_LEVELS = ['none', 'low', 'medium', 'high'];
|
|
44
|
+
const BOOLEAN_OPTIONS = [
|
|
45
|
+
'ignoreSameLanguageFamily',
|
|
46
|
+
'trimWhitespace',
|
|
47
|
+
'ignoreCase',
|
|
48
|
+
'includeIgnored',
|
|
49
|
+
'ignoreCodeLike',
|
|
50
|
+
];
|
|
51
|
+
const CHECK_NAMES = ['identical', 'hardcoded', 'placeholders'];
|
|
52
|
+
|
|
43
53
|
async function exists(filePath) {
|
|
44
54
|
try {
|
|
45
55
|
await access(filePath);
|
|
@@ -117,10 +127,27 @@ function validateConfig(config) {
|
|
|
117
127
|
throw appError('Config checks must be an object', 'CONFIG_INVALID');
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
const checks = /** @type {Record<string, unknown>} */ (candidate.checks || {});
|
|
131
|
+
for (const check of CHECK_NAMES) {
|
|
132
|
+
if (check in checks && typeof checks[check] !== 'boolean') {
|
|
133
|
+
throw appError(`Config checks.${check} must be a boolean`, 'CONFIG_INVALID');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
120
137
|
if ('format' in candidate && !['console', 'json', 'markdown', 'sarif'].includes(candidate.format)) {
|
|
121
138
|
throw appError('Config format must be console, json, markdown, or sarif', 'CONFIG_INVALID');
|
|
122
139
|
}
|
|
123
140
|
|
|
141
|
+
if ('failOn' in candidate && !FAIL_ON_LEVELS.includes(candidate.failOn)) {
|
|
142
|
+
throw appError('Config failOn must be high, medium, low, or none', 'CONFIG_INVALID');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const option of BOOLEAN_OPTIONS) {
|
|
146
|
+
if (option in candidate && typeof candidate[option] !== 'boolean') {
|
|
147
|
+
throw appError(`Config ${option} must be a boolean`, 'CONFIG_INVALID');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
124
151
|
if ('output' in candidate && typeof candidate.output !== 'string') {
|
|
125
152
|
throw appError('Config output must be a file path string', 'CONFIG_INVALID');
|
|
126
153
|
}
|
|
@@ -36,6 +36,10 @@ function makeIssue({ file, loc, value, kind, baseReason, config }) {
|
|
|
36
36
|
|
|
37
37
|
export async function scanJsSource(file, config) {
|
|
38
38
|
const source = await readFile(file, 'utf8');
|
|
39
|
+
return scanJsText(source, { file, config });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function scanJsText(source, { file, config, lineOffset = 0 }) {
|
|
39
43
|
let ast;
|
|
40
44
|
|
|
41
45
|
try {
|
|
@@ -60,7 +64,7 @@ export async function scanJsSource(file, config) {
|
|
|
60
64
|
|
|
61
65
|
const issue = makeIssue({
|
|
62
66
|
file,
|
|
63
|
-
loc: argument.loc,
|
|
67
|
+
loc: offsetLoc(argument.loc, lineOffset),
|
|
64
68
|
value,
|
|
65
69
|
kind: `js-call:${calleeName(node.callee)}`,
|
|
66
70
|
baseReason: `static string passed to ${calleeName(node.callee)}`,
|
|
@@ -74,7 +78,7 @@ export async function scanJsSource(file, config) {
|
|
|
74
78
|
if (node.type === 'JSXText') {
|
|
75
79
|
const issue = makeIssue({
|
|
76
80
|
file,
|
|
77
|
-
loc: node.loc,
|
|
81
|
+
loc: offsetLoc(node.loc, lineOffset),
|
|
78
82
|
value: node.value,
|
|
79
83
|
kind: 'jsx-text',
|
|
80
84
|
baseReason: 'static JSX text',
|
|
@@ -90,7 +94,7 @@ export async function scanJsSource(file, config) {
|
|
|
90
94
|
|
|
91
95
|
const issue = makeIssue({
|
|
92
96
|
file,
|
|
93
|
-
loc: node.value.loc,
|
|
97
|
+
loc: offsetLoc(node.value.loc, lineOffset),
|
|
94
98
|
value,
|
|
95
99
|
kind: `jsx-attribute:${jsxName(node.name)}`,
|
|
96
100
|
baseReason: `static ${jsxName(node.name)} attribute`,
|
|
@@ -103,6 +107,17 @@ export async function scanJsSource(file, config) {
|
|
|
103
107
|
return issues;
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
function offsetLoc(loc, lineOffset) {
|
|
111
|
+
if (!lineOffset) return loc;
|
|
112
|
+
return {
|
|
113
|
+
...loc,
|
|
114
|
+
start: {
|
|
115
|
+
...loc.start,
|
|
116
|
+
line: loc.start.line + lineOffset,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
106
121
|
function walk(node, visitor) {
|
|
107
122
|
if (!node || typeof node.type !== 'string') return;
|
|
108
123
|
visitor(node);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { parse } from '@vue/compiler-sfc';
|
|
3
|
+
import { scanJsText } from './scan-js-source.js';
|
|
3
4
|
import { scanVueTemplate } from './scan-vue-template.js';
|
|
4
5
|
|
|
5
6
|
function lineOffsetBefore(source, offset) {
|
|
@@ -15,12 +16,25 @@ export async function scanVueSfc(file, config) {
|
|
|
15
16
|
throw new Error(`Malformed Vue file ${file}: ${error.message || String(error)}`);
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
const
|
|
19
|
-
|
|
19
|
+
const issues = [];
|
|
20
|
+
const { template, script, scriptSetup } = result.descriptor;
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
if (template) {
|
|
23
|
+
issues.push(...scanVueTemplate(template.content, {
|
|
24
|
+
file,
|
|
25
|
+
lineOffset: lineOffsetBefore(source, template.loc.start.offset),
|
|
26
|
+
config,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const block of [script, scriptSetup]) {
|
|
31
|
+
if (!block) continue;
|
|
32
|
+
issues.push(...scanJsText(block.content, {
|
|
33
|
+
file,
|
|
34
|
+
lineOffset: lineOffsetBefore(source, block.loc.start.offset),
|
|
35
|
+
config,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return issues;
|
|
26
40
|
}
|