i18n-smell-detector 0.2.0 → 0.4.0

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
@@ -6,11 +6,12 @@
6
6
 
7
7
  Find locale values copied from the base locale.
8
8
 
9
- `i18n-smell-detector` checks localization files for keys whose translated value is identical to the base locale. This can help catch entries that were copied as placeholders and never translated.
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
11
  ## Features
12
12
 
13
13
  - Detect values that are identical to the base locale
14
+ - Detect hardcoded strings in Vue template text and selected attributes
14
15
  - Ignore expected matches by key or value
15
16
  - Classify findings as `high`, `medium`, `low`, or `ignored`
16
17
  - Output results as console text, JSON, or Markdown
@@ -27,7 +28,9 @@ npm install -D i18n-smell-detector
27
28
  Run the check:
28
29
 
29
30
  ```bash
31
+ npx i18n-smell-detector check --config i18n-smell.config.mjs
30
32
  npx i18n-smell-detector check-identical --config i18n-smell.config.mjs
33
+ npx i18n-smell-detector check-hardcoded --config i18n-smell.config.mjs
31
34
  ```
32
35
 
33
36
  Output formats:
@@ -36,20 +39,83 @@ Output formats:
36
39
  npx i18n-smell-detector check-identical --format console
37
40
  npx i18n-smell-detector check-identical --format json
38
41
  npx i18n-smell-detector check-identical --format markdown
42
+ npx i18n-smell-detector check-hardcoded --format json
43
+ npx i18n-smell-detector check --format markdown --output i18n-report.md
39
44
  ```
40
45
 
46
+ CLI options:
47
+
48
+ | Option | Description |
49
+ |---|---|
50
+ | `-c, --config <path>` | Use a specific config file. |
51
+ | `--format <format>` | Print `console`, `json`, or `markdown` output. |
52
+ | `--fail-on <level>` | Exit with code `1` for findings at `high`, `medium`, or `low`. Use `none` while tuning. |
53
+ | `--include-ignored` | Include ignored findings in the report. |
54
+ | `--output <path>` | Write the full report to a file and print a short summary. |
55
+ | `--baseline <path>` | Suppress findings stored in a baseline file. |
56
+ | `--update-baseline` | Write the current findings to the baseline file. |
57
+ | `-h, --help` | Show CLI help. |
58
+
41
59
  ## CI usage
42
60
 
43
61
  Use `--fail-on` to choose the lowest severity that should make the command exit with code `1`.
44
62
 
45
63
  ```bash
46
- npx i18n-smell-detector check-identical --config i18n-smell.config.mjs --fail-on high
64
+ npx i18n-smell-detector check --config i18n-smell.config.mjs --fail-on high
47
65
  ```
48
66
 
49
67
  For a new project, it is often easier to start in reporting mode while tuning allowlists:
50
68
 
51
69
  ```bash
52
- npx i18n-smell-detector check-identical --config i18n-smell.config.mjs --fail-on none
70
+ npx i18n-smell-detector check --config i18n-smell.config.mjs --fail-on none
71
+ ```
72
+
73
+ ### GitHub Actions example
74
+
75
+ Add a workflow such as `.github/workflows/i18n-smell.yml`:
76
+
77
+ ```yaml
78
+ name: i18n smell check
79
+
80
+ on:
81
+ pull_request:
82
+ push:
83
+ branches: [main]
84
+
85
+ jobs:
86
+ i18n-smell:
87
+ runs-on: ubuntu-latest
88
+
89
+ steps:
90
+ - uses: actions/checkout@v6
91
+
92
+ - uses: actions/setup-node@v6
93
+ with:
94
+ node-version: 22
95
+
96
+ - run: npm ci
97
+
98
+ - name: Run i18n checks
99
+ run: npx i18n-smell-detector check --config i18n-smell.config.mjs --fail-on high
100
+
101
+ - name: Write Markdown report
102
+ if: always()
103
+ run: npx i18n-smell-detector check --config i18n-smell.config.mjs --format markdown --output reports/i18n-smell.md --fail-on none
104
+
105
+ - uses: actions/upload-artifact@v4
106
+ if: always()
107
+ with:
108
+ name: i18n-smell-report
109
+ path: reports/i18n-smell.md
110
+ ```
111
+
112
+ For this repository, the local smoke commands are:
113
+
114
+ ```bash
115
+ npm test
116
+ npm run check
117
+ npm run example:basic
118
+ node bin/i18n-smell-detector.js --help
53
119
  ```
54
120
 
55
121
  ## Configuration
@@ -59,6 +125,14 @@ Create `i18n-smell.config.mjs`:
59
125
  ```js
