deep-slop 1.4.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/.deep-slop/.deep-slop-ignore +13 -0
- package/LICENSE +21 -0
- package/README.md +1170 -0
- package/dist/arch-constraints-C7s1E_bc.js +450 -0
- package/dist/arch-rules-DI1SYPqu.js +358 -0
- package/dist/ast-slop-BGdr58wZ.js +1839 -0
- package/dist/config-lint-ph3vMUbg.js +371 -0
- package/dist/dead-flow-DHRkyxZT.js +1422 -0
- package/dist/deep-slop-bundled.js +33140 -0
- package/dist/discover-B_S_Fy2S.js +164 -0
- package/dist/dup-detect-DKRXM04q.js +709 -0
- package/dist/file-utils-B_HFXhCs.js +93 -0
- package/dist/format-lint-DeElllNm.js +445 -0
- package/dist/framework-lint-CqdlF9hX.js +782 -0
- package/dist/i18n-lint-CPzx7V8Q.js +605 -0
- package/dist/import-intelligence-SK4F7XpL.js +966 -0
- package/dist/index.d.ts +233 -0
- package/dist/index.js +1030 -0
- package/dist/knip-CgxnnTBZ.js +93 -0
- package/dist/lint-external-ZbW3jGvB.js +326 -0
- package/dist/markup-lint-DKVEDz9M.js +805 -0
- package/dist/mcp.js +35939 -0
- package/dist/meta-quality-Dai1W5iC.js +224 -0
- package/dist/perf-hints-BnWFMFff.js +500 -0
- package/dist/security-deep-DJRINs10.js +1198 -0
- package/dist/syntax-deep-ZQYMutky.js +624 -0
- package/dist/tree-sitter-CM-cP0nl.js +661 -0
- package/dist/type-safety-Dboj2C1t.js +519 -0
- package/package.json +92 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { i as toLines, r as readFileContent } from "./file-utils-B_HFXhCs.js";
|
|
2
|
+
import { extname, join, relative } from "node:path";
|
|
3
|
+
import { readdir, stat } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region src/engines/i18n-lint/index.ts
|
|
6
|
+
/** Build a diagnostic with common fields filled */
|
|
7
|
+
function diag(opts) {
|
|
8
|
+
return {
|
|
9
|
+
filePath: opts.filePath,
|
|
10
|
+
engine: "i18n-lint",
|
|
11
|
+
rule: opts.rule,
|
|
12
|
+
severity: opts.severity,
|
|
13
|
+
message: opts.message,
|
|
14
|
+
help: opts.help,
|
|
15
|
+
line: opts.line,
|
|
16
|
+
column: opts.column,
|
|
17
|
+
category: "i18n",
|
|
18
|
+
fixable: opts.fixable,
|
|
19
|
+
suggestion: opts.suggestion,
|
|
20
|
+
detail: opts.detail
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Determine language from file extension */
|
|
24
|
+
function languageFromPath(filePath) {
|
|
25
|
+
const ext = extname(filePath);
|
|
26
|
+
return {
|
|
27
|
+
".ts": "typescript",
|
|
28
|
+
".tsx": "typescript",
|
|
29
|
+
".js": "javascript",
|
|
30
|
+
".jsx": "javascript",
|
|
31
|
+
".mjs": "javascript",
|
|
32
|
+
".cjs": "javascript"
|
|
33
|
+
}[ext] ?? null;
|
|
34
|
+
}
|
|
35
|
+
/** Check if a file is a JSX/TSX file */
|
|
36
|
+
function isJsxFile(filePath) {
|
|
37
|
+
const ext = extname(filePath);
|
|
38
|
+
return ext === ".tsx" || ext === ".jsx";
|
|
39
|
+
}
|
|
40
|
+
/** Technical terms that should not be flagged as hardcoded strings */
|
|
41
|
+
const TECHNICAL_TERMS = new Set([
|
|
42
|
+
"OK",
|
|
43
|
+
"ok",
|
|
44
|
+
"Ok",
|
|
45
|
+
"ID",
|
|
46
|
+
"id",
|
|
47
|
+
"Id",
|
|
48
|
+
"URL",
|
|
49
|
+
"url",
|
|
50
|
+
"Url",
|
|
51
|
+
"URI",
|
|
52
|
+
"uri",
|
|
53
|
+
"Uri",
|
|
54
|
+
"API",
|
|
55
|
+
"api",
|
|
56
|
+
"Api",
|
|
57
|
+
"HTTP",
|
|
58
|
+
"http",
|
|
59
|
+
"Http",
|
|
60
|
+
"HTTPS",
|
|
61
|
+
"https",
|
|
62
|
+
"Https",
|
|
63
|
+
"CSS",
|
|
64
|
+
"css",
|
|
65
|
+
"Css",
|
|
66
|
+
"HTML",
|
|
67
|
+
"html",
|
|
68
|
+
"Html",
|
|
69
|
+
"JSON",
|
|
70
|
+
"json",
|
|
71
|
+
"Json",
|
|
72
|
+
"XML",
|
|
73
|
+
"xml",
|
|
74
|
+
"Xml",
|
|
75
|
+
"SQL",
|
|
76
|
+
"sql",
|
|
77
|
+
"Sql",
|
|
78
|
+
"SSH",
|
|
79
|
+
"ssh",
|
|
80
|
+
"Ssh",
|
|
81
|
+
"TCP",
|
|
82
|
+
"tcp",
|
|
83
|
+
"Tcp",
|
|
84
|
+
"UDP",
|
|
85
|
+
"udp",
|
|
86
|
+
"Udp",
|
|
87
|
+
"IP",
|
|
88
|
+
"ip",
|
|
89
|
+
"Ip",
|
|
90
|
+
"JWT",
|
|
91
|
+
"jwt",
|
|
92
|
+
"Jwt",
|
|
93
|
+
"UUID",
|
|
94
|
+
"uuid",
|
|
95
|
+
"Uuid",
|
|
96
|
+
"OTP",
|
|
97
|
+
"otp",
|
|
98
|
+
"Otp",
|
|
99
|
+
"MFA",
|
|
100
|
+
"mfa",
|
|
101
|
+
"Mfa",
|
|
102
|
+
"SEO",
|
|
103
|
+
"seo",
|
|
104
|
+
"Seo",
|
|
105
|
+
"CDN",
|
|
106
|
+
"cdn",
|
|
107
|
+
"Cdn",
|
|
108
|
+
"DOM",
|
|
109
|
+
"dom",
|
|
110
|
+
"Dom",
|
|
111
|
+
"SDK",
|
|
112
|
+
"sdk",
|
|
113
|
+
"Sdk",
|
|
114
|
+
"CLI",
|
|
115
|
+
"cli",
|
|
116
|
+
"Cli",
|
|
117
|
+
"GUI",
|
|
118
|
+
"gui",
|
|
119
|
+
"Gui",
|
|
120
|
+
"PDF",
|
|
121
|
+
"pdf",
|
|
122
|
+
"Pdf",
|
|
123
|
+
"FAQ",
|
|
124
|
+
"faq",
|
|
125
|
+
"Faq",
|
|
126
|
+
"DOI",
|
|
127
|
+
"doi",
|
|
128
|
+
"Doi",
|
|
129
|
+
"ISBN",
|
|
130
|
+
"isbn",
|
|
131
|
+
"Isbn",
|
|
132
|
+
"UTC",
|
|
133
|
+
"utc",
|
|
134
|
+
"Utc",
|
|
135
|
+
"GMT",
|
|
136
|
+
"gmt",
|
|
137
|
+
"Gmt",
|
|
138
|
+
"NaN",
|
|
139
|
+
"nan",
|
|
140
|
+
"true",
|
|
141
|
+
"false",
|
|
142
|
+
"null",
|
|
143
|
+
"undefined",
|
|
144
|
+
"yes",
|
|
145
|
+
"no",
|
|
146
|
+
"Yes",
|
|
147
|
+
"No",
|
|
148
|
+
"on",
|
|
149
|
+
"off",
|
|
150
|
+
"On",
|
|
151
|
+
"Off",
|
|
152
|
+
"N/A",
|
|
153
|
+
"n/a"
|
|
154
|
+
]);
|
|
155
|
+
/** Props that carry user-facing strings and should be i18n'd */
|
|
156
|
+
const I18N_PROPS = new Set([
|
|
157
|
+
"placeholder",
|
|
158
|
+
"title",
|
|
159
|
+
"aria-label",
|
|
160
|
+
"alt",
|
|
161
|
+
"label"
|
|
162
|
+
]);
|
|
163
|
+
/** Check if a string is emoji-only */
|
|
164
|
+
function isEmojiOnly(str) {
|
|
165
|
+
const trimmed = str.trim();
|
|
166
|
+
if (trimmed.length === 0) return false;
|
|
167
|
+
return /^[\p{Emoji_Presentation}\p{Extended_Pictographic}\u200d\ufe0f\s]+$/u.test(trimmed);
|
|
168
|
+
}
|
|
169
|
+
/** Check if a string is a pure number */
|
|
170
|
+
function isNumberOnly(str) {
|
|
171
|
+
const trimmed = str.trim();
|
|
172
|
+
return /^[\d.,\-+%]+$/.test(trimmed);
|
|
173
|
+
}
|
|
174
|
+
/** Check if a string is just whitespace / formatting */
|
|
175
|
+
function isWhitespaceOnly(str) {
|
|
176
|
+
return str.trim().length === 0;
|
|
177
|
+
}
|
|
178
|
+
/** Check if a string is a single word (no spaces, no hyphens connecting words) */
|
|
179
|
+
function isSingleWord(str) {
|
|
180
|
+
const trimmed = str.trim();
|
|
181
|
+
return trimmed.length > 0 && !/\s/.test(trimmed) && !/[-–—]/.test(trimmed);
|
|
182
|
+
}
|
|
183
|
+
/** Check if a string is a technical term */
|
|
184
|
+
function isTechnicalTerm(str) {
|
|
185
|
+
return TECHNICAL_TERMS.has(str.trim());
|
|
186
|
+
}
|
|
187
|
+
/** Check if a hardcoded string should be skipped (not reported) */
|
|
188
|
+
function shouldSkipJsxString(str) {
|
|
189
|
+
const trimmed = str.trim();
|
|
190
|
+
if (trimmed.length === 0) return true;
|
|
191
|
+
if (isWhitespaceOnly(str)) return true;
|
|
192
|
+
if (isEmojiOnly(trimmed)) return true;
|
|
193
|
+
if (isNumberOnly(trimmed)) return true;
|
|
194
|
+
if (isSingleWord(trimmed) && isTechnicalTerm(trimmed)) return true;
|
|
195
|
+
if (isSingleWord(trimmed) && /^[a-z_$][a-zA-Z0-9_$]*$/.test(trimmed)) return true;
|
|
196
|
+
if (/^[a-z-]+$/.test(trimmed) && trimmed.length <= 20) return true;
|
|
197
|
+
if (/^[^\w\s]+$/u.test(trimmed)) return true;
|
|
198
|
+
if (isSingleWord(trimmed) && trimmed.length <= 2) return true;
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Detect hardcoded string literals in JSX text content (between tags).
|
|
203
|
+
* Pattern: `<div>Hello World</div>` or `<p>Submit Form</p>`
|
|
204
|
+
* Skip: single words, technical terms, emoji-only, numbers, whitespace.
|
|
205
|
+
*/
|
|
206
|
+
function detectHardcodedStringJsx(content, lines, filePath) {
|
|
207
|
+
const results = [];
|
|
208
|
+
const jsxTextPattern = />([^<{]+)</g;
|
|
209
|
+
let match;
|
|
210
|
+
while ((match = jsxTextPattern.exec(content)) !== null) {
|
|
211
|
+
const rawText = match[1];
|
|
212
|
+
match[0];
|
|
213
|
+
if (shouldSkipJsxString(rawText)) continue;
|
|
214
|
+
if (isSingleWord(rawText.trim()) && isTechnicalTerm(rawText.trim())) continue;
|
|
215
|
+
if (isSingleWord(rawText.trim())) {
|
|
216
|
+
const word = rawText.trim();
|
|
217
|
+
if (/^[a-z_$][a-zA-Z0-9_$]*$/.test(word)) continue;
|
|
218
|
+
}
|
|
219
|
+
const matchStart = match.index;
|
|
220
|
+
const lineInfo = findLineByOffset(lines, matchStart);
|
|
221
|
+
if (!lineInfo) continue;
|
|
222
|
+
const col = lineInfo.text.indexOf(rawText.trim()) + 1 || lineInfo.col;
|
|
223
|
+
results.push(diag({
|
|
224
|
+
filePath,
|
|
225
|
+
rule: "i18n-lint/hardcoded-string-jsx",
|
|
226
|
+
severity: "info",
|
|
227
|
+
message: `Hardcoded string in JSX: "${rawText.trim()}" — should use i18n translation`,
|
|
228
|
+
help: "Replace with a translation key, e.g. <div>{t('key')}</div> or <Trans i18nKey=\"key\" />",
|
|
229
|
+
line: lineInfo.line,
|
|
230
|
+
column: Math.max(col, 1),
|
|
231
|
+
fixable: true,
|
|
232
|
+
suggestion: {
|
|
233
|
+
type: "replace",
|
|
234
|
+
text: `{t('${toKeyHint(rawText.trim())}')}`,
|
|
235
|
+
confidence: .6,
|
|
236
|
+
reason: "Replacing hardcoded JSX text with a translation call enables i18n support."
|
|
237
|
+
},
|
|
238
|
+
detail: { text: rawText.trim() }
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
return results;
|
|
242
|
+
}
|
|
243
|
+
/** Convert a user-facing string to a suggested translation key */
|
|
244
|
+
function toKeyHint(text) {
|
|
245
|
+
return text.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/).map((w, i) => i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join("") || "translationKey";
|
|
246
|
+
}
|
|
247
|
+
/** Find line number and column from a character offset in the content */
|
|
248
|
+
function findLineByOffset(lines, offset) {
|
|
249
|
+
let cumLen = 0;
|
|
250
|
+
for (const { num, text } of lines) {
|
|
251
|
+
const lineLen = text.length + 1;
|
|
252
|
+
if (cumLen + lineLen > offset) return {
|
|
253
|
+
line: num,
|
|
254
|
+
col: offset - cumLen + 1,
|
|
255
|
+
text
|
|
256
|
+
};
|
|
257
|
+
cumLen += lineLen;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Detect hardcoded user-facing strings in component props:
|
|
263
|
+
* placeholder=, title=, aria-label=, alt= (on images), label=
|
|
264
|
+
* Skip: CSS class names, technical props (type=, name=, id=)
|
|
265
|
+
*/
|
|
266
|
+
function detectHardcodedStringProps(content, lines, filePath) {
|
|
267
|
+
const results = [];
|
|
268
|
+
for (const prop of I18N_PROPS) {
|
|
269
|
+
const doubleQuotedRe = new RegExp(`\\b${escapeRegex(prop)}\\s*=\\s*"([^"]+)"`, "g");
|
|
270
|
+
let match;
|
|
271
|
+
while ((match = doubleQuotedRe.exec(content)) !== null) {
|
|
272
|
+
const value = match[1];
|
|
273
|
+
if (shouldSkipPropValue(value, prop)) continue;
|
|
274
|
+
const lineInfo = findLineByOffset(lines, match.index);
|
|
275
|
+
if (!lineInfo) continue;
|
|
276
|
+
results.push(makePropDiag(filePath, prop, value, lineInfo));
|
|
277
|
+
}
|
|
278
|
+
const singleQuotedRe = new RegExp(`\\b${escapeRegex(prop)}\\s*=\\s*'([^']+)'`, "g");
|
|
279
|
+
while ((match = singleQuotedRe.exec(content)) !== null) {
|
|
280
|
+
const value = match[1];
|
|
281
|
+
if (shouldSkipPropValue(value, prop)) continue;
|
|
282
|
+
const lineInfo = findLineByOffset(lines, match.index);
|
|
283
|
+
if (!lineInfo) continue;
|
|
284
|
+
results.push(makePropDiag(filePath, prop, value, lineInfo));
|
|
285
|
+
}
|
|
286
|
+
const templateRe = new RegExp(`\\b${escapeRegex(prop)}\\s*=\\s*\\{\\s*\`([^\`]+)\`\\s*\\}`, "g");
|
|
287
|
+
while ((match = templateRe.exec(content)) !== null) {
|
|
288
|
+
const value = match[1];
|
|
289
|
+
if (shouldSkipPropValue(value, prop)) continue;
|
|
290
|
+
const lineInfo = findLineByOffset(lines, match.index);
|
|
291
|
+
if (!lineInfo) continue;
|
|
292
|
+
results.push(makePropDiag(filePath, prop, value, lineInfo));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return results;
|
|
296
|
+
}
|
|
297
|
+
function makePropDiag(filePath, prop, value, lineInfo) {
|
|
298
|
+
return diag({
|
|
299
|
+
filePath,
|
|
300
|
+
rule: "i18n-lint/hardcoded-string-props",
|
|
301
|
+
severity: "warning",
|
|
302
|
+
message: `Hardcoded string in "${prop}" prop: "${value}" — should use i18n translation`,
|
|
303
|
+
help: `Replace with a translation expression: ${prop}={t('${toKeyHint(value)}')}`,
|
|
304
|
+
line: lineInfo.line,
|
|
305
|
+
column: Math.max(lineInfo.text.indexOf(prop) + 1, 1),
|
|
306
|
+
fixable: true,
|
|
307
|
+
suggestion: {
|
|
308
|
+
type: "replace",
|
|
309
|
+
text: `${prop}={t('${toKeyHint(value)}')}`,
|
|
310
|
+
confidence: .65,
|
|
311
|
+
reason: `Prop "${prop}" should use a translation key instead of a hardcoded string for i18n support.`
|
|
312
|
+
},
|
|
313
|
+
detail: {
|
|
314
|
+
prop,
|
|
315
|
+
value
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** Skip prop values that aren't really user-facing */
|
|
320
|
+
function shouldSkipPropValue(value, prop) {
|
|
321
|
+
const trimmed = value.trim();
|
|
322
|
+
if (trimmed.length === 0) return true;
|
|
323
|
+
if (isEmojiOnly(trimmed)) return true;
|
|
324
|
+
if (isNumberOnly(trimmed)) return true;
|
|
325
|
+
if (trimmed === "") return true;
|
|
326
|
+
if (prop === "alt" && /^(separator|spacer|bullet|icon|logo|image|decorative|presentation)$/i.test(trimmed)) return true;
|
|
327
|
+
if (prop === "aria-label" && isTechnicalTerm(trimmed) && isSingleWord(trimmed)) return true;
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
function escapeRegex(str) {
|
|
331
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
332
|
+
}
|
|
333
|
+
/** Flatten a nested JSON object into dot-notation keys */
|
|
334
|
+
function flattenKeys(obj, prefix = "") {
|
|
335
|
+
const keys = /* @__PURE__ */ new Set();
|
|
336
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
337
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
338
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
339
|
+
const nested = flattenKeys(value, fullKey);
|
|
340
|
+
for (const k of nested) keys.add(k);
|
|
341
|
+
} else keys.add(fullKey);
|
|
342
|
+
}
|
|
343
|
+
return keys;
|
|
344
|
+
}
|
|
345
|
+
/** Find locale JSON files in the project */
|
|
346
|
+
async function findLocaleFiles(rootDir) {
|
|
347
|
+
const candidates = [];
|
|
348
|
+
for (const dir of [
|
|
349
|
+
"messages",
|
|
350
|
+
"locales",
|
|
351
|
+
"i18n",
|
|
352
|
+
"public/locales",
|
|
353
|
+
"public/messages",
|
|
354
|
+
"public/i18n",
|
|
355
|
+
"src/locales",
|
|
356
|
+
"src/i18n",
|
|
357
|
+
"src/messages"
|
|
358
|
+
]) {
|
|
359
|
+
const absDir = join(rootDir, dir);
|
|
360
|
+
try {
|
|
361
|
+
if (!(await stat(absDir)).isDirectory()) continue;
|
|
362
|
+
const entries = await readdir(absDir);
|
|
363
|
+
for (const entry of entries) if (entry.endsWith(".json")) candidates.push(join(absDir, entry));
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
const rootEntries = await readdir(rootDir);
|
|
368
|
+
const localePattern = /^(en|fr|de|es|pt|it|nl|ja|ko|zh|ru|ar|hi|tr|pl|cs|sv|da|no|fi|uk|he|th|vi|id)\.json$/i;
|
|
369
|
+
for (const entry of rootEntries) if (localePattern.test(entry)) candidates.push(join(rootDir, entry));
|
|
370
|
+
} catch {}
|
|
371
|
+
return candidates;
|
|
372
|
+
}
|
|
373
|
+
/** Load all locale files and return their data */
|
|
374
|
+
async function loadLocales(rootDir) {
|
|
375
|
+
const localeFiles = await findLocaleFiles(rootDir);
|
|
376
|
+
const locales = [];
|
|
377
|
+
for (const filePath of localeFiles) try {
|
|
378
|
+
const content = await readFileContent(filePath);
|
|
379
|
+
const json = JSON.parse(content);
|
|
380
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) continue;
|
|
381
|
+
const localeName = (filePath.split("/").pop() ?? filePath.split("\\").pop() ?? "unknown").replace(/\.json$/, "");
|
|
382
|
+
const keys = flattenKeys(json);
|
|
383
|
+
locales.push({
|
|
384
|
+
locale: localeName,
|
|
385
|
+
keys,
|
|
386
|
+
filePath
|
|
387
|
+
});
|
|
388
|
+
} catch {}
|
|
389
|
+
return locales;
|
|
390
|
+
}
|
|
391
|
+
/** Extract translation keys from t() calls */
|
|
392
|
+
function extractTranslationKeys(content) {
|
|
393
|
+
const keys = [];
|
|
394
|
+
const tCallPattern = /(?:\bt|i18n\.t|i18next\.t|useTranslations\(\s*['"][^'"]*['"]\s*\)\.t)\s*\(\s*['"`]([^'"`\s]+)['"`]\s*[,\)]/g;
|
|
395
|
+
let match;
|
|
396
|
+
while ((match = tCallPattern.exec(content)) !== null) keys.push({
|
|
397
|
+
key: match[1],
|
|
398
|
+
offset: match.index
|
|
399
|
+
});
|
|
400
|
+
return keys;
|
|
401
|
+
}
|
|
402
|
+
function detectMissingTranslationKeys(content, lines, filePath, locales) {
|
|
403
|
+
if (locales.length === 0) return [];
|
|
404
|
+
const results = [];
|
|
405
|
+
const extractedKeys = extractTranslationKeys(content);
|
|
406
|
+
for (const { key, offset } of extractedKeys) {
|
|
407
|
+
const missingIn = [];
|
|
408
|
+
for (const locale of locales) if (!locale.keys.has(key)) missingIn.push(locale.locale);
|
|
409
|
+
if (missingIn.length > 0) {
|
|
410
|
+
const lineInfo = findLineByOffset(lines, offset);
|
|
411
|
+
if (!lineInfo) continue;
|
|
412
|
+
const allMissing = missingIn.length === locales.length;
|
|
413
|
+
const message = allMissing ? `Translation key "${key}" not found in any locale file` : `Translation key "${key}" missing in locale(s): ${missingIn.join(", ")}`;
|
|
414
|
+
results.push(diag({
|
|
415
|
+
filePath,
|
|
416
|
+
rule: "i18n-lint/missing-translation-key",
|
|
417
|
+
severity: "warning",
|
|
418
|
+
message,
|
|
419
|
+
help: allMissing ? `Add key "${key}" to all locale files, or check for a typo in the translation key.` : `Add the missing key "${key}" to: ${missingIn.map((l) => `${l}.json`).join(", ")}`,
|
|
420
|
+
line: lineInfo.line,
|
|
421
|
+
column: lineInfo.text.indexOf(key) + 1 || lineInfo.col,
|
|
422
|
+
fixable: false,
|
|
423
|
+
detail: {
|
|
424
|
+
key,
|
|
425
|
+
missingLocales: missingIn,
|
|
426
|
+
allMissing
|
|
427
|
+
}
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return results;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Detect components that import a specific locale string directly instead
|
|
435
|
+
* of using the i18n system.
|
|
436
|
+
* Pattern: importing from './ru.json' or hardcoded locale='ru'
|
|
437
|
+
*/
|
|
438
|
+
function detectLocaleMismatch(content, lines, filePath) {
|
|
439
|
+
const results = [];
|
|
440
|
+
for (const pattern of [/import\s+[^;]*\s+from\s+['"][^'"]*\/(locales|i18n|messages)\/(en|fr|de|es|pt|it|nl|ja|ko|zh|ru|ar|hi|tr|pl|cs|sv|da|no|fi|uk|he|th|vi|id)[^/]*\.json['"]/gi, /import\s+[^;]*\s+from\s+['"]\.\.?\/[^'"]*(en|fr|de|es|pt|it|nl|ja|ko|zh|ru|ar|hi|tr|pl|cs|sv|da|no|fi|uk|he|th|vi|id)\.json['"]/gi]) {
|
|
441
|
+
let match;
|
|
442
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
443
|
+
const lineInfo = findLineByOffset(lines, match.index);
|
|
444
|
+
if (!lineInfo) continue;
|
|
445
|
+
const localeMatch = match[0].match(/(en|fr|de|es|pt|it|nl|ja|ko|zh|ru|ar|hi|tr|pl|cs|sv|da|no|fi|uk|he|th|vi|id)\.json/i);
|
|
446
|
+
const localeCode = localeMatch ? localeMatch[1] : "unknown";
|
|
447
|
+
results.push(diag({
|
|
448
|
+
filePath,
|
|
449
|
+
rule: "i18n-lint/locale-mismatch",
|
|
450
|
+
severity: "warning",
|
|
451
|
+
message: `Direct import from locale file "${localeCode}.json" — bypasses i18n system`,
|
|
452
|
+
help: "Use the i18n translation function (t()) instead of importing locale JSON directly. Direct imports tie the component to a specific language.",
|
|
453
|
+
line: lineInfo.line,
|
|
454
|
+
column: lineInfo.text.indexOf("import") + 1 || 1,
|
|
455
|
+
fixable: false,
|
|
456
|
+
suggestion: {
|
|
457
|
+
type: "refactor",
|
|
458
|
+
text: `import { useTranslation } from 'react-i18next';`,
|
|
459
|
+
confidence: .7,
|
|
460
|
+
reason: "Use the i18n hook instead of directly importing a locale file."
|
|
461
|
+
},
|
|
462
|
+
detail: {
|
|
463
|
+
localeCode,
|
|
464
|
+
importPath: match[0]
|
|
465
|
+
}
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const localePropPattern = /\blocale\s*=\s*['"](en|fr|de|es|pt|it|nl|ja|ko|zh|ru|ar|hi|tr|pl|cs|sv|da|no|fi|uk|he|th|vi|id)['"]/gi;
|
|
470
|
+
let propMatch;
|
|
471
|
+
while ((propMatch = localePropPattern.exec(content)) !== null) {
|
|
472
|
+
const lineInfo = findLineByOffset(lines, propMatch.index);
|
|
473
|
+
if (!lineInfo) continue;
|
|
474
|
+
const localeCode = propMatch[1];
|
|
475
|
+
const trimmedLine = lineInfo.text.trim();
|
|
476
|
+
if (/i18n|initReactI18next|createInstance|config/.test(trimmedLine)) continue;
|
|
477
|
+
results.push(diag({
|
|
478
|
+
filePath,
|
|
479
|
+
rule: "i18n-lint/locale-mismatch",
|
|
480
|
+
severity: "warning",
|
|
481
|
+
message: `Hardcoded locale prop: locale="${localeCode}" — should use i18n system`,
|
|
482
|
+
help: "Use the dynamic locale from the i18n context instead of hardcoding a specific language: locale={i18n.language}",
|
|
483
|
+
line: lineInfo.line,
|
|
484
|
+
column: lineInfo.text.indexOf("locale") + 1 || 1,
|
|
485
|
+
fixable: true,
|
|
486
|
+
suggestion: {
|
|
487
|
+
type: "replace",
|
|
488
|
+
text: `locale={i18n.language}`,
|
|
489
|
+
confidence: .65,
|
|
490
|
+
reason: "Use the i18n system's current language instead of hardcoding a specific locale."
|
|
491
|
+
},
|
|
492
|
+
detail: { localeCode }
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
const requireLocalePattern = /require\s*\(\s*['"][^'"]*(en|fr|de|es|pt|it|nl|ja|ko|zh|ru|ar|hi|tr|pl|cs|sv|da|no|fi|uk|he|th|vi|id)\.json['"]\s*\)/gi;
|
|
496
|
+
while ((propMatch = requireLocalePattern.exec(content)) !== null) {
|
|
497
|
+
const lineInfo = findLineByOffset(lines, propMatch.index);
|
|
498
|
+
if (!lineInfo) continue;
|
|
499
|
+
const localeCode = propMatch[1];
|
|
500
|
+
results.push(diag({
|
|
501
|
+
filePath,
|
|
502
|
+
rule: "i18n-lint/locale-mismatch",
|
|
503
|
+
severity: "warning",
|
|
504
|
+
message: `require() of locale file "${localeCode}.json" — bypasses i18n system`,
|
|
505
|
+
help: "Use the i18n translation function instead of requiring locale JSON directly.",
|
|
506
|
+
line: lineInfo.line,
|
|
507
|
+
column: lineInfo.text.indexOf("require") + 1 || 1,
|
|
508
|
+
fixable: false,
|
|
509
|
+
detail: { localeCode }
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
return results;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Compare keys across locale files, report keys present in one locale
|
|
516
|
+
* but missing in another.
|
|
517
|
+
*/
|
|
518
|
+
function detectUntranslatedLocale(locales, rootDir) {
|
|
519
|
+
if (locales.length < 2) return [];
|
|
520
|
+
const results = [];
|
|
521
|
+
const allKeys = /* @__PURE__ */ new Set();
|
|
522
|
+
for (const locale of locales) for (const key of locale.keys) allKeys.add(key);
|
|
523
|
+
for (const key of allKeys) {
|
|
524
|
+
const presentIn = [];
|
|
525
|
+
const missingIn = [];
|
|
526
|
+
for (const locale of locales) if (locale.keys.has(key)) presentIn.push(locale.locale);
|
|
527
|
+
else missingIn.push(locale.locale);
|
|
528
|
+
if (missingIn.length > 0 && presentIn.length > 0) {
|
|
529
|
+
const sourceLocale = locales.find((l) => l.keys.has(key));
|
|
530
|
+
if (!sourceLocale) continue;
|
|
531
|
+
const relPath = relative(rootDir, sourceLocale.filePath);
|
|
532
|
+
results.push(diag({
|
|
533
|
+
filePath: relPath,
|
|
534
|
+
rule: "i18n-lint/untranslated-locale",
|
|
535
|
+
severity: "info",
|
|
536
|
+
message: `Key "${key}" present in [${presentIn.join(", ")}] but missing in [${missingIn.join(", ")}]`,
|
|
537
|
+
help: `Add the translation for "${key}" to: ${missingIn.map((l) => `${l}.json`).join(", ")}`,
|
|
538
|
+
line: 1,
|
|
539
|
+
column: 1,
|
|
540
|
+
fixable: false,
|
|
541
|
+
detail: {
|
|
542
|
+
key,
|
|
543
|
+
presentLocales: presentIn,
|
|
544
|
+
missingLocales: missingIn
|
|
545
|
+
}
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return results;
|
|
550
|
+
}
|
|
551
|
+
async function analyzeFile(filePath, rootDir, locales, hardcodedStringsEnabled, validateKeysEnabled) {
|
|
552
|
+
const diagnostics = [];
|
|
553
|
+
const language = languageFromPath(filePath);
|
|
554
|
+
if (!language) return diagnostics;
|
|
555
|
+
if (language !== "typescript" && language !== "javascript") return diagnostics;
|
|
556
|
+
let content;
|
|
557
|
+
try {
|
|
558
|
+
content = await readFileContent(filePath);
|
|
559
|
+
} catch {
|
|
560
|
+
return diagnostics;
|
|
561
|
+
}
|
|
562
|
+
const lines = toLines(content);
|
|
563
|
+
const relPath = relative(rootDir, filePath);
|
|
564
|
+
const isJsx = isJsxFile(filePath);
|
|
565
|
+
if (hardcodedStringsEnabled && isJsx) diagnostics.push(...detectHardcodedStringJsx(content, lines, relPath));
|
|
566
|
+
if (hardcodedStringsEnabled && isJsx) diagnostics.push(...detectHardcodedStringProps(content, lines, relPath));
|
|
567
|
+
if (validateKeysEnabled) diagnostics.push(...detectMissingTranslationKeys(content, lines, relPath, locales));
|
|
568
|
+
diagnostics.push(...detectLocaleMismatch(content, lines, relPath));
|
|
569
|
+
return diagnostics;
|
|
570
|
+
}
|
|
571
|
+
const i18nLintEngine = {
|
|
572
|
+
name: "i18n-lint",
|
|
573
|
+
description: "Internationalization linting engine. Detects hardcoded strings in JSX text and props, missing translation keys, direct locale imports bypassing the i18n system, and untranslated keys across locale files.",
|
|
574
|
+
supportedLanguages: ["typescript", "javascript"],
|
|
575
|
+
async run(context) {
|
|
576
|
+
const start = performance.now();
|
|
577
|
+
const files = context.files ?? [];
|
|
578
|
+
if (files.length === 0) return {
|
|
579
|
+
engine: "i18n-lint",
|
|
580
|
+
diagnostics: [],
|
|
581
|
+
elapsed: performance.now() - start,
|
|
582
|
+
skipped: true,
|
|
583
|
+
skipReason: "No files to scan (context.files is empty)"
|
|
584
|
+
};
|
|
585
|
+
const { hardcodedStrings, validateKeys } = context.config.i18n;
|
|
586
|
+
const locales = await loadLocales(context.rootDirectory);
|
|
587
|
+
const allDiagnostics = [];
|
|
588
|
+
const batchSize = 20;
|
|
589
|
+
for (let i = 0; i < files.length; i += batchSize) {
|
|
590
|
+
const batch = files.slice(i, i + batchSize);
|
|
591
|
+
const results = await Promise.all(batch.map((filePath) => analyzeFile(filePath, context.rootDirectory, locales, hardcodedStrings, validateKeys)));
|
|
592
|
+
for (const diags of results) allDiagnostics.push(...diags);
|
|
593
|
+
}
|
|
594
|
+
if (validateKeys && locales.length >= 2) allDiagnostics.push(...detectUntranslatedLocale(locales, context.rootDirectory));
|
|
595
|
+
return {
|
|
596
|
+
engine: "i18n-lint",
|
|
597
|
+
diagnostics: allDiagnostics,
|
|
598
|
+
elapsed: performance.now() - start,
|
|
599
|
+
skipped: false
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
//#endregion
|
|
605
|
+
export { i18nLintEngine };
|