@localheroai/cli 0.0.73 → 0.0.74-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,721 @@
1
+ import chalk from 'chalk';
2
+ import { appendFileSync } from 'fs';
3
+ import { configService } from '../utils/config.js';
4
+ import { findTranslationFiles, parseFile, flattenTranslations, extractLocaleFromPath } from '../utils/files.js';
5
+ import { findDuplicateYamlKeys, dedupeYaml } from '../utils/yaml-duplicates.js';
6
+ import { findMissingTranslationsByLocale, findTargetFile, processTargetContent } from '../utils/translation-utils.js';
7
+ import { createIgnoreMatcher, filterKeys } from '../utils/ignore-keys.js';
8
+ import { detectCheckConfig, defaultConfigDetectionDeps } from '../utils/check-config.js';
9
+ import { findOrphanKeys, findPlaceholderMismatches, findStructureMismatches, findPluralShapeMismatches, findPluralizedFlatKeys, findMissingPluralCategories, usedPluralCategories, findEmptyAndIdentical, findConflictingKeys, toStringValue } from '../utils/check-utils.js';
10
+ import { detectCiContext } from '../utils/ci-context.js';
11
+ import { resolveChangeBase, runGit } from '../utils/check-git.js';
12
+ import { baseFileSet, DUPLICATE_KEY_ERROR } from '../utils/check-changes.js';
13
+ import { buildStepSummary, plural } from '../utils/check-summary.js';
14
+ import { spellPlaceholders } from '../utils/placeholders.js';
15
+ import { keyLineFinder } from '../utils/key-lines.js';
16
+ const FAIL_ON_MODES = ['missing', 'placeholders', 'any', 'none'];
17
+ const FORMATS = ['github', 'text'];
18
+ const defaultDeps = {
19
+ console,
20
+ configUtils: configService,
21
+ fileUtils: { findTranslationFiles },
22
+ projectDetection: { detectProjectType: defaultConfigDetectionDeps.detectProjectType },
23
+ fsUtils: { listFiles: defaultConfigDetectionDeps.listFiles, readFile: defaultConfigDetectionDeps.readFile },
24
+ env: process.env,
25
+ git: runGit,
26
+ appendFile: (path, text) => appendFileSync(path, text)
27
+ };
28
+ // The CLI's YAML parser rejects a repeated key that Rails accepts (last value
29
+ // wins), so such files are reparsed here and the repeat becomes a finding.
30
+ async function recoverDuplicateKeyFiles(failures, knownLocales, config, readFile) {
31
+ const recovered = { files: [], duplicates: [] };
32
+ for (const failure of failures) {
33
+ const format = failure.path.split('.').pop() ?? '';
34
+ if (!['yml', 'yaml'].includes(format) || !String(failure.error).includes(DUPLICATE_KEY_ERROR))
35
+ continue;
36
+ let locale;
37
+ try {
38
+ locale = extractLocaleFromPath(failure.path, config.translationFiles.localeRegex, knownLocales);
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ const raw = await readFile(failure.path);
44
+ recovered.files.push({ path: failure.path, format, locale, content: Buffer.from(dedupeYaml(raw)).toString('base64') });
45
+ for (const duplicate of findDuplicateYamlKeys(raw, locale)) {
46
+ recovered.duplicates.push({ locale, path: failure.path, ...duplicate });
47
+ }
48
+ }
49
+ return recovered;
50
+ }
51
+ function decode(file, sourceLocale) {
52
+ if (!file.content)
53
+ return {};
54
+ const raw = Buffer.from(file.content, 'base64').toString('utf8');
55
+ return parseFile(raw, file.format, file.path, { sourceLanguage: sourceLocale, currentLanguage: file.locale });
56
+ }
57
+ function sourceKeysFor(sourceFile, sourceLocale) {
58
+ const parsed = decode(sourceFile, sourceLocale);
59
+ const wrapper = parsed[sourceLocale];
60
+ const tree = wrapper && typeof wrapper === 'object' && !Array.isArray(wrapper) ? wrapper : parsed;
61
+ return flattenTranslations(tree, '', sourceFile.format);
62
+ }
63
+ function isYaml(file) {
64
+ return file.format === 'yml' || file.format === 'yaml';
65
+ }
66
+ function targetKeysFor(targetFiles, targetLocale, sourceFile, sourceLocale) {
67
+ const targetFile = findTargetFile(targetFiles, targetLocale, sourceFile, sourceLocale);
68
+ if (!targetFile)
69
+ return { keys: {}, path: '' };
70
+ return { keys: keysOf(targetFile, targetLocale, sourceLocale), path: targetFile.path };
71
+ }
72
+ function keysOf(targetFile, targetLocale, sourceLocale) {
73
+ return processTargetContent(decode(targetFile, sourceLocale), targetLocale, targetFile.format);
74
+ }
75
+ function failedResult(format, sourceLocale = '') {
76
+ return {
77
+ aborted: true,
78
+ exitCode: 1,
79
+ reports: [],
80
+ sourceLocale,
81
+ sourceFiles: [],
82
+ keyCount: 0,
83
+ parseFailures: [],
84
+ detected: null,
85
+ format,
86
+ changes: null,
87
+ fileContents: {}
88
+ };
89
+ }
90
+ const SOURCE_REASONS = {
91
+ option: 'from --source',
92
+ gettext: 'its catalog is untranslated',
93
+ template: 'from the .pot template',
94
+ en: 'en is present',
95
+ guessed: 'guessed from the most keys'
96
+ };
97
+ function printDetected(con, detected, targetCount) {
98
+ const where = detected.paths.map((dir) => `${dir}${detected.pattern}`).join(', ');
99
+ if (targetCount === 0) {
100
+ con.log(chalk.blue(`ℹ No localhero.json found. Only ${detected.source} found in ${where}, so there is nothing to compare.`));
101
+ return;
102
+ }
103
+ con.log(chalk.blue(`ℹ No localhero.json found. Checking ${where}, source ${detected.source} (${SOURCE_REASONS[detected.reason]}).`));
104
+ if (detected.excluded.length > 0) {
105
+ con.log(chalk.blue(`ℹ Skipped ${detected.excluded.join(', ')}: no file matches a source file. Include them with --locales.`));
106
+ }
107
+ if (detected.reason === 'guessed') {
108
+ con.log(chalk.yellow(`⚠ ${detected.source} is a guess. If your source language is another one, pass --source <locale>.`));
109
+ }
110
+ }
111
+ function parseLocales(value) {
112
+ return value ? value.split(',').map((l) => l.trim()).filter(Boolean) : [];
113
+ }
114
+ function invalidOption(options) {
115
+ if (options.failOn && !FAIL_ON_MODES.includes(options.failOn)) {
116
+ return `Invalid --fail-on "${options.failOn}". Use one of: ${FAIL_ON_MODES.join(', ')}.`;
117
+ }
118
+ if (options.format && !FORMATS.includes(options.format)) {
119
+ return `Invalid --format "${options.format}". Use one of: ${FORMATS.join(', ')}.`;
120
+ }
121
+ if (options.changedOnly && options.full) {
122
+ return 'Use either --changed-only or --full, not both.';
123
+ }
124
+ return null;
125
+ }
126
+ function outputFormat(options, ci) {
127
+ if (options.json)
128
+ return 'json';
129
+ if (options.format)
130
+ return options.format;
131
+ return ci.githubActions ? 'github' : 'text';
132
+ }
133
+ const SILENT = { log: () => { } };
134
+ /** Target files a missing key points at that no longer exist: the change may have deleted them. */
135
+ function vanishedTargets(reports, files) {
136
+ const vanished = [];
137
+ for (const report of reports) {
138
+ const present = new Set((files.targetFilesByLocale[report.locale] ?? []).map((f) => f.path));
139
+ for (const path of new Set(report.missing.map((m) => m.targetPath))) {
140
+ if (!present.has(path))
141
+ vanished.push({ path, format: formatOf(path), locale: report.locale });
142
+ }
143
+ }
144
+ return vanished;
145
+ }
146
+ function compareWithBase(deps, ci, config, files, current, analyzeFiles) {
147
+ const configured = config.translationFiles.baseBranch || deps.env.GITHUB_BASE_REF;
148
+ const base = resolveChangeBase(deps.git, {
149
+ pullRequest: ci.pullRequest,
150
+ baseBranches: configured ? [configured] : ['main', 'master']
151
+ });
152
+ if ('error' in base)
153
+ return { status: base, reports: current };
154
+ let before;
155
+ try {
156
+ before = analyzeFiles(baseFileSet(deps.git, base.ref, files, vanishedTargets(current, files)));
157
+ }
158
+ catch (error) {
159
+ return { status: { error: `git could not read the base version: ${error.message.trim()}` }, reports: current };
160
+ }
161
+ return {
162
+ status: { base: base.label },
163
+ reports: current.map((report) => tagAgainstBase(report, before.find((b) => b.locale === report.locale)))
164
+ };
165
+ }
166
+ function sortedList(items) {
167
+ return [...items].sort().join('\0');
168
+ }
169
+ /**
170
+ * A finding is introduced when the base did not have the same problem. Each identity holds what makes the
171
+ * problem, so a placeholder mismatch that now misses a different placeholder counts as new.
172
+ */
173
+ function tagAgainstBase(report, base) {
174
+ const tag = (current, before, identity) => {
175
+ const existing = new Set((before ?? []).map((finding) => JSON.stringify(identity(finding))));
176
+ return current.map((finding) => ({ ...finding, introduced: !existing.has(JSON.stringify(identity(finding))) }));
177
+ };
178
+ const placeholders = (f) => [f.key, f.path, sortedList(f.missingInTarget), sortedList(f.unexpectedInTarget)];
179
+ return {
180
+ ...report,
181
+ missing: tag(report.missing, base?.missing, (f) => [f.key, f.targetPath]),
182
+ empty: tag(report.empty, base?.empty, (f) => [f.key, f.path]),
183
+ identical: tag(report.identical, base?.identical, (f) => [f.key, f.path, f.value]),
184
+ placeholderMismatches: tag(report.placeholderMismatches, base?.placeholderMismatches, placeholders),
185
+ placeholderHints: tag(report.placeholderHints, base?.placeholderHints, placeholders),
186
+ orphans: tag(report.orphans, base?.orphans, (f) => [f.key, f.path]),
187
+ structureMismatches: tag(report.structureMismatches, base?.structureMismatches, (f) => [f.key, f.path, f.sourceShape, f.targetShape]),
188
+ pluralShapeMismatches: tag(report.pluralShapeMismatches, base?.pluralShapeMismatches, (f) => [f.key, f.path]),
189
+ missingPluralCategories: tag(report.missingPluralCategories, base?.missingPluralCategories, (f) => [f.key, f.path, sortedList(f.missing)]),
190
+ conflictingKeys: tag(report.conflictingKeys, base?.conflictingKeys, (f) => [f.key, f.files, f.values]),
191
+ duplicateKeys: tag(report.duplicateKeys, base?.duplicateKeys, (f) => [f.key, f.path, f.values])
192
+ };
193
+ }
194
+ function filterFindings(r, keep) {
195
+ return {
196
+ ...r,
197
+ missing: r.missing.filter(keep),
198
+ empty: r.empty.filter(keep),
199
+ identical: r.identical.filter(keep),
200
+ placeholderMismatches: r.placeholderMismatches.filter(keep),
201
+ placeholderHints: r.placeholderHints.filter(keep),
202
+ orphans: r.orphans.filter(keep),
203
+ structureMismatches: r.structureMismatches.filter(keep),
204
+ pluralShapeMismatches: r.pluralShapeMismatches.filter(keep),
205
+ missingPluralCategories: r.missingPluralCategories.filter(keep),
206
+ conflictingKeys: r.conflictingKeys.filter(keep),
207
+ duplicateKeys: r.duplicateKeys.filter(keep)
208
+ };
209
+ }
210
+ function isIntroduced(finding) {
211
+ return finding.introduced !== false;
212
+ }
213
+ function isExisting(finding) {
214
+ return finding.introduced === false;
215
+ }
216
+ function formatOf(path) {
217
+ return path.split('.').pop() ?? '';
218
+ }
219
+ function withPath(findings, path) {
220
+ return findings.map((finding) => ({ ...finding, path }));
221
+ }
222
+ function missingCount(report) {
223
+ return report.missing.length + report.empty.length;
224
+ }
225
+ function isAtOrUnder(key, parents) {
226
+ for (let end = key.length; end !== -1; end = key.lastIndexOf('.', end - 1)) {
227
+ if (parents.has(key.slice(0, end)))
228
+ return true;
229
+ }
230
+ return false;
231
+ }
232
+ function reshapedKeysBetween(sourceKeys, targetKeys) {
233
+ return [
234
+ ...findStructureMismatches(sourceKeys, targetKeys).map((f) => f.key),
235
+ ...findPluralShapeMismatches(sourceKeys, targetKeys).map((f) => f.key),
236
+ ...findPluralizedFlatKeys(sourceKeys, targetKeys)
237
+ ];
238
+ }
239
+ function analyze(files, context) {
240
+ const { sourceLocale, targetLocales, detected, console, ignoreMatcher } = context;
241
+ const { sourceFiles, targetFilesByLocale } = files;
242
+ const filesFor = (locale) => (targetFilesByLocale[locale] || []).map((f) => f.path);
243
+ const withoutIgnored = (keys) => filterKeys(keys, ignoreMatcher).kept;
244
+ const localePluralCategories = {};
245
+ for (const locale of targetLocales) {
246
+ const categories = usedPluralCategories(locale);
247
+ if (categories)
248
+ localePluralCategories[locale] = categories;
249
+ }
250
+ const { missing } = findMissingTranslationsByLocale(sourceFiles, targetFilesByLocale, { sourceLocale, outputLocales: targetLocales, localePluralCategories }, false, console, { ignoreMatcher });
251
+ const readSource = (file) => withoutIgnored(sourceKeysFor(file, sourceLocale));
252
+ const sourceKeyMaps = sourceFiles.map((file) => ({ file, keys: readSource(file) }));
253
+ const totalSourceKeys = new Set();
254
+ for (const { keys } of sourceKeyMaps) {
255
+ for (const [key, value] of Object.entries(keys)) {
256
+ const text = toStringValue(value);
257
+ if (Array.isArray(value) || (text !== null && text !== ''))
258
+ totalSourceKeys.add(key);
259
+ }
260
+ }
261
+ const allSourceKeys = Object.assign({}, ...sourceKeyMaps.map(({ keys }) => keys));
262
+ const reports = targetLocales.map((locale) => {
263
+ const reshapedKeys = new Set();
264
+ const report = {
265
+ locale,
266
+ files: filesFor(locale),
267
+ keyCount: totalSourceKeys.size,
268
+ missing: [],
269
+ empty: [],
270
+ identical: [],
271
+ placeholderMismatches: [],
272
+ placeholderHints: [],
273
+ missingPluralCategories: [],
274
+ orphans: [],
275
+ structureMismatches: [],
276
+ pluralShapeMismatches: [],
277
+ conflictingKeys: [],
278
+ duplicateKeys: files.duplicates
279
+ .filter((d) => d.locale === locale)
280
+ .map(({ key, path, values }) => ({ key, path, values })),
281
+ missingFiles: []
282
+ };
283
+ const targetFiles = targetFilesByLocale[locale] || [];
284
+ const pairedTargetKeys = new Map();
285
+ for (const { file: sourceFile, keys: sourceKeys } of sourceKeyMaps) {
286
+ const { keys: allTargetKeys, path: targetPath } = targetKeysFor(targetFiles, locale, sourceFile, sourceLocale);
287
+ const targetKeys = withoutIgnored(allTargetKeys);
288
+ const { empty, identical } = findEmptyAndIdentical(sourceKeys, targetKeys);
289
+ report.empty.push(...withPath(empty, targetPath));
290
+ report.identical.push(...withPath(identical, targetPath));
291
+ for (const finding of withPath(findPlaceholderMismatches(sourceKeys, targetKeys), targetPath)) {
292
+ (finding.hint ? report.placeholderHints : report.placeholderMismatches).push(finding);
293
+ }
294
+ const structureMismatches = findStructureMismatches(sourceKeys, targetKeys);
295
+ const pluralShapeMismatches = findPluralShapeMismatches(sourceKeys, targetKeys);
296
+ report.structureMismatches.push(...withPath(structureMismatches, targetPath));
297
+ report.pluralShapeMismatches.push(...withPath(pluralShapeMismatches, targetPath));
298
+ for (const { key } of [...structureMismatches, ...pluralShapeMismatches])
299
+ reshapedKeys.add(key);
300
+ for (const key of findPluralizedFlatKeys(sourceKeys, targetKeys))
301
+ reshapedKeys.add(key);
302
+ if (targetPath)
303
+ pairedTargetKeys.set(targetPath, targetKeys);
304
+ report.missingPluralCategories.push(...withPath(findMissingPluralCategories(targetKeys, locale), targetPath));
305
+ }
306
+ // Rails merges every file of a locale, so a target key is an orphan only when no source file has it.
307
+ for (const [targetPath, targetKeys] of pairedTargetKeys) {
308
+ const reshaped = new Set([...reshapedKeys, ...reshapedKeysBetween(allSourceKeys, targetKeys)]);
309
+ const orphans = findOrphanKeys(allSourceKeys, targetKeys).filter((f) => !isAtOrUnder(f.key, reshaped));
310
+ report.orphans.push(...withPath(orphans, targetPath));
311
+ }
312
+ // Only single-language YAML shares one namespace per locale: gettext domains, i18next namespaces
313
+ // and multi-language files (often scoped to one view) are separate.
314
+ report.conflictingKeys = findConflictingKeys(targetFiles.filter((file) => isYaml(file) && !file.multiLanguage).map((file) => ({ path: file.path, keys: withoutIgnored(keysOf(file, locale, sourceLocale)) })));
315
+ const reportedElsewhere = new Set([...reshapedKeys, ...report.empty.map((f) => f.key)]);
316
+ const existingTargets = new Set(filesFor(locale));
317
+ for (const entry of Object.values(missing)) {
318
+ if (entry.locale !== locale)
319
+ continue;
320
+ // Without a config the targets are guessed, so a language that only
321
+ // translates part of the app is not reported missing for the rest.
322
+ if (detected && !existingTargets.has(entry.targetPath)) {
323
+ report.missingFiles.push(entry.path);
324
+ continue;
325
+ }
326
+ for (const key of Object.keys(entry.keys)) {
327
+ if (ignoreMatcher(key) || isAtOrUnder(key, reportedElsewhere))
328
+ continue;
329
+ report.missing.push({ key, path: entry.path, targetPath: entry.targetPath });
330
+ }
331
+ }
332
+ return report;
333
+ });
334
+ return { reports, keyCount: totalSourceKeys.size };
335
+ }
336
+ export async function runCheck(options = {}, deps = defaultDeps) {
337
+ const { console, configUtils, fileUtils, projectDetection, fsUtils } = deps;
338
+ const ci = detectCiContext(deps.env);
339
+ const format = outputFormat(options, ci);
340
+ const optionError = invalidOption(options);
341
+ if (optionError) {
342
+ console.error(chalk.red(`\n✖ ${optionError}\n`));
343
+ return failedResult(format);
344
+ }
345
+ const requestedLocales = parseLocales(options.locales);
346
+ let config = await configUtils.getProjectConfig();
347
+ let detected = null;
348
+ if (!config) {
349
+ const detection = await detectCheckConfig({ source: options.source, locales: requestedLocales, path: options.path, pattern: options.pattern }, { ...projectDetection, ...fsUtils });
350
+ if (!detection) {
351
+ console.error(chalk.red('\n✖ No translation files found. Run check from your project root, or point it at your locale folder with --path <dir>.\n'));
352
+ return failedResult(format);
353
+ }
354
+ ({ config, detected } = detection);
355
+ }
356
+ if (!config.translationFiles?.paths) {
357
+ console.error(chalk.red('\n✖ Invalid configuration: missing translationFiles.paths. Please run `npx @localheroai/cli init` to set up your configuration.\n'));
358
+ return failedResult(format);
359
+ }
360
+ const sourceLocale = options.source || config.sourceLocale;
361
+ const targetLocales = options.locales ? requestedLocales : config.outputLocales || [];
362
+ const result = await fileUtils.findTranslationFiles(config, {
363
+ returnFullResult: true,
364
+ sourceLocale,
365
+ targetLocales,
366
+ ...(format === 'json' ? { logger: { log: console.error } } : {}),
367
+ ...(format === 'github' ? { logger: SILENT } : {})
368
+ });
369
+ const discovered = result;
370
+ const recovered = await recoverDuplicateKeyFiles(discovered.parseFailures ?? [], [sourceLocale, ...targetLocales], config, fsUtils.readFile);
371
+ const recoveredPaths = new Set(recovered.files.map((f) => f.path));
372
+ const parseFailures = (discovered.parseFailures ?? []).filter((f) => !recoveredPaths.has(f.path));
373
+ const allFiles = [...discovered.allFiles, ...recovered.files];
374
+ const sourceFiles = [...discovered.sourceFiles, ...recovered.files.filter((f) => f.locale === sourceLocale)];
375
+ const targetFilesByLocale = { ...discovered.targetFilesByLocale };
376
+ for (const file of recovered.files.filter((f) => f.locale !== sourceLocale)) {
377
+ targetFilesByLocale[file.locale] = [...(targetFilesByLocale[file.locale] ?? []), file];
378
+ }
379
+ const filesFor = (locale) => (targetFilesByLocale[locale] || []).map((f) => f.path);
380
+ if (format === 'text') {
381
+ if (detected)
382
+ printDetected(console, detected, targetLocales.length);
383
+ console.log(chalk.blue(`ℹ Source locale: ${sourceLocale} (${sourceFiles.map((f) => f.path).join(', ') || 'no source files found'})`));
384
+ if (targetLocales.length === 0 && !detected)
385
+ console.log(chalk.blue('ℹ Target locales: none configured'));
386
+ for (const locale of targetLocales) {
387
+ console.log(chalk.blue(`ℹ ${locale}: ${filesFor(locale).join(', ') || 'no files found'}`));
388
+ }
389
+ }
390
+ if (parseFailures.length > 0 && format !== 'json') {
391
+ console.error(chalk.red(`\n✖ ${parseFailures.length} translation file(s) could not be parsed and were skipped:`));
392
+ for (const failure of parseFailures) {
393
+ console.error(chalk.red(` - ${failure.path}: ${failure.error}`));
394
+ }
395
+ }
396
+ if (!allFiles || allFiles.length === 0) {
397
+ console.error(chalk.red('\n✖ No translation files found in the specified paths.\n'));
398
+ return failedResult(format, sourceLocale);
399
+ }
400
+ const context = {
401
+ sourceLocale,
402
+ targetLocales,
403
+ detected,
404
+ console,
405
+ ignoreMatcher: createIgnoreMatcher(config.translationFiles?.ignoreKeys ?? [])
406
+ };
407
+ const files = { sourceFiles, targetFilesByLocale, duplicates: recovered.duplicates };
408
+ const current = analyze(files, context);
409
+ const changedOnly = !options.full && (options.changedOnly || ci.pullRequest !== null);
410
+ const quiet = { ...context, console: { log: SILENT.log, error: SILENT.log } };
411
+ const { status: changeStatus, reports } = changedOnly
412
+ ? compareWithBase(deps, ci, config, files, current.reports, (baseFiles) => analyze(baseFiles, quiet).reports)
413
+ : { status: null, reports: current.reports };
414
+ const failOn = options.failOn || 'missing';
415
+ // Without the diff, failing on every existing finding would block pull requests that did not cause them.
416
+ const findingsFail = !(changeStatus && 'error' in changeStatus) && shouldFail(reports.map((r) => filterFindings(r, isIntroduced)), failOn);
417
+ const exitCode = findingsFail || (parseFailures.length > 0 && failOn !== 'none') ? 1 : 0;
418
+ return {
419
+ aborted: false,
420
+ exitCode,
421
+ reports,
422
+ sourceLocale,
423
+ sourceFiles: sourceFiles.map((f) => f.path),
424
+ keyCount: current.keyCount,
425
+ parseFailures,
426
+ detected,
427
+ format,
428
+ changes: changeStatus,
429
+ fileContents: rawContents(discovered.allFiles)
430
+ };
431
+ }
432
+ // Files rebuilt because of duplicate keys are left out: their lines no longer match the repo.
433
+ function rawContents(files) {
434
+ const contents = {};
435
+ for (const file of files) {
436
+ if (file.content && !(file.path in contents)) {
437
+ contents[file.path] = Buffer.from(file.content, 'base64').toString('utf8');
438
+ }
439
+ }
440
+ return contents;
441
+ }
442
+ function lineLocator(contents) {
443
+ const finders = new Map();
444
+ return (file, key, locale) => {
445
+ const content = contents[file];
446
+ if (content === undefined)
447
+ return undefined;
448
+ const cacheKey = `${file}\u0000${locale}`;
449
+ let find = finders.get(cacheKey);
450
+ if (!find) {
451
+ find = keyLineFinder(content, formatOf(file), locale);
452
+ finders.set(cacheKey, find);
453
+ }
454
+ return find(key) ?? undefined;
455
+ };
456
+ }
457
+ function shouldFail(reports, failOn) {
458
+ if (failOn === 'none')
459
+ return false;
460
+ const totalMissing = reports.reduce((sum, r) => sum + missingCount(r), 0);
461
+ const placeholderCount = reports.reduce((sum, r) => sum + r.placeholderMismatches.length, 0);
462
+ if (failOn === 'missing')
463
+ return totalMissing > 0;
464
+ if (failOn === 'placeholders')
465
+ return placeholderCount > 0;
466
+ // Orphans never fail a run: a target-only key is as often an unloaded file or a
467
+ // framework's bundled translations as a real leftover, too unreliable to break CI.
468
+ const structureCount = reports.reduce((sum, r) => sum +
469
+ r.structureMismatches.length +
470
+ r.pluralShapeMismatches.length +
471
+ r.missingPluralCategories.length +
472
+ r.conflictingKeys.length +
473
+ r.duplicateKeys.length, 0);
474
+ return totalMissing > 0 || placeholderCount > 0 || structureCount > 0;
475
+ }
476
+ function truncate(text, max = 60) {
477
+ const oneLine = text.replace(/\s+/g, ' ').trim();
478
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
479
+ }
480
+ function printList(con, title, items, render, all, cap = 10) {
481
+ if (items.length === 0)
482
+ return;
483
+ con.log(chalk.bold(`\n${title} (${items.length}):`));
484
+ const shown = all ? items : items.slice(0, cap);
485
+ for (const item of shown)
486
+ con.log(` ${render(item)}`);
487
+ if (!all && items.length > cap)
488
+ con.log(chalk.dim(` ... and ${items.length - cap} more`));
489
+ }
490
+ function printHumanReport(con, reports, keyCount, all) {
491
+ if (reports.length === 0)
492
+ return;
493
+ con.log(chalk.bold('\nLocale Keys Missing Placeholders Orphans Complete'));
494
+ for (const r of reports) {
495
+ const missing = missingCount(r);
496
+ const complete = keyCount === 0 ? 100 : Math.round(((keyCount - missing) / keyCount) * 100);
497
+ con.log(`${r.locale.padEnd(14)}${String(keyCount).padEnd(7)}${String(missing).padEnd(9)}${String(r.placeholderMismatches.length).padEnd(14)}${String(r.orphans.length).padEnd(9)}${complete}%`);
498
+ }
499
+ const totalPlaceholders = reports.reduce((sum, r) => sum + r.placeholderMismatches.length, 0);
500
+ const totalOrphans = reports.reduce((sum, r) => sum + r.orphans.length, 0);
501
+ const totalMissing = reports.reduce((sum, r) => sum + missingCount(r), 0);
502
+ const totalPossible = keyCount * reports.length;
503
+ const overallComplete = totalPossible === 0 ? 100 : Math.round(((totalPossible - totalMissing) / totalPossible) * 100);
504
+ con.log(chalk.bold(`\n${reports.length} locale(s), ${keyCount} keys, ${overallComplete}% complete, ${totalPlaceholders} placeholder mismatches, ${totalOrphans} orphans`));
505
+ printFindings(con, reports, all);
506
+ }
507
+ function placeholderProblem(m) {
508
+ return [
509
+ m.missingInTarget.length ? `missing ${spellPlaceholders(m.missingInTarget, m.source).join(' ')}` : '',
510
+ m.unexpectedInTarget.length ? `unexpected ${spellPlaceholders(m.unexpectedInTarget, m.target).join(' ')}` : ''
511
+ ]
512
+ .filter(Boolean)
513
+ .join(', ');
514
+ }
515
+ function printFindings(con, reports, all) {
516
+ for (const r of reports) {
517
+ con.log(chalk.bold(`\n== ${r.locale} ==`));
518
+ printList(con, 'Missing keys', r.missing, (m) => m.key, all);
519
+ printList(con, 'Empty values', r.empty, (m) => `${m.key}: "${truncate(m.source)}" -> ""`, all);
520
+ printList(con, 'Placeholder mismatches', r.placeholderMismatches, (m) => `${m.key}: "${truncate(m.source)}" -> "${truncate(m.target)}" [${placeholderProblem(m)}]`, all);
521
+ printList(con, 'Placeholder hints (a plural form may leave out a placeholder)', r.placeholderHints, (m) => `${m.key}: "${truncate(m.source)}" -> "${truncate(m.target)}" [omits: ${spellPlaceholders(m.missingInTarget, m.source).join(', ')}]`, all);
522
+ printList(con, 'Orphan keys', r.orphans, (m) => `${m.key}: "${truncate(m.target ?? '')}"`, all);
523
+ printList(con, 'Structure mismatches', r.structureMismatches, (m) => `${m.key}: source is ${m.sourceShape}, target is ${m.targetShape}`, all);
524
+ printList(con, 'Plural shape mismatches', r.pluralShapeMismatches, (m) => m.key, all);
525
+ printList(con, 'Missing plural categories (falls back to `other`)', r.missingPluralCategories, (m) => `${m.key}: needs ${m.missing.join(', ')}`, all);
526
+ printList(con, 'Conflicting values (defined differently in several files)', r.conflictingKeys, (m) => `${m.key}: ${m.files.join(', ')}`, all);
527
+ printList(con, 'Duplicate keys (the last value wins)', r.duplicateKeys, (m) => `${m.key}: ${m.values.map((v) => `"${truncate(v, 30)}"`).join(' then ')} in ${m.path}`, all);
528
+ printList(con, `Not checked: no ${r.locale} file for`, r.missingFiles, (m) => m, all);
529
+ printList(con, 'Identical to source (hint)', r.identical, (m) => `${m.key}: "${truncate(m.value)}"`, all);
530
+ }
531
+ }
532
+ function escapeAnnotationData(text) {
533
+ return text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
534
+ }
535
+ function escapeAnnotationProperty(text) {
536
+ return escapeAnnotationData(text).replace(/:/g, '%3A').replace(/,/g, '%2C');
537
+ }
538
+ // GitHub shows only a handful of annotations per run; thousands just bury the log.
539
+ const MAX_ANNOTATIONS = 50;
540
+ // A missing translation points at the source key's line: in a pull request that
541
+ // added the key, that line is in the diff, so GitHub shows the annotation inline.
542
+ function annotationsFor(reports, sourceLocale, lineOf = () => undefined) {
543
+ const annotations = [];
544
+ for (const r of reports) {
545
+ const annotate = (level, file, message, key) => annotations.push({
546
+ level,
547
+ locale: r.locale,
548
+ file,
549
+ line: key === undefined ? undefined : lineOf(file, key, r.locale),
550
+ message
551
+ });
552
+ for (const m of r.missing) {
553
+ annotations.push({
554
+ level: 'error',
555
+ locale: r.locale,
556
+ file: m.path,
557
+ line: lineOf(m.path, m.key, sourceLocale),
558
+ message: `Missing translation for "${m.key}" (locale ${r.locale})`,
559
+ targetFile: m.targetPath
560
+ });
561
+ }
562
+ for (const m of r.empty) {
563
+ annotate('error', m.path, `Empty translation for "${m.key}" (locale ${r.locale})`, m.key);
564
+ }
565
+ for (const m of r.placeholderMismatches) {
566
+ annotate('error', m.path, `Placeholder mismatch for "${m.key}" (locale ${r.locale}): ${placeholderProblem(m)}`, m.key);
567
+ }
568
+ for (const m of r.missingPluralCategories) {
569
+ annotate('error', m.path, `"${m.key}" lacks plural forms ${m.missing.join(', ')} (locale ${r.locale})`, m.key);
570
+ }
571
+ for (const m of r.placeholderHints) {
572
+ const omitted = spellPlaceholders(m.missingInTarget, m.source).join(', ');
573
+ annotate('notice', m.path, `Plural form omits ${omitted} for "${m.key}" (locale ${r.locale})`, m.key);
574
+ }
575
+ for (const m of r.orphans) {
576
+ annotate('warning', m.path, `Orphan key "${m.key}" (locale ${r.locale}) no longer in source`, m.key);
577
+ }
578
+ for (const m of r.structureMismatches) {
579
+ annotate('error', m.path, `Structure mismatch for "${m.key}" (locale ${r.locale})`, m.key);
580
+ }
581
+ for (const m of r.pluralShapeMismatches) {
582
+ annotate('error', m.path, `Plural shape mismatch for "${m.key}" (locale ${r.locale})`, m.key);
583
+ }
584
+ for (const m of r.conflictingKeys) {
585
+ annotate('error', m.files[0], `"${m.key}" has different values in ${m.files.join(', ')} (locale ${r.locale})`, m.key);
586
+ }
587
+ for (const m of r.duplicateKeys) {
588
+ annotate('warning', m.path, `"${m.key}" is defined ${m.values.length} times; the last value wins (locale ${r.locale})`, m.key);
589
+ }
590
+ }
591
+ return annotations;
592
+ }
593
+ function problemsIn(reports, sourceLocale) {
594
+ return annotationsFor(reports, sourceLocale).filter((a) => a.level !== 'notice');
595
+ }
596
+ function printGithubAnnotations(con, annotations) {
597
+ const lines = annotations.map(({ level, file, line, message }) => {
598
+ const where = line === undefined ? '' : `,line=${line}`;
599
+ return `::${level} file=${escapeAnnotationProperty(file)}${where}::${escapeAnnotationData(message)}`;
600
+ });
601
+ for (const line of lines.slice(0, MAX_ANNOTATIONS))
602
+ con.log(line);
603
+ if (lines.length > MAX_ANNOTATIONS) {
604
+ con.log(`::warning::${lines.length - MAX_ANNOTATIONS} more findings not shown. Run check --json for the full report.`);
605
+ }
606
+ }
607
+ function problemCounts(r) {
608
+ return {
609
+ missing: missingCount(r),
610
+ placeholders: r.placeholderMismatches.length,
611
+ plurals: r.missingPluralCategories.length + r.pluralShapeMismatches.length,
612
+ structure: r.structureMismatches.length,
613
+ conflicts: r.conflictingKeys.length + r.duplicateKeys.length,
614
+ orphans: r.orphans.length
615
+ };
616
+ }
617
+ function unavailableMessage(error) {
618
+ return `Could not compare with the base branch: ${error}. Checked every key instead; its findings do not fail the run. In GitHub Actions, check out with fetch-depth: 0 if this keeps happening.`;
619
+ }
620
+ function existingProblemsLine(count) {
621
+ return `${plural(count, 'problem')} already on the base branch ${count === 1 ? 'is' : 'are'}`;
622
+ }
623
+ function changedOnlyJson(changes) {
624
+ if (changes === null)
625
+ return null;
626
+ if ('error' in changes)
627
+ return { base: null, diffAvailable: false, reason: changes.error };
628
+ return { base: changes.base, diffAvailable: true };
629
+ }
630
+ function printJsonReport(con, result) {
631
+ con.log(JSON.stringify({
632
+ sourceLocale: result.sourceLocale,
633
+ sourceFiles: result.sourceFiles,
634
+ keyCount: result.keyCount,
635
+ parseFailures: result.parseFailures,
636
+ detected: result.detected,
637
+ changedOnly: changedOnlyJson(result.changes),
638
+ locales: result.reports.map((r) => ({
639
+ locale: r.locale,
640
+ files: r.files,
641
+ keyCount: r.keyCount,
642
+ missing: r.missing,
643
+ empty: r.empty,
644
+ identical: r.identical,
645
+ placeholderMismatches: r.placeholderMismatches,
646
+ placeholderHints: r.placeholderHints,
647
+ missingPluralCategories: r.missingPluralCategories,
648
+ orphans: r.orphans,
649
+ structureMismatches: r.structureMismatches,
650
+ pluralShapeMismatches: r.pluralShapeMismatches,
651
+ conflictingKeys: r.conflictingKeys,
652
+ duplicateKeys: r.duplicateKeys,
653
+ missingFiles: r.missingFiles
654
+ }))
655
+ }));
656
+ }
657
+ function countedInSummary(changes, reports) {
658
+ if (changes === null)
659
+ return [];
660
+ if ('error' in changes)
661
+ return reports;
662
+ return reports.map((r) => filterFindings(r, isExisting));
663
+ }
664
+ function writeStepSummary(deps, path, changes, reports, sourceLocale) {
665
+ const listed = changes !== null && 'error' in changes ? [] : reports.map((r) => filterFindings(r, isIntroduced));
666
+ const markdown = buildStepSummary({
667
+ changes,
668
+ problems: problemsIn(listed, sourceLocale).map((a) => ({ ...a, file: a.targetFile ?? a.file })),
669
+ counts: countedInSummary(changes, reports).map((r) => ({ locale: r.locale, counts: problemCounts(r) })),
670
+ missingTranslations: reports.some((r) => missingCount(r) > 0)
671
+ });
672
+ try {
673
+ deps.appendFile(path, `${markdown}\n`);
674
+ }
675
+ catch (error) {
676
+ deps.console.error(chalk.yellow(`⚠ Could not write the job summary: ${error.message}`));
677
+ }
678
+ }
679
+ export async function check(options = {}, deps = defaultDeps) {
680
+ const con = deps.console;
681
+ const result = await runCheck(options, deps);
682
+ const { reports, sourceLocale, keyCount, format, changes, fileContents } = result;
683
+ process.exitCode = result.exitCode;
684
+ if (result.aborted)
685
+ return;
686
+ const unavailable = changes !== null && 'error' in changes ? changes.error : null;
687
+ const base = changes !== null && 'base' in changes ? changes.base : null;
688
+ const introduced = reports.map((r) => filterFindings(r, isIntroduced));
689
+ const existingProblems = base ? problemsIn(reports.map((r) => filterFindings(r, isExisting)), sourceLocale).length : 0;
690
+ const summaryPath = detectCiContext(deps.env).stepSummaryPath;
691
+ if (format === 'json') {
692
+ if (unavailable)
693
+ con.error(chalk.yellow(`⚠ ${unavailableMessage(unavailable)}`));
694
+ printJsonReport(con, result);
695
+ }
696
+ else if (format === 'github') {
697
+ if (unavailable) {
698
+ con.log(`::warning::${escapeAnnotationData(unavailableMessage(unavailable))}`);
699
+ }
700
+ else {
701
+ printGithubAnnotations(con, annotationsFor(introduced, sourceLocale, lineLocator(fileContents)));
702
+ }
703
+ if (base) {
704
+ const where = summaryPath ? 'counted in the job summary' : 'not listed; run with --full to see them';
705
+ con.log(`Compared with ${base}. ${existingProblemsLine(existingProblems)} ${where}.`);
706
+ }
707
+ }
708
+ else if (base) {
709
+ con.log(chalk.blue(`ℹ Compared with ${base}. Only new problems are listed.`));
710
+ printFindings(con, introduced, Boolean(options.all));
711
+ con.log(`\n${existingProblemsLine(existingProblems)} not listed. Run with --full to see them.`);
712
+ }
713
+ else {
714
+ if (unavailable)
715
+ con.log(chalk.yellow(`⚠ ${unavailableMessage(unavailable)}`));
716
+ printHumanReport(con, reports, keyCount, Boolean(options.all));
717
+ }
718
+ if (summaryPath)
719
+ writeStepSummary(deps, summaryPath, changes, reports, sourceLocale);
720
+ }
721
+ //# sourceMappingURL=check.js.map