60
126
  export default {
61
127
  baseLocale: 'en',
128
+ checks: {
129
+ identical: true,
130
+ hardcoded: true
131
+ },
132
+ source: [
133
+ 'src/**/*.vue'
134
+ ],
135
+
62
136
  locales: {
63
137
  en: './src/locales/en.json',
64
138
  zh: './src/locales/zh.json',
@@ -90,10 +164,28 @@ export default {
90
164
  ],
91
165
 
92
166
  ignoreCodeLike: true,
167
+ hardcoded: {
168
+ vueAttributes: [
169
+ 'placeholder',
170
+ 'title',
171
+ 'alt',
172
+ 'aria-label',
173
+ 'aria-description'
174
+ ],
175
+ ignoreValues: [
176
+ 'OK',
177
+ 'ID'
178
+ ],
179
+ ignorePatterns: [
180
+ '^v\\d+$'
181
+ ]
182
+ },
93
183
  ignoreSameLanguageFamily: true,
94
184
  trimWhitespace: true,
95
185
  ignoreCase: false,
96
186
  includeIgnored: false,
187
+ format: 'console',
188
+ baseline: '.i18n-smell-baseline.json',
97
189
  failOn: 'high'
98
190
  };
99
191
  ```
@@ -103,16 +195,24 @@ export default {
103
195
  | Option | Type | Default | Description |
104
196
  |---|---|---:|---|
105
197
  | `baseLocale` | `string` | `'en'` | Locale used as the comparison source. It must also be listed in `locales`. |
198
+ | `checks` | `{ identical?: boolean, hardcoded?: boolean }` | both enabled | Checks run by the combined `check` command. Existing single-check commands ignore this setting. |
199
+ | `source` | `string[]` | `['src/**/*.vue']` | Source globs used by `check-hardcoded`. `node_modules` is ignored. |
106
200
  | `locales` | `Record<string, string>` | `{}` | Map of locale codes to JSON locale file paths. Relative paths are resolved from the config file directory. |
107
201
  | `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. |
108
202
  | `allowIdenticalValues` | `(string \| RegExp)[]` | `[]` | Value rules that are allowed to match the base locale. String rules are exact matches, for example `OK`, `API`, or `GET`. `RegExp` rules are tested against the full value. |
109
203
  | `placeholderPatterns` | `(string \| RegExp)[]` | `{...}` and `{{...}}` patterns | Placeholder patterns ignored when the whole value is only placeholders and whitespace. String entries must be valid regular expression sources. |
110
204
  | `ignoreCodeLike` | `boolean` | `true` | Ignore built-in code-like values such as paths, colors, acronyms, and identifiers. Set to `false` to report them. |
205
+ | `hardcoded.vueAttributes` | `string[]` | `placeholder`, `title`, `alt`, `aria-label`, `aria-description` | Static Vue attributes checked by `check-hardcoded`. Dynamic bindings such as `:placeholder` are ignored. |
206
+ | `hardcoded.ignoreValues` | `(string \| RegExp)[]` | `[]` | Exact values or regular expressions ignored by `check-hardcoded`. |
207
+ | `hardcoded.ignorePatterns` | `(string \| RegExp)[]` | `[]` | Regular expression patterns ignored by `check-hardcoded`. String entries are compiled as regular expressions. |
111
208
  | `failOn` | `'high' \| 'medium' \| 'low' \| 'none'` | `'high'` | Lowest severity that makes the CLI exit with code `1`. Use `none` to report without failing. |
112
209
  | `trimWhitespace` | `boolean` | `true` | Trim leading and trailing whitespace before comparing values. |
113
210
  | `ignoreCase` | `boolean` | `false` | Compare values case-insensitively when set to `true`. |
114
211
  | `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. |
115
212
  | `includeIgnored` | `boolean` | `false` | Include ignored findings in reports. Can also be enabled with `--include-ignored`. |
213
+ | `format` | `'console' \| 'json' \| 'markdown'` | `'console'` | Default report format. CLI `--format` takes precedence. |
214
+ | `output` | `string` | unset | Report file path. CLI `--output` takes precedence. |
215
+ | `baseline` | `string` | unset | Baseline file path used by `--baseline` or `--update-baseline`. |
116
216
 
117
217
  ## Example output
118
218
 
@@ -173,6 +273,110 @@ Summary counts include all findings, including ignored findings that may be omit
173
273
 
174
274
  Ignored issues are omitted by default. Pass `--include-ignored` to include them in JSON, console, or Markdown output.
175
275
 
276
+ ## Hardcoded string output
277
+
278
+ `check-hardcoded` reports Vue template text and static user-visible attributes:
279
+
280
+ ```txt
281
+ i18n-smell-detector: hardcoded strings
282
+ high=1 medium=1 low=0 ignored=4
283
+
284
+ HIGH src/components/UserPanel.vue:12:15
285
+ value: "Profile settings"
286
+ reason: static template text
287
+
288
+ MEDIUM src/components/SearchInput.vue:8:23
289
+ value: "Search"
290
+ reason: static placeholder attribute
291
+ ```
292
+
293
+ JSON output includes source locations:
294
+
295
+ ```json
296
+ {
297
+ "summary": {
298
+ "high": 1,
299
+ "medium": 1,
300
+ "low": 0,
301
+ "ignored": 4
302
+ },
303
+ "issues": [
304
+ {
305
+ "file": "src/components/UserPanel.vue",
306
+ "line": 12,
307
+ "column": 15,
308
+ "value": "Profile settings",
309
+ "severity": "high",
310
+ "reason": "static template text",
311
+ "kind": "vue-text"
312
+ }
313
+ ]
314
+ }
315
+ ```
316
+
317
+ ## Running All Checks
318
+
319
+ Use `check` to run every enabled check in one command:
320
+
321
+ ```bash
322
+ npx i18n-smell-detector check --config i18n-smell.config.mjs
323
+ ```
324
+
325
+ Disable checks during gradual rollout:
326
+
327
+ ```js
328
+ export default {
329
+ checks: {
330
+ identical: true,
331
+ hardcoded: false
332
+ }
333
+ };
334
+ ```
335
+
336
+ Combined JSON output groups counts by check:
337
+
338
+ ```json
339
+ {
340
+ "summary": {
341
+ "identical": {
342
+ "high": 2,
343
+ "medium": 1,
344
+ "low": 0,
345
+ "ignored": 16
346
+ },
347
+ "hardcoded": {
348
+ "high": 3,
349
+ "medium": 2,
350
+ "low": 0,
351
+ "ignored": 3
352
+ }
353
+ },
354
+ "issues": []
355
+ }
356
+ ```
357
+
358
+ ## Writing Reports
359
+
360
+ Use `--output` to write the full report to a file. The CLI prints a short summary to stdout after writing.
361
+
362
+ ```bash
363
+ npx i18n-smell-detector check --format markdown --output reports/i18n-report.md
364
+ npx i18n-smell-detector check --format json --output reports/i18n-report.json
365
+ ```
366
+
367
+ Markdown output for `check` includes a summary table and one section per check, which makes it suitable for CI artifacts.
368
+
369
+ ## Gradual Adoption
370
+
371
+ Use a baseline to ignore existing findings while still failing CI for new ones:
372
+
373
+ ```bash
374
+ npx i18n-smell-detector check --baseline .i18n-smell-baseline.json --update-baseline
375
+ npx i18n-smell-detector check --baseline .i18n-smell-baseline.json
376
+ ```
377
+
378
+ The baseline stores stable issue identifiers. Updating it removes findings that no longer exist and records the current visible findings.
379
+
176
380
  ## Exit codes
177
381
 
178
382
  | Code | Meaning |
@@ -194,6 +398,8 @@ Ignored issues are omitted by default. Pass `--include-ignored` to include them
194
398
  | Single English word | medium |
195
399
  | English phrase | high |
196
400
 
401
+ For `check-hardcoded`, sentence-like template text and static attributes are `high`, single words are `medium`, short common labels are `low`, and configured or non-user-facing values are `ignored`.
402
+
197
403
  ## False positive tuning
198
404
 
199
405
  Identical text is sometimes intentional. Start by running the detector in reporting mode, then allow known names, protocol terms, short labels, and other strings that should stay the same across languages.
@@ -236,6 +442,13 @@ Useful tuning patterns:
236
442
  - Set `ignoreSameLanguageFamily: false` if you want to check regional variants such as `en-GB`, `en-AU`, or `pt-BR`.
237
443
  - Set `failOn: 'none'` while tuning a new project, then raise it to `high` or `medium` once the allowlist is stable.
238
444
  - Pass `--include-ignored` when reviewing why a value was ignored.
445
+ - Use `source` to choose Vue files for `check-hardcoded`.
446
+ - Tune `hardcoded.vueAttributes` if your project uses additional static attributes for user-visible text.
447
+ - Use `hardcoded.ignoreValues` and `hardcoded.ignorePatterns` for intentional literals such as `OK`, version labels, or product codes.
448
+
449
+ ## Limitations
450
+
451
+ `check-hardcoded` currently scans Vue templates only. JavaScript, TypeScript, and JSX string detection are not included yet.
239
452
 
240
453
  ## Notes
241
454
 
@@ -249,6 +462,7 @@ Useful tuning patterns:
249
462
  npm install
250
463
  npm test
251
464
  npm run check
465
+ npm run example:basic
252
466
  ```
253
467
 
254
468
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-smell-detector",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Find locale values copied from the base locale.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,10 @@
21
21
  "LICENSE"
22
22
  ],
