@localheroai/cli 0.0.74-rc.1 → 0.0.74-rc.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.
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { appendFileSync } from 'fs';
2
3
  import { configService } from '../utils/config.js';
3
4
  import { findTranslationFiles, parseFile, flattenTranslations, extractLocaleFromPath } from '../utils/files.js';
4
5
  import { findDuplicateYamlKeys, dedupeYaml } from '../utils/yaml-duplicates.js';
@@ -6,16 +7,24 @@ import { findMissingTranslationsByLocale, findTargetFile, processTargetContent }
6
7
  import { createIgnoreMatcher, filterKeys } from '../utils/ignore-keys.js';
7
8
  import { detectCheckConfig, defaultConfigDetectionDeps } from '../utils/check-config.js';
8
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';
9
16
  const FAIL_ON_MODES = ['missing', 'placeholders', 'any', 'none'];
10
- const FORMATS = ['github'];
17
+ const FORMATS = ['github', 'text'];
11
18
  const defaultDeps = {
12
19
  console,
13
20
  configUtils: configService,
14
21
  fileUtils: { findTranslationFiles },
15
22
  projectDetection: { detectProjectType: defaultConfigDetectionDeps.detectProjectType },
16
- fsUtils: { listFiles: defaultConfigDetectionDeps.listFiles, readFile: defaultConfigDetectionDeps.readFile }
23
+ fsUtils: { listFiles: defaultConfigDetectionDeps.listFiles, readFile: defaultConfigDetectionDeps.readFile },
24
+ env: process.env,
25
+ git: runGit,
26
+ appendFile: (path, text) => appendFileSync(path, text)
17
27
  };
18
- const DUPLICATE_KEY_ERROR = 'Map keys must be unique';
19
28
  // The CLI's YAML parser rejects a repeated key that Rails accepts (last value
20
29
  // wins), so such files are reparsed here and the repeat becomes a finding.
