i18n-smell-detector 0.5.0 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-smell-detector",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Find copied locale values, placeholder mismatches, and hardcoded user-visible strings.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,7 @@
46
46
  "@babel/parser": "^7.29.7",
47
47
  "@vue/compiler-dom": "^3.5.39",
48
48
  "@vue/compiler-sfc": "^3.5.39",
49
+ "commander": "^12.1.0",
49
50
  "fast-glob": "^3.3.3"
50
51
  },
51
52
  "devDependencies": {
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fg from 'fast-glob';
3
+ import { appError } from './errors.js';
3
4
  import { scanJsSource } from './source/scan-js-source.js';
4
5
  import { scanVueSfc } from './source/scan-vue-sfc.js';
5
6
 
@@ -20,7 +21,7 @@ export async function checkHardcodedStrings(config, baseDir) {
20
21
  });
21
22
 
22
23
  if (files.length === 0) {
23
- throw new Error(`No source files matched: ${config.source.join(', ')}`);
24
+ throw appError(`No source files matched: ${config.source.join(', ')}`, 'SOURCE_NOT_FOUND');
24
25
  }
25
26
 
26
27
  const issues = [];
package/src/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { Command, InvalidArgumentError } from 'commander';
3
4
  import { applyBaseline, loadBaseline, writeBaseline } from './baseline.js';
4
5
  import { loadConfig, resolveConfigPath } from './config.js';
6
+ import { appError } from './errors.js';
5
7
  import { flattenResults, renderCombinedReport, summarizeResults } from './reporters/combined.js';
6
8
  import { renderReport } from './reporters/index.js';
7
9
  import { enabledChecks, runChecks } from './run-checks.js';