23
23
  "scripts": {
24
- "check": "node ./bin/i18n-smell-detector.js check-identical --config ./examples/vue-vite/i18n-smell.config.mjs --fail-on none",
24
+ "check": "node ./bin/i18n-smell-detector.js check --config ./examples/vue-vite/i18n-smell.config.mjs --fail-on none",
25
+ "check:identical": "node ./bin/i18n-smell-detector.js check-identical --config ./examples/vue-vite/i18n-smell.config.mjs --fail-on none",
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",
25
28
  "test": "node --test"
26
29
  },
27
30
  "keywords": [
@@ -34,5 +37,10 @@
34
37
  "license": "MIT",
35
38
  "engines": {
36
39
  "node": ">=18.17"
40
+ },
41
+ "dependencies": {
42
+ "@vue/compiler-dom": "^3.5.39",
43
+ "@vue/compiler-sfc": "^3.5.39",
44
+ "fast-glob": "^3.3.3"
37
45
  }
38
46
  }
@@ -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
+ }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path';
2
+ import fg from 'fast-glob';
3
+ import { scanVueSfc } from './source/scan-vue-sfc.js';
4
+
5
+ function compareIssues(a, b) {
6
+ const rank = { high: 3, medium: 2, low: 1, ignored: 0 };
7
+ return rank[b.severity] - rank[a.severity]
8
+ || a.file.localeCompare(b.file)
9
+ || a.line - b.line
10
+ || a.column - b.column;
11
+ }
12
+
13
+ export async function checkHardcodedStrings(config, baseDir) {
14
+ const files = await fg(config.source, {
15
+ cwd: baseDir,
16
+ absolute: true,
17
+ onlyFiles: true,
18
+ ignore: ['**/node_modules/**'],
19
+ });
20
+
21
+ if (files.length === 0) {
22
+ throw new Error(`No source files matched: ${config.source.join(', ')}`);
23
+ }
24
+
25
+ const issues = [];
26
+ for (const file of files) {
27
+ const fileIssues = await scanVueSfc(file, config);
28
+ for (const issue of fileIssues) {
29
+ issues.push({
30
+ ...issue,
31
+ file: path.relative(baseDir, issue.file),
32
+ });
33
+ }
34
+ }
35
+
36
+ return issues.sort(compareIssues);
37
+ }
package/src/cli.js CHANGED
@@ -1,32 +1,39 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
1
2
  import path from 'node:path';
