@mxml3gend/gloss 0.1.1 → 0.1.3
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 +74 -0
- package/dist/baseline.js +118 -0
- package/dist/cache.js +78 -0
- package/dist/cacheMetrics.js +120 -0
- package/dist/check.js +510 -0
- package/dist/config.js +214 -10
- package/dist/fs.js +105 -6
- package/dist/gitDiff.js +113 -0
- package/dist/hooks.js +101 -0
- package/dist/index.js +437 -12
- package/dist/renameKeyUsage.js +4 -12
- package/dist/server.js +163 -9
- package/dist/translationKeys.js +20 -0
- package/dist/translationTree.js +42 -0
- package/dist/typegen.js +30 -0
- package/dist/ui/Gloss_logo.png +0 -0
- package/dist/ui/assets/index-BCr07xD_.js +21 -0
- package/dist/ui/assets/index-CjmLcA1x.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/logo_full.png +0 -0
- package/dist/usage.js +105 -22
- package/dist/usageExtractor.js +151 -0
- package/dist/usageScanner.js +110 -28
- package/dist/xliff.js +92 -0
- package/package.json +15 -5
- package/dist/ui/assets/index-CREq9Gop.css +0 -1
- package/dist/ui/assets/index-Dhb2pVPI.js +0 -10
package/dist/check.js
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readAllTranslations } from "./fs.js";
|
|
4
|
+
import { createScanMatcher } from "./scanFilters.js";
|
|
5
|
+
import { flattenObject } from "./translationTree.js";
|
|
6
|
+
import { getInvalidTranslationKeyReason, isLikelyTranslationKey, } from "./translationKeys.js";
|
|
7
|
+
import { inferUsageRoot, scanUsage } from "./usageScanner.js";
|
|
8
|
+
const HARDCODED_IGNORED_DIRECTORIES = new Set([
|
|
9
|
+
"node_modules",
|
|
10
|
+
".git",
|
|
11
|
+
".next",
|
|
12
|
+
".nuxt",
|
|
13
|
+
".turbo",
|
|
14
|
+
"dist",
|
|
15
|
+
"build",
|
|
16
|
+
"coverage",
|
|
17
|
+
"out",
|
|
18
|
+
"storybook-static",
|
|
19
|
+
]);
|
|
20
|
+
const HARDCODED_EXTENSIONS = new Set([".tsx", ".jsx"]);
|
|
21
|
+
const JSX_TEXT_REGEX = />\s*([A-Za-z][A-Za-z0-9 .,!?'’"’-]{1,})\s*</g;
|
|
22
|
+
const JSX_ATTRIBUTE_REGEX = /\b(?:title|label|placeholder|alt|aria-label|helperText|tooltip|description)\s*=\s*["'`]([^"'`]+)["'`]/g;
|
|
23
|
+
const SIMPLE_PLACEHOLDER_REGEX = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
24
|
+
const ICU_PLACEHOLDER_REGEX = /\{([A-Za-z_][A-Za-z0-9_]*)\s*,\s*(plural|select|selectordinal)\s*,/g;
|
|
25
|
+
const ICU_PLURAL_START_REGEX = /\{([A-Za-z_][A-Za-z0-9_]*)\s*,\s*plural\s*,/g;
|
|
26
|
+
const ICU_CATEGORY_REGEX = /(?:^|[\s,])(=?\d+|zero|one|two|few|many|other)\s*\{/g;
|
|
27
|
+
const HARDCODED_IGNORE_MARKER = "gloss-ignore";
|
|
28
|
+
const CHECK_ALWAYS_FAIL_ON = ["missingTranslations", "invalidKeys"];
|
|
29
|
+
const CHECK_ALWAYS_WARN_ON = ["orphanKeys", "hardcodedTexts"];
|
|
30
|
+
const projectRoot = () => process.env.INIT_CWD || process.cwd();
|
|
31
|
+
const getCheckPolicy = (cfg) => {
|
|
32
|
+
const strictPlaceholders = cfg.strictPlaceholders !== false;
|
|
33
|
+
const failOn = [...CHECK_ALWAYS_FAIL_ON];
|
|
34
|
+
const warnOn = [...CHECK_ALWAYS_WARN_ON];
|
|
35
|
+
if (strictPlaceholders) {
|
|
36
|
+
failOn.push("placeholderMismatches");
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
warnOn.push("placeholderMismatches");
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
strictPlaceholders,
|
|
43
|
+
failOn,
|
|
44
|
+
warnOn,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const normalizePath = (filePath) => filePath.split(path.sep).join("/");
|
|
48
|
+
const withCollapsedWhitespace = (value) => value.replace(/\s+/g, " ").trim();
|
|
49
|
+
const lineNumberAtIndex = (source, index) => {
|
|
50
|
+
let line = 1;
|
|
51
|
+
for (let cursor = 0; cursor < index; cursor += 1) {
|
|
52
|
+
if (source.charCodeAt(cursor) === 10) {
|
|
53
|
+
line += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return line;
|
|
57
|
+
};
|
|
58
|
+
const hasIgnoredPathSegment = (relativePath) => normalizePath(relativePath)
|
|
59
|
+
.split("/")
|
|
60
|
+
.some((segment) => HARDCODED_IGNORED_DIRECTORIES.has(segment));
|
|
61
|
+
const isLikelyHardcodedText = (value, minLength) => {
|
|
62
|
+
const text = withCollapsedWhitespace(value);
|
|
63
|
+
if (text.length < minLength) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (!/[A-Za-z]/.test(text)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// Ignore values that clearly look like i18n keys, but keep plain words
|
|
70
|
+
// like "test" or "Save" so hardcoded UI text is still detected.
|
|
71
|
+
if (isLikelyTranslationKey(text) && /[.:/]/.test(text)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (/^(true|false|null|undefined)$/i.test(text)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (/^(https?:|\/|#)/i.test(text)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (/[=;(){}|]|=>/.test(text)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (/\b(?:return|const|let|var|function|import|export)\b/.test(text)) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (/\b(?:void|promise|string|number|boolean|record|unknown|any|extends|infer)\b/i.test(text)) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
};
|
|
91
|
+
const flattenByLocale = (cfg, data) => {
|
|
92
|
+
const result = {};
|
|
93
|
+
for (const locale of cfg.locales) {
|
|
94
|
+
const tree = data[locale] ?? {};
|
|
95
|
+
result[locale] = flattenObject(tree);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
};
|
|
99
|
+
const uniqueSorted = (items) => Array.from(new Set(items)).sort((left, right) => left.localeCompare(right));
|
|
100
|
+
const asComparable = (items) => items.join("\u0000");
|
|
101
|
+
const findMatchingBraceEnd = (value, startIndex) => {
|
|
102
|
+
let depth = 0;
|
|
103
|
+
for (let index = startIndex; index < value.length; index += 1) {
|
|
104
|
+
const char = value[index];
|
|
105
|
+
if (char === "{") {
|
|
106
|
+
depth += 1;
|
|
107
|
+
}
|
|
108
|
+
else if (char === "}") {
|
|
109
|
+
depth -= 1;
|
|
110
|
+
if (depth === 0) {
|
|
111
|
+
return index;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return -1;
|
|
116
|
+
};
|
|
117
|
+
const extractPlaceholders = (value) => {
|
|
118
|
+
const placeholders = new Set();
|
|
119
|
+
let match = SIMPLE_PLACEHOLDER_REGEX.exec(value);
|
|
120
|
+
while (match) {
|
|
121
|
+
placeholders.add(match[1]);
|
|
122
|
+
match = SIMPLE_PLACEHOLDER_REGEX.exec(value);
|
|
123
|
+
}
|
|
124
|
+
SIMPLE_PLACEHOLDER_REGEX.lastIndex = 0;
|
|
125
|
+
match = ICU_PLACEHOLDER_REGEX.exec(value);
|
|
126
|
+
while (match) {
|
|
127
|
+
placeholders.add(match[1]);
|
|
128
|
+
match = ICU_PLACEHOLDER_REGEX.exec(value);
|
|
129
|
+
}
|
|
130
|
+
ICU_PLACEHOLDER_REGEX.lastIndex = 0;
|
|
131
|
+
return uniqueSorted(placeholders);
|
|
132
|
+
};
|
|
133
|
+
const extractPluralCategories = (value) => {
|
|
134
|
+
const categoriesByVariable = new Map();
|
|
135
|
+
let match = ICU_PLURAL_START_REGEX.exec(value);
|
|
136
|
+
while (match) {
|
|
137
|
+
const variable = match[1];
|
|
138
|
+
const startIndex = match.index;
|
|
139
|
+
const endIndex = findMatchingBraceEnd(value, startIndex);
|
|
140
|
+
if (endIndex === -1) {
|
|
141
|
+
match = ICU_PLURAL_START_REGEX.exec(value);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const block = value.slice(startIndex, endIndex + 1);
|
|
145
|
+
const categories = categoriesByVariable.get(variable) ?? new Set();
|
|
146
|
+
let categoryMatch = ICU_CATEGORY_REGEX.exec(block);
|
|
147
|
+
while (categoryMatch) {
|
|
148
|
+
categories.add(categoryMatch[1]);
|
|
149
|
+
categoryMatch = ICU_CATEGORY_REGEX.exec(block);
|
|
150
|
+
}
|
|
151
|
+
ICU_CATEGORY_REGEX.lastIndex = 0;
|
|
152
|
+
categoriesByVariable.set(variable, categories);
|
|
153
|
+
ICU_PLURAL_START_REGEX.lastIndex = endIndex + 1;
|
|
154
|
+
match = ICU_PLURAL_START_REGEX.exec(value);
|
|
155
|
+
}
|
|
156
|
+
ICU_PLURAL_START_REGEX.lastIndex = 0;
|
|
157
|
+
return new Map(Array.from(categoriesByVariable.entries()).map(([variable, categories]) => [
|
|
158
|
+
variable,
|
|
159
|
+
uniqueSorted(categories),
|
|
160
|
+
]));
|
|
161
|
+
};
|
|
162
|
+
const scanHardcodedText = async (rootDir, cfg) => {
|
|
163
|
+
const issues = [];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
const shouldScanFile = createScanMatcher(cfg.scan);
|
|
166
|
+
const hardcodedConfig = cfg.hardcodedText ?? {
|
|
167
|
+
enabled: true,
|
|
168
|
+
minLength: 3,
|
|
169
|
+
excludePatterns: [],
|
|
170
|
+
};
|
|
171
|
+
if (hardcodedConfig.enabled === false) {
|
|
172
|
+
return { issues, suppressedCount: 0 };
|
|
173
|
+
}
|
|
174
|
+
const minLength = typeof hardcodedConfig.minLength === "number" &&
|
|
175
|
+
Number.isFinite(hardcodedConfig.minLength) &&
|
|
176
|
+
hardcodedConfig.minLength >= 1
|
|
177
|
+
? hardcodedConfig.minLength
|
|
178
|
+
: 3;
|
|
179
|
+
const excludeMatchers = (hardcodedConfig.excludePatterns ?? [])
|
|
180
|
+
.map((pattern) => pattern.trim())
|
|
181
|
+
.filter((pattern) => pattern.length > 0)
|
|
182
|
+
.map((pattern) => new RegExp(pattern));
|
|
183
|
+
let suppressedCount = 0;
|
|
184
|
+
const isSuppressed = (text, line, lines) => {
|
|
185
|
+
const currentLine = lines[line - 1] ?? "";
|
|
186
|
+
const previousLine = lines[line - 2] ?? "";
|
|
187
|
+
if (currentLine.includes(HARDCODED_IGNORE_MARKER) ||
|
|
188
|
+
previousLine.includes(HARDCODED_IGNORE_MARKER)) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
return excludeMatchers.some((matcher) => matcher.test(text));
|
|
192
|
+
};
|
|
193
|
+
const visitDirectory = async (directory) => {
|
|
194
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
const fullPath = path.join(directory, entry.name);
|
|
197
|
+
if (entry.isDirectory()) {
|
|
198
|
+
if (entry.name.startsWith(".") ||
|
|
199
|
+
HARDCODED_IGNORED_DIRECTORIES.has(entry.name)) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
await visitDirectory(fullPath);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!entry.isFile() || !HARDCODED_EXTENSIONS.has(path.extname(entry.name))) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const relativePath = normalizePath(path.relative(rootDir, fullPath));
|
|
209
|
+
if (hasIgnoredPathSegment(relativePath) || !shouldScanFile(relativePath)) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const source = await fs.readFile(fullPath, "utf8");
|
|
213
|
+
const lines = source.split("\n");
|
|
214
|
+
let textMatch = JSX_TEXT_REGEX.exec(source);
|
|
215
|
+
while (textMatch) {
|
|
216
|
+
const text = withCollapsedWhitespace(textMatch[1]);
|
|
217
|
+
if (isLikelyHardcodedText(text, minLength)) {
|
|
218
|
+
const line = lineNumberAtIndex(source, textMatch.index);
|
|
219
|
+
if (isSuppressed(text, line, lines)) {
|
|
220
|
+
suppressedCount += 1;
|
|
221
|
+
textMatch = JSX_TEXT_REGEX.exec(source);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const dedupeKey = `${relativePath}:${line}:jsx_text:${text}`;
|
|
225
|
+
if (!seen.has(dedupeKey)) {
|
|
226
|
+
seen.add(dedupeKey);
|
|
227
|
+
issues.push({ file: relativePath, line, kind: "jsx_text", text });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
textMatch = JSX_TEXT_REGEX.exec(source);
|
|
231
|
+
}
|
|
232
|
+
JSX_TEXT_REGEX.lastIndex = 0;
|
|
233
|
+
let attrMatch = JSX_ATTRIBUTE_REGEX.exec(source);
|
|
234
|
+
while (attrMatch) {
|
|
235
|
+
const text = withCollapsedWhitespace(attrMatch[1]);
|
|
236
|
+
if (isLikelyHardcodedText(text, minLength)) {
|
|
237
|
+
const line = lineNumberAtIndex(source, attrMatch.index);
|
|
238
|
+
if (isSuppressed(text, line, lines)) {
|
|
239
|
+
suppressedCount += 1;
|
|
240
|
+
attrMatch = JSX_ATTRIBUTE_REGEX.exec(source);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const dedupeKey = `${relativePath}:${line}:jsx_attribute:${text}`;
|
|
244
|
+
if (!seen.has(dedupeKey)) {
|
|
245
|
+
seen.add(dedupeKey);
|
|
246
|
+
issues.push({ file: relativePath, line, kind: "jsx_attribute", text });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
attrMatch = JSX_ATTRIBUTE_REGEX.exec(source);
|
|
250
|
+
}
|
|
251
|
+
JSX_ATTRIBUTE_REGEX.lastIndex = 0;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
await visitDirectory(rootDir);
|
|
255
|
+
issues.sort((left, right) => {
|
|
256
|
+
if (left.file !== right.file) {
|
|
257
|
+
return left.file.localeCompare(right.file);
|
|
258
|
+
}
|
|
259
|
+
if (left.line !== right.line) {
|
|
260
|
+
return left.line - right.line;
|
|
261
|
+
}
|
|
262
|
+
return left.text.localeCompare(right.text);
|
|
263
|
+
});
|
|
264
|
+
return { issues, suppressedCount };
|
|
265
|
+
};
|
|
266
|
+
export async function runGlossCheck(cfg, options) {
|
|
267
|
+
const rootDir = projectRoot();
|
|
268
|
+
const data = (await readAllTranslations(cfg));
|
|
269
|
+
const flatByLocale = flattenByLocale(cfg, data);
|
|
270
|
+
const usageRoot = inferUsageRoot(cfg);
|
|
271
|
+
const usage = await scanUsage(usageRoot, cfg.scan, {
|
|
272
|
+
useCache: options?.useCache,
|
|
273
|
+
});
|
|
274
|
+
const usageKeys = new Set(Object.keys(usage));
|
|
275
|
+
const translationKeys = new Set(cfg.locales.flatMap((locale) => Object.keys(flatByLocale[locale] ?? {})));
|
|
276
|
+
const allKeys = uniqueSorted([...translationKeys, ...usageKeys]);
|
|
277
|
+
const missingTranslations = [];
|
|
278
|
+
for (const key of allKeys) {
|
|
279
|
+
const missingLocales = cfg.locales.filter((locale) => {
|
|
280
|
+
const value = flatByLocale[locale]?.[key];
|
|
281
|
+
return value === undefined || value.trim() === "";
|
|
282
|
+
});
|
|
283
|
+
if (missingLocales.length > 0) {
|
|
284
|
+
missingTranslations.push({
|
|
285
|
+
key,
|
|
286
|
+
missingLocales,
|
|
287
|
+
usedInCode: usageKeys.has(key),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const orphanKeys = [];
|
|
292
|
+
for (const key of translationKeys) {
|
|
293
|
+
if (usageKeys.has(key)) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const localesWithValue = cfg.locales.filter((locale) => {
|
|
297
|
+
const value = flatByLocale[locale]?.[key];
|
|
298
|
+
return value !== undefined && value.trim() !== "";
|
|
299
|
+
});
|
|
300
|
+
orphanKeys.push({ key, localesWithValue });
|
|
301
|
+
}
|
|
302
|
+
orphanKeys.sort((left, right) => left.key.localeCompare(right.key));
|
|
303
|
+
const invalidKeys = [];
|
|
304
|
+
for (const key of translationKeys) {
|
|
305
|
+
const reason = getInvalidTranslationKeyReason(key);
|
|
306
|
+
if (reason) {
|
|
307
|
+
invalidKeys.push({ key, reason });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
invalidKeys.sort((left, right) => left.key.localeCompare(right.key));
|
|
311
|
+
const placeholderMismatches = [];
|
|
312
|
+
for (const key of translationKeys) {
|
|
313
|
+
const localesWithValue = cfg.locales.filter((locale) => {
|
|
314
|
+
const value = flatByLocale[locale]?.[key];
|
|
315
|
+
return value !== undefined && value.trim() !== "";
|
|
316
|
+
});
|
|
317
|
+
if (localesWithValue.length <= 1) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const referenceLocale = localesWithValue.includes(cfg.defaultLocale)
|
|
321
|
+
? cfg.defaultLocale
|
|
322
|
+
: localesWithValue[0];
|
|
323
|
+
const referenceValue = flatByLocale[referenceLocale][key];
|
|
324
|
+
const expectedPlaceholders = extractPlaceholders(referenceValue);
|
|
325
|
+
const expectedPluralByVariable = extractPluralCategories(referenceValue);
|
|
326
|
+
const expectedPlaceholdersComparable = asComparable(expectedPlaceholders);
|
|
327
|
+
const mismatchedLocales = [];
|
|
328
|
+
const byLocale = {};
|
|
329
|
+
const pluralMismatches = [];
|
|
330
|
+
for (const locale of localesWithValue) {
|
|
331
|
+
const value = flatByLocale[locale][key];
|
|
332
|
+
const placeholders = extractPlaceholders(value);
|
|
333
|
+
byLocale[locale] = placeholders;
|
|
334
|
+
if (asComparable(placeholders) !== expectedPlaceholdersComparable) {
|
|
335
|
+
mismatchedLocales.push(locale);
|
|
336
|
+
}
|
|
337
|
+
const actualPluralByVariable = extractPluralCategories(value);
|
|
338
|
+
const pluralVariables = uniqueSorted([
|
|
339
|
+
...expectedPluralByVariable.keys(),
|
|
340
|
+
...actualPluralByVariable.keys(),
|
|
341
|
+
]);
|
|
342
|
+
for (const variable of pluralVariables) {
|
|
343
|
+
const expectedCategories = expectedPluralByVariable.get(variable) ?? [];
|
|
344
|
+
const actualCategories = actualPluralByVariable.get(variable) ?? [];
|
|
345
|
+
if (asComparable(expectedCategories) !== asComparable(actualCategories)) {
|
|
346
|
+
pluralMismatches.push({
|
|
347
|
+
locale,
|
|
348
|
+
variable,
|
|
349
|
+
expectedCategories,
|
|
350
|
+
actualCategories,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (mismatchedLocales.length > 0 || pluralMismatches.length > 0) {
|
|
356
|
+
pluralMismatches.sort((left, right) => {
|
|
357
|
+
if (left.locale !== right.locale) {
|
|
358
|
+
return left.locale.localeCompare(right.locale);
|
|
359
|
+
}
|
|
360
|
+
return left.variable.localeCompare(right.variable);
|
|
361
|
+
});
|
|
362
|
+
placeholderMismatches.push({
|
|
363
|
+
key,
|
|
364
|
+
referenceLocale,
|
|
365
|
+
expectedPlaceholders,
|
|
366
|
+
byLocale,
|
|
367
|
+
mismatchedLocales: uniqueSorted(mismatchedLocales),
|
|
368
|
+
pluralMismatches,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
placeholderMismatches.sort((left, right) => left.key.localeCompare(right.key));
|
|
373
|
+
const hardcodedScan = await scanHardcodedText(usageRoot, cfg);
|
|
374
|
+
const hardcodedTexts = hardcodedScan.issues;
|
|
375
|
+
const policy = getCheckPolicy(cfg);
|
|
376
|
+
const categoryCounts = {
|
|
377
|
+
missingTranslations: missingTranslations.length,
|
|
378
|
+
orphanKeys: orphanKeys.length,
|
|
379
|
+
invalidKeys: invalidKeys.length,
|
|
380
|
+
placeholderMismatches: placeholderMismatches.length,
|
|
381
|
+
hardcodedTexts: hardcodedTexts.length,
|
|
382
|
+
suppressedHardcodedTexts: hardcodedScan.suppressedCount,
|
|
383
|
+
};
|
|
384
|
+
const errorIssues = policy.failOn.reduce((total, category) => {
|
|
385
|
+
return total + categoryCounts[category];
|
|
386
|
+
}, 0);
|
|
387
|
+
const warningIssues = policy.warnOn.reduce((total, category) => {
|
|
388
|
+
return total + categoryCounts[category];
|
|
389
|
+
}, 0);
|
|
390
|
+
const totalIssues = errorIssues + warningIssues;
|
|
391
|
+
const summary = {
|
|
392
|
+
...categoryCounts,
|
|
393
|
+
errorIssues,
|
|
394
|
+
warningIssues,
|
|
395
|
+
totalIssues,
|
|
396
|
+
};
|
|
397
|
+
const ok = summary.errorIssues === 0;
|
|
398
|
+
return {
|
|
399
|
+
schemaVersion: 1,
|
|
400
|
+
status: ok ? "pass" : "fail",
|
|
401
|
+
ok,
|
|
402
|
+
generatedAt: new Date().toISOString(),
|
|
403
|
+
rootDir: rootDir,
|
|
404
|
+
locales: cfg.locales,
|
|
405
|
+
policy: {
|
|
406
|
+
failOn: [...policy.failOn],
|
|
407
|
+
warnOn: [...policy.warnOn],
|
|
408
|
+
},
|
|
409
|
+
summary,
|
|
410
|
+
missingTranslations,
|
|
411
|
+
orphanKeys,
|
|
412
|
+
invalidKeys,
|
|
413
|
+
placeholderMismatches,
|
|
414
|
+
hardcodedTexts,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const printTable = (rows) => {
|
|
418
|
+
const labelWidth = Math.max(...rows.map((row) => row.label.length));
|
|
419
|
+
console.log("");
|
|
420
|
+
for (const row of rows) {
|
|
421
|
+
console.log(`${row.label.padEnd(labelWidth)} : ${row.value}`);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const printSample = (title, lines) => {
|
|
425
|
+
console.log(`\n${title} (${lines.length})`);
|
|
426
|
+
const limit = 12;
|
|
427
|
+
for (const line of lines.slice(0, limit)) {
|
|
428
|
+
console.log(`- ${line}`);
|
|
429
|
+
}
|
|
430
|
+
if (lines.length > limit) {
|
|
431
|
+
console.log(`- ... +${lines.length - limit} more`);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
export const printGlossCheck = (result, format, baseline) => {
|
|
435
|
+
const formatSigned = (value) => (value > 0 ? `+${value}` : `${value}`);
|
|
436
|
+
if (format === "human" || format === "both") {
|
|
437
|
+
console.log(`Gloss check for ${result.rootDir}`);
|
|
438
|
+
const placeholderSeverity = result.policy.failOn.includes("placeholderMismatches")
|
|
439
|
+
? "error"
|
|
440
|
+
: "warning";
|
|
441
|
+
printTable([
|
|
442
|
+
{
|
|
443
|
+
label: "Missing translations (error)",
|
|
444
|
+
value: result.summary.missingTranslations,
|
|
445
|
+
},
|
|
446
|
+
{ label: "Orphan keys (warning)", value: result.summary.orphanKeys },
|
|
447
|
+
{ label: "Invalid keys (error)", value: result.summary.invalidKeys },
|
|
448
|
+
{
|
|
449
|
+
label: `Placeholder mismatches (${placeholderSeverity})`,
|
|
450
|
+
value: result.summary.placeholderMismatches,
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
label: "Hardcoded text candidates (warning)",
|
|
454
|
+
value: result.summary.hardcodedTexts,
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
label: "Hardcoded text suppressed",
|
|
458
|
+
value: result.summary.suppressedHardcodedTexts,
|
|
459
|
+
},
|
|
460
|
+
{ label: "Error issues (fail CI)", value: result.summary.errorIssues },
|
|
461
|
+
{ label: "Warning issues", value: result.summary.warningIssues },
|
|
462
|
+
{ label: "Total issues", value: result.summary.totalIssues },
|
|
463
|
+
]);
|
|
464
|
+
printSample("Missing translations", result.missingTranslations.map((issue) => `${issue.key} -> missing in [${issue.missingLocales.join(", ")}]${issue.usedInCode ? " (used)" : ""}`));
|
|
465
|
+
printSample("Orphan keys", result.orphanKeys.map((issue) => `${issue.key} -> present in [${issue.localesWithValue.join(", ")}]`));
|
|
466
|
+
printSample("Invalid keys", result.invalidKeys.map((issue) => `${issue.key} -> ${issue.reason}`));
|
|
467
|
+
printSample("Placeholder mismatches", result.placeholderMismatches.map((issue) => {
|
|
468
|
+
const pluralInfo = issue.pluralMismatches.length > 0
|
|
469
|
+
? `; plural mismatches: ${issue.pluralMismatches.length}`
|
|
470
|
+
: "";
|
|
471
|
+
return `${issue.key} -> expected [${issue.expectedPlaceholders.join(", ")}] from ${issue.referenceLocale}; locales: [${issue.mismatchedLocales.join(", ")}]${pluralInfo}`;
|
|
472
|
+
}));
|
|
473
|
+
printSample("Hardcoded text candidates", result.hardcodedTexts.map((issue) => `${issue.file}:${issue.line} [${issue.kind}] ${issue.text}`));
|
|
474
|
+
if (baseline?.hasPrevious) {
|
|
475
|
+
console.log("\nDelta since baseline");
|
|
476
|
+
printTable([
|
|
477
|
+
{
|
|
478
|
+
label: "Missing translations",
|
|
479
|
+
value: formatSigned(baseline.delta.missingTranslations),
|
|
480
|
+
},
|
|
481
|
+
{ label: "Orphan keys", value: formatSigned(baseline.delta.orphanKeys) },
|
|
482
|
+
{ label: "Invalid keys", value: formatSigned(baseline.delta.invalidKeys) },
|
|
483
|
+
{
|
|
484
|
+
label: "Placeholder mismatches",
|
|
485
|
+
value: formatSigned(baseline.delta.placeholderMismatches),
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
label: "Hardcoded text candidates",
|
|
489
|
+
value: formatSigned(baseline.delta.hardcodedTexts),
|
|
490
|
+
},
|
|
491
|
+
{ label: "Error issues", value: formatSigned(baseline.delta.errorIssues) },
|
|
492
|
+
{
|
|
493
|
+
label: "Warning issues",
|
|
494
|
+
value: formatSigned(baseline.delta.warningIssues),
|
|
495
|
+
},
|
|
496
|
+
{ label: "Total issues", value: formatSigned(baseline.delta.totalIssues) },
|
|
497
|
+
]);
|
|
498
|
+
}
|
|
499
|
+
else if (baseline) {
|
|
500
|
+
console.log(`\nBaseline initialized at ${baseline.baselinePath}`);
|
|
501
|
+
}
|
|
502
|
+
console.log(result.ok ? "\nResult: PASS" : "\nResult: FAIL (blocking issues found)");
|
|
503
|
+
}
|
|
504
|
+
if (format === "json" || format === "both") {
|
|
505
|
+
if (format === "both") {
|
|
506
|
+
console.log("\nJSON output:");
|
|
507
|
+
}
|
|
508
|
+
console.log(JSON.stringify({ ...result, baseline }, null, 2));
|
|
509
|
+
}
|
|
510
|
+
};
|