@@ -33,44 +35,79 @@ Example:
33
35
  `;
34
36
 
35
37
  function parseArgs(argv) {
36
- const args = argv.slice(2);
37
- const command = args[0] && !args[0].startsWith('-') ? args.shift() : 'check-identical';
38
- const options = { command, includeIgnored: false, help: false, updateBaseline: false };
39
-
40
- for (let index = 0; index < args.length; index += 1) {
41
- const arg = args[index];
42
-
43
- if (arg === '-h' || arg === '--help') {
44
- options.help = true;
45
- } else if (arg === '-c' || arg === '--config') {
46
- options.configPath = args[++index];
47
- } else if (arg === '--format') {
48
- options.format = readChoice(args[++index], ['console', 'json', 'markdown', 'sarif'], 'format');
49
- } else if (arg === '--fail-on') {
50
- options.failOn = readChoice(args[++index], ['none', 'low', 'medium', 'high'], 'fail-on level');
51
- } else if (arg === '--output') {
52
- options.output = args[++index];
53
- } else if (arg === '--baseline') {
54
- options.baseline = args[++index];
55
- } else if (arg === '--update-baseline') {
56
- options.updateBaseline = true;
57
- } else if (arg === '--include-ignored') {
58
- options.includeIgnored = true;
59
- } else {
60
- throw new Error(`Unknown option: ${arg}`);
61
- }
38
+ const program = new Command()
39
+ .name('i18n-smell-detector')
40
+ .usage('[command] [options]')
41
+ .description('Find localization issues that key coverage checks miss.')
42
+ .argument('[command]', 'check-identical | check-hardcoded | check-placeholders | check', 'check-identical')
43
+ .option('-c, --config <path>', 'Config file path')
44
+ .option('--format <format>', 'console | json | markdown | sarif', readFormat)
45
+ .option('--fail-on <level>', 'high | medium | low | none', readFailOn)
46
+ .option('--output <path>', 'Write the full report to a file')
47
+ .option('--baseline <path>', 'Read a baseline file')
48
+ .option('--update-baseline', 'Write the current issues to the baseline file', false)
49
+ .option('--include-ignored', 'Print ignored matches as well', false)
50
+ .addHelpText('after', `
51
+
52
+ Example:
53
+ i18n-smell-detector check-identical -c i18n-smell.config.mjs --format markdown
54
+ i18n-smell-detector check-hardcoded -c i18n-smell.config.mjs --format json
55
+ i18n-smell-detector check-placeholders -c i18n-smell.config.mjs --format json`)
56
+ .exitOverride()
57
+ .configureOutput({
58
+ writeOut: () => {},
59
+ writeErr: () => {},
60
+ outputError: () => {},
61
+ });
62
+
63
+ try {
64
+ program.parse(argv, { from: 'node' });
65
+ } catch (error) {
66
+ if (error.code === 'commander.helpDisplayed') return { help: true };
67
+ throw appError(formatCommanderError(error), 'CLI_USAGE');
68
+ }
69
+
70
+ const [command] = program.args;
71
+ if (!['check-identical', 'check-hardcoded', 'check-placeholders', 'check'].includes(command)) {
72
+ throw appError(`Unknown command: ${command}\n${HELP}`, 'CLI_USAGE');
62
73
  }
63
74
 
64
- return options;
75
+ return {
76
+ command,
77
+ configPath: program.opts().config,
78
+ format: program.opts().format,
79
+ failOn: program.opts().failOn,
80
+ output: program.opts().output,
81
+ baseline: program.opts().baseline,
82
+ updateBaseline: program.opts().updateBaseline,
83
+ includeIgnored: program.opts().includeIgnored,
84
+ help: false,
85
+ };
86
+ }
87
+
88
+ function readFormat(value) {
89
+ return readChoice(value, ['console', 'json', 'markdown', 'sarif'], 'format');
90
+ }
91
+
92
+ function readFailOn(value) {
93
+ return readChoice(value, ['none', 'low', 'medium', 'high'], 'fail-on level');
65
94
  }
66
95
 
67
96
  function readChoice(value, choices, name) {
68
- if (!choices.includes(value)) {
69
- throw new Error(`Unsupported ${name}: ${value}`);
70
- }
97
+ if (!choices.includes(value)) throw new InvalidArgumentError(`Unsupported ${name}: ${value}`);
71
98
  return value;
72
99
  }
73
100
 
101
+ function formatCommanderError(error) {
102
+ if (error.code === 'commander.unknownOption') return `Unknown option: ${extractQuotedValue(error.message) || error.optionName || ''}`.trim();
103
+ if (error.code === 'commander.missingArgument') return `Missing value for option: ${error.optionName?.() || ''}`.trim();
104
+ return error.message.replace(/^error: /, '');
105
+ }
106
+
107
+ function extractQuotedValue(value) {
108
+ return value.match(/'([^']+)'/)?.[1];
109
+ }
110
+
74
111
  export async function runCli(argv) {
75
112
  const options = parseArgs(argv);
76
113
 
@@ -79,10 +116,6 @@ export async function runCli(argv) {
79
116
  return;
80
117
  }
81
118
 
82
- if (!['check-identical', 'check-hardcoded', 'check-placeholders', 'check'].includes(options.command)) {
83
- throw new Error(`Unknown command: ${options.command}\n${HELP}`);
84
- }
85
-
86
119
  const configPath = await resolveConfigPath(options.configPath, process.cwd());
87
120
  const config = await loadConfig(configPath);
88
121
  const effectiveConfig = {
@@ -99,7 +132,7 @@ export async function runCli(argv) {
99
132
  const rawResults = await runChecks(effectiveConfig, configDir, checks);
100
133
 
101
134
  if (options.updateBaseline) {
102
- if (!effectiveConfig.baseline) throw new Error('Baseline path is required for --update-baseline');
135
+ if (!effectiveConfig.baseline) throw appError('Baseline path is required for --update-baseline', 'CLI_USAGE');
103
136
  const baselinePath = resolveFromConfigDir(effectiveConfig.baseline, configDir);
104
137
  const count = await writeBaseline(baselinePath, rawResults);
105
138
  console.log(`Baseline updated: ${effectiveConfig.baseline} (${count} issues)`);
@@ -164,7 +197,7 @@ async function writeOutput(filePath, report) {
164
197
  await mkdir(path.dirname(filePath), { recursive: true });
165
198
  await writeFile(filePath, report);
166
199
  } catch (error) {
167
- throw new Error(`Failed to write report file: ${filePath}\nReason: ${error.message}`);
200
+ throw appError(`Failed to write report file: ${filePath}\nReason: ${error.message}`, 'OUTPUT_WRITE_FAILED');
168
201
  }
169
202
  }
170
203
 
package/src/config.js CHANGED
@@ -2,6 +2,7 @@ import { access } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { createRequire } from 'node:module';
5
+ import { appError } from './errors.js';
5
6
 
6
7
  const require = createRequire(import.meta.url);
7
8
 
@@ -51,7 +52,7 @@ async function exists(filePath) {
51
52
  export async function resolveConfigPath(explicitPath, cwd) {
52
53
  if (explicitPath) {
53
54
  const absolute = path.isAbsolute(explicitPath) ? explicitPath : path.resolve(cwd, explicitPath);
54
- if (!(await exists(absolute))) throw new Error(`Config file not found: ${absolute}`);
55
+ if (!(await exists(absolute))) throw appError(`Config file not found: ${absolute}`, 'CONFIG_NOT_FOUND');
55
56
  return absolute;
56
57
  }
57
58
 
@@ -60,7 +61,7 @@ export async function resolveConfigPath(explicitPath, cwd) {
60
61
  if (await exists(absolute)) return absolute;
61
62
  }
62
63
 
63
- throw new Error(`Config file not found. Expected one of: ${CONFIG_FILES.join(', ')}`);
64
+ throw appError(`Config file not found. Expected one of: ${CONFIG_FILES.join(', ')}`, 'CONFIG_NOT_FOUND');
64
65
  }
65
66
 
66
67
  export async function loadConfig(configPath) {
@@ -79,21 +80,21 @@ export async function loadConfig(configPath) {
79
80
 
80
81
  function validateConfig(config) {
81
82
  if (!config || typeof config !== 'object') {
82
- throw new Error('Config must export an object');
83
+ throw appError('Config must export an object', 'CONFIG_INVALID');
83
84
  }
84
85
 
85
86
  const candidate = /** @type {Record<string, unknown>} */ (config);
86
87
  if ('baseLocale' in candidate && (typeof candidate.baseLocale !== 'string' || !candidate.baseLocale)) {
87
- throw new Error('Config baseLocale must be a non-empty string');
88
+ throw appError('Config baseLocale must be a non-empty string', 'CONFIG_INVALID');
88
89
  }
89
90
 
90
91
  if ('locales' in candidate && (!candidate.locales || typeof candidate.locales !== 'object' || Array.isArray(candidate.locales))) {
91
- throw new Error('Config locales must be an object');
92
+ throw appError('Config locales must be an object', 'CONFIG_INVALID');
92
93
  }
93
94
 
94
95
  for (const [locale, value] of Object.entries(candidate.locales || {})) {
95
96
  if (typeof value !== 'string' || !value) {
96
- throw new Error(`Config locales.${locale} must be a file path string`);
97
+ throw appError(`Config locales.${locale} must be a file path string`, 'CONFIG_INVALID');
97
98
  }
98
99
  }
99
100
 
@@ -101,45 +102,45 @@ function validateConfig(config) {
101
102
  validateRuleList(candidate.allowIdenticalValues, 'allowIdenticalValues');
102
103
 
103
104
  if ('placeholderPatterns' in candidate && !Array.isArray(candidate.placeholderPatterns)) {
104
- throw new Error('Config placeholderPatterns must be an array of strings or RegExp objects');
105
+ throw appError('Config placeholderPatterns must be an array of strings or RegExp objects', 'CONFIG_INVALID');
105
106
  }
106
107
 
107
108
  if ('source' in candidate && !isStringArray(candidate.source)) {
108
- throw new Error('Config source must be an array of glob strings');
109
+ throw appError('Config source must be an array of glob strings', 'CONFIG_INVALID');
109
110
  }
110
111
 
111
112
  if ('hardcoded' in candidate && (!candidate.hardcoded || typeof candidate.hardcoded !== 'object' || Array.isArray(candidate.hardcoded))) {
112
- throw new Error('Config hardcoded must be an object');
113
+ throw appError('Config hardcoded must be an object', 'CONFIG_INVALID');
113
114
  }
114
115
 
115
116
  if ('checks' in candidate && (!candidate.checks || typeof candidate.checks !== 'object' || Array.isArray(candidate.checks))) {
116
- throw new Error('Config checks must be an object');
117
+ throw appError('Config checks must be an object', 'CONFIG_INVALID');
117
118
  }
118
119
 
119
120
  if ('format' in candidate && !['console', 'json', 'markdown', 'sarif'].includes(candidate.format)) {
120
- throw new Error('Config format must be console, json, markdown, or sarif');
121
+ throw appError('Config format must be console, json, markdown, or sarif', 'CONFIG_INVALID');
121
122
  }
122
123
 
123
124
  if ('output' in candidate && typeof candidate.output !== 'string') {
124
- throw new Error('Config output must be a file path string');
125
+ throw appError('Config output must be a file path string', 'CONFIG_INVALID');
125
126
  }
126
127
 
127
128
  if ('baseline' in candidate && typeof candidate.baseline !== 'string') {
128
- throw new Error('Config baseline must be a file path string');
129
+ throw appError('Config baseline must be a file path string', 'CONFIG_INVALID');
129
130
  }
130
131
 
131
132
  const hardcoded = /** @type {Record<string, unknown>} */ (candidate.hardcoded || {});
132
133
  if ('vueAttributes' in hardcoded && !isStringArray(hardcoded.vueAttributes)) {
133
- throw new Error('Config hardcoded.vueAttributes must be an array of strings');
134
+ throw appError('Config hardcoded.vueAttributes must be an array of strings', 'CONFIG_INVALID');
134
135
  }
135
136
  if ('jsxAttributes' in hardcoded && !isStringArray(hardcoded.jsxAttributes)) {
136
- throw new Error('Config hardcoded.jsxAttributes must be an array of strings');
137
+ throw appError('Config hardcoded.jsxAttributes must be an array of strings', 'CONFIG_INVALID');
137
138
  }
138
139
  if ('functions' in hardcoded && !isStringArray(hardcoded.functions)) {
139
- throw new Error('Config hardcoded.functions must be an array of strings');
140
+ throw appError('Config hardcoded.functions must be an array of strings', 'CONFIG_INVALID');
140
141
  }
141
142
  if ('ignoreFiles' in hardcoded && !isStringArray(hardcoded.ignoreFiles)) {
142
- throw new Error('Config hardcoded.ignoreFiles must be an array of glob strings');
143
+ throw appError('Config hardcoded.ignoreFiles must be an array of glob strings', 'CONFIG_INVALID');
143
144
  }
144
145
  validateRuleList(hardcoded.ignoreValues, 'hardcoded.ignoreValues');
145
146
  validateRuleList(hardcoded.ignorePatterns, 'hardcoded.ignorePatterns');
@@ -157,12 +158,6 @@ function withDefaults(config) {
157
158
  ignoreCase: false,
158
159
  includeIgnored: false,
159
160
  failOn: 'high',
160
- checks: {
161
- identical: true,
162
- hardcoded: true,
163
- placeholders: true,
164
- ...(config.checks || {}),
165
- },
166
161
  ...config,
167
162
  checks: {
168
163
  identical: true,
@@ -183,11 +178,11 @@ function isStringArray(value) {
183
178
 
184
179
  function validateRuleList(rules, name) {
185
180
  if (rules === undefined) return;
186
- if (!Array.isArray(rules)) throw new Error(`Config ${name} must be an array`);
181
+ if (!Array.isArray(rules)) throw appError(`Config ${name} must be an array`, 'CONFIG_INVALID');
187
182
 
188
183
  for (const rule of rules) {
189
184
  if (typeof rule !== 'string' && !(rule instanceof RegExp)) {
190
- throw new Error(`Config ${name} entries must be strings or RegExp objects`);
185
+ throw appError(`Config ${name} entries must be strings or RegExp objects`, 'CONFIG_INVALID');
191
186
  }
192
187
  }
193
188
  }
@@ -197,15 +192,15 @@ function normalizePlaceholderPatterns(patterns) {
197
192
  if (pattern instanceof RegExp) return withGlobalFlag(pattern);
198
193
 
199
194
  if (typeof pattern !== 'string') {
200
- throw new Error('Config placeholderPatterns entries must be strings or RegExp objects');
195
+ throw appError('Config placeholderPatterns entries must be strings or RegExp objects', 'CONFIG_INVALID');
201
196
  }
202
197
 
203
198
  try {
204
199
  return new RegExp(pattern, 'g');
205
200
  } catch (error) {
206
- throw new Error(`Invalid placeholder pattern "${pattern}": ${error.message}`);
201
+ throw appError(`Invalid placeholder pattern "${pattern}": ${error.message}`, 'CONFIG_INVALID');
207
202
  }
208
- });
203
+ }).sort((a, b) => b.source.length - a.source.length);
209
204
  }
210
205
 
211
206
  function normalizeHardcodedConfig(config) {
@@ -223,13 +218,13 @@ function normalizeRegexRules(rules, name) {
223
218
  return rules.map((rule) => {
224
219
  if (rule instanceof RegExp) return rule;
225
220
  if (typeof rule !== 'string') {
226
- throw new Error(`Config ${name} entries must be strings or RegExp objects`);
221
+ throw appError(`Config ${name} entries must be strings or RegExp objects`, 'CONFIG_INVALID');
227
222
  }
228
223
 
229
224
  try {
230
225
  return new RegExp(rule);
231
226
  } catch (error) {
232
- throw new Error(`Invalid ${name} pattern "${rule}": ${error.message}`);
227
+ throw appError(`Invalid ${name} pattern "${rule}": ${error.message}`, 'CONFIG_INVALID');
233
228
  }
234
229
  });
235
230
  }
package/src/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ export class AppError extends Error {
2
+ constructor(message, code = 'APP_ERROR') {
3
+ super(message);
4
+ this.name = 'AppError';
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ export function appError(message, code) {
10
+ return new AppError(message, code);
11
+ }
@@ -1,7 +1,11 @@
1
1
  import { summarizeIssues } from './summary.js';
2
2
 
3
3
  function escapeCell(value) {
4
- return String(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\n/g, '<br>');
4
+ return String(value)
5
+ .replace(/\r?\n/g, ' ')
6
+ .replace(/\\/g, '\\\\')
7
+ .replace(/\|/g, '\\|')
8
+ .replace(/[*_`[\]]/g, '\\$&');
5
9
  }
6
10
 
7
11
  export function renderMarkdownReport(issues, options) {
@@ -21,9 +21,7 @@ function isCodeLike(value) {
21
21
  function isPlaceholderOnly(value, patterns) {
22
22
  let text = value.trim();
23
23
 
24
- const orderedPatterns = [...(patterns || [])].sort((a, b) => b.source.length - a.source.length);
25
-
26
- for (const pattern of orderedPatterns) {
24
+ for (const pattern of patterns || []) {
27
25
  pattern.lastIndex = 0;
28
26
  text = text.replace(pattern, '');
29
27
  }