2
- import { checkIdenticalTranslations } from './check-identical.js';
3
+ import { applyBaseline, loadBaseline, writeBaseline } from './baseline.js';
3
4
  import { loadConfig, resolveConfigPath } from './config.js';
4
- import { loadFlattenedLocales } from './locale/load-locales.js';
5
+ import { flattenResults, renderCombinedReport, summarizeResults } from './reporters/combined.js';
5
6
  import { renderReport } from './reporters/index.js';
7
+ import { enabledChecks, runChecks } from './run-checks.js';
6
8
  import { failRank, severityRank } from './severity.js';
7
9
 
8
10
  const HELP = `
9
- i18n-smell-detector v0.1.1
11
+ i18n-smell-detector
10
12
 
11
13
  Usage:
12
14
  i18n-smell-detector check-identical [options]
15
+ i18n-smell-detector check-hardcoded [options]
13
16
  i18n-smell-detector check [options]
14
17
 
15
18
  Options:
16
19
  -c, --config <path> Config file path
17
20
  --format <format> console | json | markdown (default: console)
18
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
19
25
  --include-ignored Print ignored matches as well
20
26
  -h, --help Show help
21
27
 
22
28
  Example:
23
29
  i18n-smell-detector check-identical -c i18n-smell.config.mjs --format markdown
30
+ i18n-smell-detector check-hardcoded -c i18n-smell.config.mjs --format json
24
31
  `;
25
32
 
