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 +2 -1
- package/src/check-hardcoded.js +2 -1
- package/src/cli.js +69 -36
- package/src/config.js +25 -30
- package/src/errors.js +11 -0
- package/src/reporters/markdown.js +5 -1
- package/src/rules/classify-identical.js +1 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-smell-detector",
|
|
3
|
-
"version": "0.5.
|
|
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": {
|
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 { 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
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
227
|
+
throw appError(`Invalid ${name} pattern "${rule}": ${error.message}`, 'CONFIG_INVALID');
|
|
233
228
|
}
|
|
234
229
|
});
|
|
235
230
|
}
|
package/src/errors.js
ADDED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { summarizeIssues } from './summary.js';
|
|
2
2
|
|
|
3
3
|
function escapeCell(value) {
|
|
4
|
-
return String(value)
|
|
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
|
|
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
|
}
|