21
30
  async function recoverDuplicateKeyFiles(failures, knownLocales, config, readFile) {
@@ -63,7 +72,7 @@ function targetKeysFor(targetFiles, targetLocale, sourceFile, sourceLocale) {
63
72
  function keysOf(targetFile, targetLocale, sourceLocale) {
64
73
  return processTargetContent(decode(targetFile, sourceLocale), targetLocale, targetFile.format);
65
74
  }
66
- function failedResult(sourceLocale = '') {
75
+ function failedResult(format, sourceLocale = '') {
67
76
  return {
68
77
  aborted: true,
69
78
  exitCode: 1,
@@ -72,7 +81,10 @@ function failedResult(sourceLocale = '') {
72
81
  sourceFiles: [],
73
82
  keyCount: 0,
74
83
  parseFailures: [],
75
- detected: null
84
+ detected: null,
85
+ format,
86
+ changes: null,
87
+ fileContents: {}
76
88
  };
77
89
  }
78
90
  const SOURCE_REASONS = {
@@ -106,8 +118,104 @@ function invalidOption(options) {
106
118
  if (options.format && !FORMATS.includes(options.format)) {
107
119
  return `Invalid --format "${options.format}". Use one of: ${FORMATS.join(', ')}.`;
108
120
  }
121
+ if (options.changedOnly && options.full) {
122
+ return 'Use either --changed-only or --full, not both.';
123
+ }
109
124
  return null;
110
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
+ }
111
219
  function withPath(findings, path) {
112
220
  return findings.map((finding) => ({ ...finding, path }));
113
221
  }
@@ -128,68 +236,10 @@ function reshapedKeysBetween(sourceKeys, targetKeys) {
128
236
  ...findPluralizedFlatKeys(sourceKeys, targetKeys)
129
237
  ];
130
238
  }
131
- export async function runCheck(options = {}, deps = defaultDeps) {
132
- const { console, configUtils, fileUtils, projectDetection, fsUtils } = deps;
133
- const optionError = invalidOption(options);
134
- if (optionError) {
135
- console.error(chalk.red(`\n✖ ${optionError}\n`));
136
- return failedResult();
137
- }
138
- const requestedLocales = parseLocales(options.locales);
139
- let config = await configUtils.getProjectConfig();
140
- let detected = null;
141
- if (!config) {
142
- const detection = await detectCheckConfig({ source: options.source, locales: requestedLocales, path: options.path, pattern: options.pattern }, { ...projectDetection, ...fsUtils });
143
- if (!detection) {
144
- 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'));
145
- return failedResult();
146
- }
147
- ({ config, detected } = detection);
148
- }
149
- if (!config.translationFiles?.paths) {
150
- console.error(chalk.red('\n✖ Invalid configuration: missing translationFiles.paths. Please run `npx @localheroai/cli init` to set up your configuration.\n'));
151
- return failedResult();
152
- }
153
- const sourceLocale = options.source || config.sourceLocale;
154
- const targetLocales = options.locales ? requestedLocales : config.outputLocales || [];
155
- const result = await fileUtils.findTranslationFiles(config, {
156
- returnFullResult: true,
157
- sourceLocale,
158
- targetLocales,
159
- ...(options.json ? { logger: { log: console.error } } : {})
160
- });
161
- const discovered = result;
162
- const recovered = await recoverDuplicateKeyFiles(discovered.parseFailures ?? [], [sourceLocale, ...targetLocales], config, fsUtils.readFile);
163
- const recoveredPaths = new Set(recovered.files.map((f) => f.path));
164
- const parseFailures = (discovered.parseFailures ?? []).filter((f) => !recoveredPaths.has(f.path));
165
- const allFiles = [...discovered.allFiles, ...recovered.files];
166
- const sourceFiles = [...discovered.sourceFiles, ...recovered.files.filter((f) => f.locale === sourceLocale)];
167
- const targetFilesByLocale = { ...discovered.targetFilesByLocale };
168
- for (const file of recovered.files.filter((f) => f.locale !== sourceLocale)) {
169
- targetFilesByLocale[file.locale] = [...(targetFilesByLocale[file.locale] ?? []), file];
170
- }
239
+ function analyze(files, context) {
240
+ const { sourceLocale, targetLocales, detected, console, ignoreMatcher } = context;
241
+ const { sourceFiles, targetFilesByLocale } = files;
171
242
  const filesFor = (locale) => (targetFilesByLocale[locale] || []).map((f) => f.path);
172
- if (!options.json && !options.format) {
173
- if (detected)
174
- printDetected(console, detected, targetLocales.length);
175
- console.log(chalk.blue(`ℹ Source locale: ${sourceLocale} (${sourceFiles.map((f) => f.path).join(', ') || 'no source files found'})`));
176
- if (targetLocales.length === 0 && !detected)
177
- console.log(chalk.blue('ℹ Target locales: none configured'));
178
- for (const locale of targetLocales) {
179
- console.log(chalk.blue(`ℹ ${locale}: ${filesFor(locale).join(', ') || 'no files found'}`));
180
- }
181
- }
182
- if (parseFailures.length > 0 && !options.json) {
183
- console.error(chalk.red(`\n✖ ${parseFailures.length} translation file(s) could not be parsed and were skipped:`));
184
- for (const failure of parseFailures) {
185
- console.error(chalk.red(` - ${failure.path}: ${failure.error}`));
186
- }
187
- }
188
- if (!allFiles || allFiles.length === 0) {
189
- console.error(chalk.red('\n✖ No translation files found in the specified paths.\n'));
190
- return failedResult(sourceLocale);
191
- }
192
- const ignoreMatcher = createIgnoreMatcher(config.translationFiles?.ignoreKeys ?? []);
193
243
  const withoutIgnored = (keys) => filterKeys(keys, ignoreMatcher).kept;
194
244
  const localePluralCategories = {};
195
245
  for (const locale of targetLocales) {
@@ -198,7 +248,8 @@ export async function runCheck(options = {}, deps = defaultDeps) {
198
248
  localePluralCategories[locale] = categories;
199
249
  }
200
250
  const { missing } = findMissingTranslationsByLocale(sourceFiles, targetFilesByLocale, { sourceLocale, outputLocales: targetLocales, localePluralCategories }, false, console, { ignoreMatcher });
201
- const sourceKeyMaps = sourceFiles.map((file) => ({ file, keys: withoutIgnored(sourceKeysFor(file, sourceLocale)) }));
251
+ const readSource = (file) => withoutIgnored(sourceKeysFor(file, sourceLocale));
252
+ const sourceKeyMaps = sourceFiles.map((file) => ({ file, keys: readSource(file) }));
202
253
  const totalSourceKeys = new Set();
203
254
  for (const { keys } of sourceKeyMaps) {
204
255
  for (const [key, value] of Object.entries(keys)) {
@@ -224,7 +275,7 @@ export async function runCheck(options = {}, deps = defaultDeps) {
224
275
  structureMismatches: [],
225
276
  pluralShapeMismatches: [],
226
277
  conflictingKeys: [],
227
- duplicateKeys: recovered.duplicates
278
+ duplicateKeys: files.duplicates
228
279
  .filter((d) => d.locale === locale)
229
280
  .map(({ key, path, values }) => ({ key, path, values })),
230
281
  missingFiles: []
@@ -280,17 +331,129 @@ export async function runCheck(options = {}, deps = defaultDeps) {
280
331
  }
281
332
  return report;
282
333
  });
283
- const failOn = options.failOn || 'missing';
284
- const exitCode = shouldFail(reports, failOn) || (parseFailures.length > 0 && failOn !== 'none') ? 1 : 0;
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
+ // Compared with the base, every finding left is one the pull request caused, so all of them fail by default.
415
+ const compared = changeStatus !== null && 'base' in changeStatus;
416
+ const failOn = options.failOn ?? (compared ? 'any' : 'missing');
417
+ // Without the diff, failing on every existing finding would block pull requests that did not cause them.
418
+ const findingsFail = !(changeStatus && 'error' in changeStatus) && shouldFail(reports.map((r) => filterFindings(r, isIntroduced)), failOn);
419
+ const exitCode = findingsFail || (parseFailures.length > 0 && failOn !== 'none') ? 1 : 0;
285
420
  return {
286
421
  aborted: false,
287
422
  exitCode,
288
423
  reports,
289
424
  sourceLocale,
290
425
  sourceFiles: sourceFiles.map((f) => f.path),
291
- keyCount: totalSourceKeys.size,
426
+ keyCount: current.keyCount,
292
427
  parseFailures,
293
- detected
428
+ detected,
429
+ format,
430
+ changes: changeStatus,
431
+ fileContents: rawContents(discovered.allFiles)
432
+ };
433
+ }
434
+ // Files rebuilt because of duplicate keys are left out: their lines no longer match the repo.
435
+ function rawContents(files) {
436
+ const contents = {};
437
+ for (const file of files) {
438
+ if (file.content && !(file.path in contents)) {
439
+ contents[file.path] = Buffer.from(file.content, 'base64').toString('utf8');
440
+ }
441
+ }
442
+ return contents;
443
+ }
444
+ function lineLocator(contents) {
445
+ const finders = new Map();
446
+ return (file, key, locale) => {
447
+ const content = contents[file];
448
+ if (content === undefined)
449
+ return undefined;
450
+ const cacheKey = `${file}\u0000${locale}`;
451
+ let find = finders.get(cacheKey);
452
+ if (!find) {
453
+ find = keyLineFinder(content, formatOf(file), locale);
454
+ finders.set(cacheKey, find);
455
+ }
456
+ return find(key) ?? undefined;
294
457
  };
295
458
  }
296
459
  function shouldFail(reports, failOn) {
@@ -341,14 +504,23 @@ function printHumanReport(con, reports, keyCount, all) {
341
504
  const totalPossible = keyCount * reports.length;
342
505
  const overallComplete = totalPossible === 0 ? 100 : Math.round(((totalPossible - totalMissing) / totalPossible) * 100);
343
506
  con.log(chalk.bold(`\n${reports.length} locale(s), ${keyCount} keys, ${overallComplete}% complete, ${totalPlaceholders} placeholder mismatches, ${totalOrphans} orphans`));
507
+ printFindings(con, reports, all);
508
+ }
509
+ function placeholderProblem(m) {
510
+ return [
511
+ m.missingInTarget.length ? `missing ${spellPlaceholders(m.missingInTarget, m.source).join(' ')}` : '',
512
+ m.unexpectedInTarget.length ? `unexpected ${spellPlaceholders(m.unexpectedInTarget, m.target).join(' ')}` : ''
513
+ ]
514
+ .filter(Boolean)
515
+ .join(', ');
516
+ }
517
+ function printFindings(con, reports, all) {
344
518
  for (const r of reports) {
345
519
  con.log(chalk.bold(`\n== ${r.locale} ==`));
346
520
  printList(con, 'Missing keys', r.missing, (m) => m.key, all);
347
521
  printList(con, 'Empty values', r.empty, (m) => `${m.key}: "${truncate(m.source)}" -> ""`, all);
348
- printList(con, 'Placeholder mismatches', r.placeholderMismatches, (m) => `${m.key}: "${truncate(m.source)}" -> "${truncate(m.target)}"` +
349
- (m.missingInTarget.length ? ` [missing: ${m.missingInTarget.join(', ')}]` : '') +
350
- (m.unexpectedInTarget.length ? ` [unexpected: ${m.unexpectedInTarget.join(', ')}]` : ''), all);
351
- printList(con, 'Placeholder hints (a plural form may leave out a placeholder)', r.placeholderHints, (m) => `${m.key}: "${truncate(m.source)}" -> "${truncate(m.target)}" [omits: ${m.missingInTarget.join(', ')}]`, all);
522
+ printList(con, 'Placeholder mismatches', r.placeholderMismatches, (m) => `${m.key}: "${truncate(m.source)}" -> "${truncate(m.target)}" [${placeholderProblem(m)}]`, all);
523
+ 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);
352
524
  printList(con, 'Orphan keys', r.orphans, (m) => `${m.key}: "${truncate(m.target ?? '')}"`, all);
353
525
  printList(con, 'Structure mismatches', r.structureMismatches, (m) => `${m.key}: source is ${m.sourceShape}, target is ${m.targetShape}`, all);
354
526
  printList(con, 'Plural shape mismatches', r.pluralShapeMismatches, (m) => m.key, all);
@@ -367,85 +539,185 @@ function escapeAnnotationProperty(text) {
367
539
  }
368
540
  // GitHub shows only a handful of annotations per run; thousands just bury the log.
369
541
  const MAX_ANNOTATIONS = 50;
370
- function printGithubAnnotations(con, reports) {
371
- const lines = [];
372
- const annotate = (level, file, message) => lines.push(`::${level} file=${escapeAnnotationProperty(file)}::${escapeAnnotationData(message)}`);
373
- // Parsed locale files carry no line numbers, so annotations are file-level.
542
+ // A missing translation points at the source key's line: in a pull request that
543
+ // added the key, that line is in the diff, so GitHub shows the annotation inline.
544
+ function annotationsFor(reports, sourceLocale, lineOf = () => undefined) {
545
+ const annotations = [];
374
546
  for (const r of reports) {
547
+ const annotate = (level, file, message, key) => annotations.push({
548
+ level,
549
+ locale: r.locale,
550
+ file,
551
+ line: key === undefined ? undefined : lineOf(file, key, r.locale),
552
+ message
553
+ });
375
554
  for (const m of r.missing) {
376
- annotate('error', m.targetPath, `Missing translation for "${m.key}" (locale ${r.locale})`);
555
+ annotations.push({
556
+ level: 'error',
557
+ locale: r.locale,
558
+ file: m.path,
559
+ line: lineOf(m.path, m.key, sourceLocale),
560
+ message: `Missing translation for "${m.key}" (locale ${r.locale})`,
561
+ targetFile: m.targetPath
562
+ });
377
563
  }
378
564
  for (const m of r.empty) {
379
- annotate('error', m.path, `Empty translation for "${m.key}" (locale ${r.locale})`);
565
+ annotate('error', m.path, `Empty translation for "${m.key}" (locale ${r.locale})`, m.key);
380
566
  }
381
567
  for (const m of r.placeholderMismatches) {
382
- annotate('error', m.path, `Placeholder mismatch for "${m.key}" (locale ${r.locale})`);
568
+ annotate('error', m.path, `Placeholder mismatch for "${m.key}" (locale ${r.locale}): ${placeholderProblem(m)}`, m.key);
383
569
  }
384
570
  for (const m of r.missingPluralCategories) {
385
- annotate('error', m.path, `"${m.key}" lacks plural forms ${m.missing.join(', ')} (locale ${r.locale})`);
571
+ annotate('error', m.path, `"${m.key}" lacks plural forms ${m.missing.join(', ')} (locale ${r.locale})`, m.key);
386
572
  }
387
573
  for (const m of r.placeholderHints) {
388
- annotate('notice', m.path, `Plural form omits ${m.missingInTarget.join(', ')} for "${m.key}" (locale ${r.locale})`);
574
+ const omitted = spellPlaceholders(m.missingInTarget, m.source).join(', ');
575
+ annotate('notice', m.path, `Plural form omits ${omitted} for "${m.key}" (locale ${r.locale})`, m.key);
389
576
  }
390
577
  for (const m of r.orphans) {
391
- annotate('warning', m.path, `Orphan key "${m.key}" (locale ${r.locale}) no longer in source`);
578
+ annotate('warning', m.path, `Orphan key "${m.key}" (locale ${r.locale}) no longer in source`, m.key);
392
579
  }
393
580
  for (const m of r.structureMismatches) {
394
- annotate('error', m.path, `Structure mismatch for "${m.key}" (locale ${r.locale})`);
581
+ annotate('error', m.path, `Structure mismatch for "${m.key}" (locale ${r.locale})`, m.key);
395
582
  }
396
583
  for (const m of r.pluralShapeMismatches) {
397
- annotate('error', m.path, `Plural shape mismatch for "${m.key}" (locale ${r.locale})`);
584
+ annotate('error', m.path, `Plural shape mismatch for "${m.key}" (locale ${r.locale})`, m.key);
398
585
  }
399
586
  for (const m of r.conflictingKeys) {
400
- annotate('error', m.files[0], `"${m.key}" has different values in ${m.files.join(', ')} (locale ${r.locale})`);
587
+ annotate('error', m.files[0], `"${m.key}" has different values in ${m.files.join(', ')} (locale ${r.locale})`, m.key);
401
588
  }
402
589
  for (const m of r.duplicateKeys) {
403
- annotate('warning', m.path, `"${m.key}" is defined ${m.values.length} times; the last value wins (locale ${r.locale})`);
590
+ annotate('warning', m.path, `"${m.key}" is defined ${m.values.length} times; the last value wins (locale ${r.locale})`, m.key);
404
591
  }
405
592
  }
593
+ return annotations;
594
+ }
595
+ function problemsIn(reports, sourceLocale) {
596
+ return annotationsFor(reports, sourceLocale).filter((a) => a.level !== 'notice');
597
+ }
598
+ function printGithubAnnotations(con, annotations) {
599
+ const lines = annotations.map(({ level, file, line, message }) => {
600
+ const where = line === undefined ? '' : `,line=${line}`;
601
+ return `::${level} file=${escapeAnnotationProperty(file)}${where}::${escapeAnnotationData(message)}`;
602
+ });
406
603
  for (const line of lines.slice(0, MAX_ANNOTATIONS))
407
604
  con.log(line);
408
605
  if (lines.length > MAX_ANNOTATIONS) {
409
606
  con.log(`::warning::${lines.length - MAX_ANNOTATIONS} more findings not shown. Run check --json for the full report.`);
410
607
  }
411
608
  }
609
+ function problemCounts(r) {
610
+ return {
611
+ missing: missingCount(r),
612
+ placeholders: r.placeholderMismatches.length,
613
+ plurals: r.missingPluralCategories.length + r.pluralShapeMismatches.length,
614
+ structure: r.structureMismatches.length,
615
+ conflicts: r.conflictingKeys.length + r.duplicateKeys.length,
616
+ orphans: r.orphans.length
617
+ };
618
+ }
619
+ function unavailableMessage(error) {
620
+ 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.`;
621
+ }
622
+ function existingProblemsLine(count) {
623
+ return `${plural(count, 'problem')} already on the base branch ${count === 1 ? 'is' : 'are'}`;
624
+ }
625
+ function changedOnlyJson(changes) {
626
+ if (changes === null)
627
+ return null;
628
+ if ('error' in changes)
629
+ return { base: null, diffAvailable: false, reason: changes.error };
630
+ return { base: changes.base, diffAvailable: true };
631
+ }
632
+ function printJsonReport(con, result) {
633
+ con.log(JSON.stringify({
634
+ sourceLocale: result.sourceLocale,
635
+ sourceFiles: result.sourceFiles,
636
+ keyCount: result.keyCount,
637
+ parseFailures: result.parseFailures,
638
+ detected: result.detected,
639
+ changedOnly: changedOnlyJson(result.changes),
640
+ locales: result.reports.map((r) => ({
641
+ locale: r.locale,
642
+ files: r.files,
643
+ keyCount: r.keyCount,
644
+ missing: r.missing,
645
+ empty: r.empty,
646
+ identical: r.identical,
647
+ placeholderMismatches: r.placeholderMismatches,
648
+ placeholderHints: r.placeholderHints,
649
+ missingPluralCategories: r.missingPluralCategories,
650
+ orphans: r.orphans,
651
+ structureMismatches: r.structureMismatches,
652
+ pluralShapeMismatches: r.pluralShapeMismatches,
653
+ conflictingKeys: r.conflictingKeys,
654
+ duplicateKeys: r.duplicateKeys,
655
+ missingFiles: r.missingFiles
656
+ }))
657
+ }));
658
+ }
659
+ function countedInSummary(changes, reports) {
660
+ if (changes === null)
661
+ return [];
662
+ if ('error' in changes)
663
+ return reports;
664
+ return reports.map((r) => filterFindings(r, isExisting));
665
+ }
666
+ function writeStepSummary(deps, path, changes, reports, sourceLocale) {
667
+ const listed = changes !== null && 'error' in changes ? [] : reports.map((r) => filterFindings(r, isIntroduced));
668
+ const markdown = buildStepSummary({
669
+ changes,
670
+ problems: problemsIn(listed, sourceLocale).map((a) => ({ ...a, file: a.targetFile ?? a.file })),
671
+ counts: countedInSummary(changes, reports).map((r) => ({ locale: r.locale, counts: problemCounts(r) })),
672
+ missingTranslations: reports.some((r) => missingCount(r) > 0)
673
+ });
674
+ try {
675
+ deps.appendFile(path, `${markdown}\n`);
676
+ }
677
+ catch (error) {
678
+ deps.console.error(chalk.yellow(`⚠ Could not write the job summary: ${error.message}`));
679
+ }
680
+ }
412
681
  export async function check(options = {}, deps = defaultDeps) {
413
682
  const con = deps.console;
414
- const { aborted, exitCode, reports, sourceLocale, sourceFiles, keyCount, parseFailures, detected } = await runCheck(options, deps);
415
- process.exitCode = exitCode;
416
- if (aborted)
683
+ const result = await runCheck(options, deps);
684
+ const { reports, sourceLocale, keyCount, format, changes, fileContents } = result;
685
+ process.exitCode = result.exitCode;
686
+ if (result.aborted)
417
687
  return;
418
- if (options.json) {
419
- con.log(JSON.stringify({
420
- sourceLocale,
421
- sourceFiles,
422
- keyCount,
423
- parseFailures,
424
- detected,
425
- locales: reports.map((r) => ({
426
- locale: r.locale,
427
- files: r.files,
428
- keyCount: r.keyCount,
429
- missing: r.missing,
430
- empty: r.empty,
431
- identical: r.identical,
432
- placeholderMismatches: r.placeholderMismatches,
433
- placeholderHints: r.placeholderHints,
434
- missingPluralCategories: r.missingPluralCategories,
435
- orphans: r.orphans,
436
- structureMismatches: r.structureMismatches,
437
- pluralShapeMismatches: r.pluralShapeMismatches,
438
- conflictingKeys: r.conflictingKeys,
439
- duplicateKeys: r.duplicateKeys,
440
- missingFiles: r.missingFiles
441
- }))
442
- }));
688
+ const unavailable = changes !== null && 'error' in changes ? changes.error : null;
689
+ const base = changes !== null && 'base' in changes ? changes.base : null;
690
+ const introduced = reports.map((r) => filterFindings(r, isIntroduced));
691
+ const existingProblems = base ? problemsIn(reports.map((r) => filterFindings(r, isExisting)), sourceLocale).length : 0;
692
+ const summaryPath = detectCiContext(deps.env).stepSummaryPath;
693
+ if (format === 'json') {
694
+ if (unavailable)
695
+ con.error(chalk.yellow(`⚠ ${unavailableMessage(unavailable)}`));
696
+ printJsonReport(con, result);
697
+ }
698
+ else if (format === 'github') {
699
+ if (unavailable) {
700
+ con.log(`::warning::${escapeAnnotationData(unavailableMessage(unavailable))}`);
701
+ }
702
+ else {
703
+ printGithubAnnotations(con, annotationsFor(introduced, sourceLocale, lineLocator(fileContents)));
704
+ }
705
+ if (base) {
706
+ const where = summaryPath ? 'counted in the job summary' : 'not listed; run with --full to see them';
707
+ con.log(`Compared with ${base}. ${existingProblemsLine(existingProblems)} ${where}.`);
708
+ }
443
709
  }
444
- else if (options.format === 'github') {
445
- printGithubAnnotations(con, reports);
710
+ else if (base) {
711
+ con.log(chalk.blue(`ℹ Compared with ${base}. Only new problems are listed.`));
712
+ printFindings(con, introduced, Boolean(options.all));
713
+ con.log(`\n${existingProblemsLine(existingProblems)} not listed. Run with --full to see them.`);
446
714
  }
447
715
  else {
716
+ if (unavailable)
717
+ con.log(chalk.yellow(`⚠ ${unavailableMessage(unavailable)}`));
448
718
  printHumanReport(con, reports, keyCount, Boolean(options.all));
449
719
  }
720
+ if (summaryPath)
721
+ writeStepSummary(deps, summaryPath, changes, reports, sourceLocale);
450
722
  }
451
723
  //# sourceMappingURL=check.js.map