26
33
  function parseArgs(argv) {
27
34
  const args = argv.slice(2);
28
35
  const command = args[0] && !args[0].startsWith('-') ? args.shift() : 'check-identical';
29
- const options = { command, format: 'console', includeIgnored: false, help: false };
36
+ const options = { command, includeIgnored: false, help: false, updateBaseline: false };
30
37
 
31
38
  for (let index = 0; index < args.length; index += 1) {
32
39
  const arg = args[index];
@@ -39,6 +46,12 @@ function parseArgs(argv) {
39
46
  options.format = readChoice(args[++index], ['console', 'json', 'markdown'], 'format');
40
47
  } else if (arg === '--fail-on') {
41
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;
42
55
  } else if (arg === '--include-ignored') {
43
56
  options.includeIgnored = true;
44
57
  } else {
@@ -64,7 +77,7 @@ export async function runCli(argv) {
64
77
  return;
65
78
  }
66
79
 
67
- if (!['check-identical', 'check'].includes(options.command)) {
80
+ if (!['check-identical', 'check-hardcoded', 'check'].includes(options.command)) {
68
81
  throw new Error(`Unknown command: ${options.command}\n${HELP}`);
69
82
  }
70
83
 
@@ -73,18 +86,88 @@ export async function runCli(argv) {
73
86
  const effectiveConfig = {
74
87
  ...config,
75
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,
76
92
  includeIgnored: options.includeIgnored || config.includeIgnored || false,
77
93
  };
78
94
 
79
- const locales = await loadFlattenedLocales(effectiveConfig, path.dirname(configPath));
80
- const issues = checkIdenticalTranslations(locales, effectiveConfig);
95
+ const configDir = path.dirname(configPath);
96
+ const checks = commandChecks(options.command, effectiveConfig);
97
+ const rawResults = await runChecks(effectiveConfig, configDir, checks);
81
98
 
82
- console.log(renderReport(issues, {
83
- format: options.format,
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
+ }
106
+
107
+ const baselineIds = await loadBaseline(resolveOptional(effectiveConfig.baseline, configDir));
108
+ const results = applyBaseline(rawResults, baselineIds);
109
+ const reportOptions = {
110
+ format: effectiveConfig.format,
84
111
  includeIgnored: effectiveConfig.includeIgnored || false,
85
- }));
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
+ }
86
123
 
87
- const visible = effectiveConfig.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
124
+ const visible = options.command === 'check'
125
+ ? flattenResults(results, { includeIgnored: effectiveConfig.includeIgnored || false })
126
+ : visibleIssues(results[0].issues, effectiveConfig.includeIgnored || false);
88
127
  const shouldFail = visible.some((issue) => severityRank[issue.severity] >= failRank(effectiveConfig.failOn || 'high'));
89
128
  if (shouldFail) process.exit(1);
90
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
@@ -17,6 +17,14 @@ const DEFAULT_PLACEHOLDER_PATTERNS = [
17
17
  String.raw`\{[^}]+\}`,
18
18
  ];
19
19
 
20
+ const DEFAULT_HARDCODED_ATTRIBUTES = [
21
+ 'placeholder',
22
+ 'title',
23
+ 'alt',
24
+ 'aria-label',
25
+ 'aria-description',
26
+ ];
27
+
20
28
  async function exists(filePath) {
21
29
  try {
22
30
  await access(filePath);
@@ -81,6 +89,37 @@ function validateConfig(config) {
81
89
  if ('placeholderPatterns' in candidate && !Array.isArray(candidate.placeholderPatterns)) {
82
90
  throw new Error('Config placeholderPatterns must be an array of strings or RegExp objects');
83
91
  }
92
+
93
+ if ('source' in candidate && !isStringArray(candidate.source)) {
94
+ throw new Error('Config source must be an array of glob strings');
95
+ }
96
+
97
+ if ('hardcoded' in candidate && (!candidate.hardcoded || typeof candidate.hardcoded !== 'object' || Array.isArray(candidate.hardcoded))) {
98
+ throw new Error('Config hardcoded must be an object');
99
+ }
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
+
117
+ const hardcoded = /** @type {Record<string, unknown>} */ (candidate.hardcoded || {});
118
+ if ('vueAttributes' in hardcoded && !isStringArray(hardcoded.vueAttributes)) {
119
+ throw new Error('Config hardcoded.vueAttributes must be an array of strings');
120
+ }
121
+ validateRuleList(hardcoded.ignoreValues, 'hardcoded.ignoreValues');
122
+ validateRuleList(hardcoded.ignorePatterns, 'hardcoded.ignorePatterns');
84
123
  }
85
124
 
86
125
  /** @param {import('./types.js').DetectorConfig} config */
@@ -95,12 +134,28 @@ function withDefaults(config) {
95
134
  ignoreCase: false,
96
135
  includeIgnored: false,
97
136
  failOn: 'high',
137
+ checks: {
138
+ identical: true,
139
+ hardcoded: true,
140
+ ...(config.checks || {}),
141
+ },
98
142
  ...config,
143
+ checks: {
144
+ identical: true,
145
+ hardcoded: true,
146
+ ...(config.checks || {}),
147
+ },
148
+ source: config.source || ['src/**/*.vue'],
149
+ hardcoded: normalizeHardcodedConfig(config.hardcoded || {}),
99
150
  placeholderPatterns: normalizePlaceholderPatterns(config.placeholderPatterns || DEFAULT_PLACEHOLDER_PATTERNS),
100
151
  ignoreCodeLike: config.ignoreCodeLike ?? true,
101
152
  };
102
153
  }
103
154
 
155
+ function isStringArray(value) {
156
+ return Array.isArray(value) && value.every((item) => typeof item === 'string' && item.length > 0);
157
+ }
158
+
104
159
  function validateRuleList(rules, name) {
105
160
  if (rules === undefined) return;
106
161
  if (!Array.isArray(rules)) throw new Error(`Config ${name} must be an array`);
@@ -128,6 +183,29 @@ function normalizePlaceholderPatterns(patterns) {
128
183
  });
129
184
  }
130
185
 
186
+ function normalizeHardcodedConfig(config) {
187
+ return {
188
+ vueAttributes: config.vueAttributes || DEFAULT_HARDCODED_ATTRIBUTES,
189
+ ignoreValues: config.ignoreValues || [],
190
+ ignorePatterns: normalizeRegexRules(config.ignorePatterns || [], 'hardcoded.ignorePatterns'),
191
+ };
192
+ }
193
+
194
+ function normalizeRegexRules(rules, name) {
195
+ return rules.map((rule) => {
196
+ if (rule instanceof RegExp) return rule;
197
+ if (typeof rule !== 'string') {
198
+ throw new Error(`Config ${name} entries must be strings or RegExp objects`);
199
+ }
200
+
201
+ try {
202
+ return new RegExp(rule);
203
+ } catch (error) {
204
+ throw new Error(`Invalid ${name} pattern "${rule}": ${error.message}`);
205
+ }
206
+ });
207
+ }
208
+
131
209
  function withGlobalFlag(pattern) {
132
210
  const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
133
211
  return new RegExp(pattern.source, flags);
@@ -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
+ }
@@ -21,21 +21,26 @@ export function renderConsoleReport(issues, options) {
21
21
  const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
22
22
  const counts = summarizeIssues(issues);
23
23
  const lines = [
24
- color('1', 'i18n-smell-detector: identical translations'),
24
+ color('1', `i18n-smell-detector: ${options.title || 'identical translations'}`),
25
25
  `high=${counts.high} medium=${counts.medium} low=${counts.low} ignored=${counts.ignored}`,
26
26
  ];
27
27
 
28
28
  if (visible.length === 0) {
29
- lines.push(color('32', 'No copied base-locale values found.'));
29
+ lines.push(color('32', options.emptyMessage || 'No copied base-locale values found.'));
30
30
  return lines.join('\n');
31
31
  }
32
32
 
33
33
  lines.push('');
34
34
  for (const issue of visible) {
35
- lines.push(`${label(issue.severity)} ${issue.targetLocale}.${issue.key}`);
35
+ lines.push(`${label(issue.severity)} ${formatLocation(issue)}`);
36
36
  lines.push(` value: "${preview(issue.value)}"`);
37
37
  lines.push(` reason: ${issue.reason}`);
38
38
  }
39
39
 
40
40
  return lines.join('\n');
41
41
  }
42
+
43
+ function formatLocation(issue) {
44
+ if (issue.file) return `${issue.file}:${issue.line}:${issue.column}`;
45
+ return `${issue.targetLocale}.${issue.key}`;
46
+ }
@@ -8,24 +8,41 @@ export function renderMarkdownReport(issues, options) {
8
8
  const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
9
9
  const summary = summarizeIssues(issues);
10
10
  const lines = [
11
- '# Identical translations',
11
+ `# ${options.heading || 'Identical translations'}`,
12
12
  '',
13
13
  `high=${summary.high} medium=${summary.medium} low=${summary.low} ignored=${summary.ignored}`,
14
14
  '',
15
15
  ];
16
16
 
17
17
  if (visible.length === 0) {
18
- lines.push('No copied base-locale values found.');
18
+ lines.push(options.emptyMessage || 'No copied base-locale values found.');
19
19
  return `${lines.join('\n')}\n`;
20
20
  }
21
21
 
22
- lines.push('| Level | Locale | Key | Value | Reason |');
23
- lines.push('|---|---|---|---|---|');
22
+ if (visible.some((issue) => issue.file)) {
23
+ lines.push('| Level | Location | Value | Reason |');
24
+ lines.push('|---|---|---|---|');
25
+ } else {
26
+ lines.push('| Level | Locale | Key | Value | Reason |');
27
+ lines.push('|---|---|---|---|---|');
28
+ }
29
+
24
30
  for (const issue of visible) {
25
- lines.push(
26
- `| ${escapeCell(issue.severity)} | ${escapeCell(issue.targetLocale)} | ${escapeCell(issue.key)} | ${escapeCell(issue.value)} | ${escapeCell(issue.reason)} |`
27
- );
31
+ if (issue.file) {
32
+ lines.push(
33
+ `| ${escapeCell(issue.severity)} | ${escapeCell(formatLocation(issue))} | ${escapeCell(issue.value)} | ${escapeCell(issue.reason)} |`
34
+ );
35
+ } else {
36
+ lines.push(
37
+ `| ${escapeCell(issue.severity)} | ${escapeCell(issue.targetLocale)} | ${escapeCell(issue.key)} | ${escapeCell(issue.value)} | ${escapeCell(issue.reason)} |`
38
+ );
39
+ }
28
40
  }
29
41
 
30
42
  return `${lines.join('\n')}\n`;
31
43
  }
44
+
45
+ function formatLocation(issue) {
46
+ if (issue.file) return `${issue.file}:${issue.line}:${issue.column}`;
47
+ return `${issue.targetLocale}.${issue.key}`;
48
+ }
@@ -0,0 +1,54 @@
1
+ import { matchesAnyRule } from './match-rule.js';
2
+
3
+ function isBlank(value) {
4
+ return value.trim().length === 0;
5
+ }
6
+
7
+ function isMustacheOnly(value) {
8
+ return /^\{\{[\s\S]*\}\}$/.test(value.trim());
9
+ }
10
+
11
+ function isNonUserFacing(value) {
12
+ const text = value.trim();
13
+ return /^[0-9.,:;+\-*/%()\s]+$/.test(text)
14
+ || /^[\p{P}\p{S}]+$/u.test(text)
15
+ || /^#[0-9a-f]{3,8}$/i.test(text)
16
+ || /^(https?:\/\/|mailto:|tel:)/i.test(text)
17
+ || /^\/[a-z0-9_./:-]*$/i.test(text)
18
+ || /^[a-z0-9_.-]+\.(css|js|png|jpe?g|gif|svg|webp|json)$/i.test(text)
19
+ || /^[A-Z]{2,6}$/.test(text);
20
+ }
21
+
22
+ function latinWords(value) {
23
+ return value.match(/[A-Za-z][A-Za-z']*/g) || [];
24
+ }
25
+
26
+ function isShortCommonLabel(value) {
27
+ const text = value.trim();
28
+ return text.length <= 3 || /^(ok|id|no|yes|on|off)$/i.test(text);
29
+ }
30
+
31
+ export function classifyHardcoded(value, config = {}) {
32
+ const hardcoded = config.hardcoded || {};
33
+
34
+ if (matchesAnyRule(value, hardcoded.ignoreValues || [])) {
35
+ return { severity: 'ignored', reason: 'ignored value' };
36
+ }
37
+
38
+ if (matchesAnyRule(value, hardcoded.ignorePatterns || [])) {
39
+ return { severity: 'ignored', reason: 'ignored pattern' };
40
+ }
41
+
42
+ if (isBlank(value)) return { severity: 'ignored', reason: 'blank value' };
43
+ if (isMustacheOnly(value)) return { severity: 'ignored', reason: 'mustache expression' };
44
+ if (isNonUserFacing(value)) return { severity: 'ignored', reason: 'non-user-facing value' };
45
+ if (isShortCommonLabel(value)) return { severity: 'low', reason: 'short common label' };
46
+
47
+ const words = latinWords(value);
48
+ if (words.length >= 2 || /[.!?。!?]/.test(value)) {
49
+ return { severity: 'high', reason: 'sentence-like content' };
50
+ }
51
+
52
+ if (words.length === 1) return { severity: 'medium', reason: 'single word' };
53
+ return { severity: 'low', reason: 'possible user-facing text' };
54
+ }
@@ -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
+ }
@@ -0,0 +1,26 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parse } from '@vue/compiler-sfc';
3
+ import { scanVueTemplate } from './scan-vue-template.js';
4
+
5
+ function lineOffsetBefore(source, offset) {
6
+ return source.slice(0, offset).split(/\r?\n/).length - 1;
7
+ }
8
+
9
+ export async function scanVueSfc(file, config) {
10
+ const source = await readFile(file, 'utf8');
11
+ const result = parse(source, { filename: file });
12
+
13
+ if (result.errors.length > 0) {
14
+ const [error] = result.errors;
15
+ throw new Error(`Malformed Vue file ${file}: ${error.message || String(error)}`);
16
+ }
17
+
18
+ const template = result.descriptor.template;
19
+ if (!template) return [];
20
+
21
+ return scanVueTemplate(template.content, {
22
+ file,
23
+ lineOffset: lineOffsetBefore(source, template.loc.start.offset),
24
+ config,
25
+ });
26
+ }
@@ -0,0 +1,105 @@
1
+ import { NodeTypes, parse } from '@vue/compiler-dom';
2
+ import { classifyHardcoded } from '../rules/classify-hardcoded.js';
3
+
4
+ function offsetToLocation(source, offset) {
5
+ const before = source.slice(0, offset);
6
+ const lines = before.split(/\r?\n/);
7
+ return {
8
+ line: lines.length,
9
+ column: lines[lines.length - 1].length + 1,
10
+ };
11
+ }
12
+
13
+ function normalizeText(value) {
14
+ return value.replace(/\s+/g, ' ').trim();
15
+ }
16
+
17
+ function isI18nExpression(value) {
18
+ return /\b(?:t|te|\$t|i18n\.t)\s*\(/.test(value);
19
+ }
20
+
21
+ function makeIssue({ file, lineOffset, template, loc, value, kind, reason, severity }) {
22
+ const start = offsetToLocation(template, loc.start.offset);
23
+ return {
24
+ file,
25
+ line: lineOffset + start.line,
26
+ column: start.column,
27
+ value,
28
+ severity,
29
+ reason,
30
+ kind,
31
+ };
32
+ }
33
+
34
+ function scanValue({ file, lineOffset, template, loc, value, kind, baseReason, config }) {
35
+ const text = normalizeText(value);
36
+ if (!text) return null;
37
+
38
+ const classification = classifyHardcoded(text, config);
39
+ return makeIssue({
40
+ file,
41
+ lineOffset,
42
+ template,
43
+ loc,
44
+ value: text,
45
+ severity: classification.severity,
46
+ reason: classification.severity === 'ignored' ? classification.reason : baseReason,
47
+ kind,
48
+ });
49
+ }
50
+
51
+ function walk(node, visitor) {
52
+ visitor(node);
53
+ for (const child of node.children || []) walk(child, visitor);
54
+ }
55
+
56
+ export function scanVueTemplate(template, { file, lineOffset = 0, config }) {
57
+ let ast;
58
+ try {
59
+ ast = parse(template, { comments: false });
60
+ } catch (error) {
61
+ throw new Error(`Malformed Vue template in ${file}: ${error.message}`);
62
+ }
63
+
64
+ const attributes = new Set(config.hardcoded.vueAttributes);
65
+ const issues = [];
66
+
67
+ walk(ast, (node) => {
68
+ if (node.type === NodeTypes.TEXT) {
69
+ const issue = scanValue({
70
+ file,
71
+ lineOffset,
72
+ template,
73
+ loc: node.loc,
74
+ value: node.content,
75
+ kind: 'vue-text',
76
+ baseReason: 'static template text',
77
+ config,
78
+ });
79
+ if (issue) issues.push(issue);
80
+ return;
81
+ }
82
+
83
+ if (node.type !== NodeTypes.ELEMENT) return;
84
+
85
+ for (const prop of node.props || []) {
86
+ if (prop.type !== NodeTypes.ATTRIBUTE) continue;
87
+ if (!attributes.has(prop.name) || !prop.value) continue;
88
+ if (isI18nExpression(prop.value.content)) continue;
89
+
90
+ const issue = scanValue({
91
+ file,
92
+ lineOffset,
93
+ template,
94
+ loc: prop.value.loc,
95
+ value: prop.value.content,
96
+ kind: `vue-attribute:${prop.name}`,
97
+ baseReason: `static ${prop.name} attribute`,
98
+ config,
99
+ });
100
+ if (issue) issues.push(issue);
101
+ }
102
+ });
103
+
104
+ return issues;
105
+ }
package/src/types.js CHANGED
@@ -8,6 +8,12 @@
8
8
  * @property {(string | RegExp)[]=} allowIdenticalKeys
9
9
  * @property {(string | RegExp)[]=} allowIdenticalValues
10
10
  * @property {(string | RegExp)[]=} placeholderPatterns
11
+ * @property {string[]=} source
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
11
17
  * @property {boolean=} ignoreCodeLike
12
18
  * @property {boolean=} ignoreSameLanguageFamily
13
19
  * @property {boolean=} trimWhitespace
@@ -22,6 +28,15 @@
22
28
  * @property {string} value
23
29
  * @property {Severity} severity
24
30
  * @property {string} reason
31
+ *
32
+ * @typedef {Object} HardcodedIssue
33
+ * @property {string} file
34
+ * @property {number} line
35
+ * @property {number} column
36
+ * @property {string} value
37
+ * @property {Severity} severity
38
+ * @property {string} reason
39
+ * @property {string} kind
25
40
  */
26
41
 
27
42
  export {};