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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-smell-detector",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "Find copied locale values, placeholder mismatches, and hardcoded user-visible strings.",
5
5
  "type": "module",
6
6
  "bin": {
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 template = result.descriptor.template;
19
- if (!template) return [];
19
+ const issues = [];
20
+ const { template, script, scriptSetup } = result.descriptor;
20
21
 
21
- return scanVueTemplate(template.content, {
22
- file,
23
- lineOffset: lineOffsetBefore(source, template.loc.start.offset),
24
- config,
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
  }