@localheroai/cli 0.0.74-rc.1 → 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.
@@ -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,127 @@ export async function runCheck(options = {}, deps = defaultDeps) {
280
331
  }
281
332
  return report;
282
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 };
283
414
  const failOn = options.failOn || 'missing';
284
- const exitCode = shouldFail(reports, failOn) || (parseFailures.length > 0 && failOn !== 'none') ? 1 : 0;
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;
285
418
  return {
286
419
  aborted: false,
287
420
  exitCode,
288
421
  reports,
289
422
  sourceLocale,
290
423
  sourceFiles: sourceFiles.map((f) => f.path),
291
- keyCount: totalSourceKeys.size,
424
+ keyCount: current.keyCount,
292
425
  parseFailures,
293
- detected
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;
294
455
  };
295
456
  }
296
457
  function shouldFail(reports, failOn) {
@@ -341,14 +502,23 @@ function printHumanReport(con, reports, keyCount, all) {
341
502
  const totalPossible = keyCount * reports.length;
342
503
  const overallComplete = totalPossible === 0 ? 100 : Math.round(((totalPossible - totalMissing) / totalPossible) * 100);
343
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) {
344
516
  for (const r of reports) {
345
517
  con.log(chalk.bold(`\n== ${r.locale} ==`));
346
518
  printList(con, 'Missing keys', r.missing, (m) => m.key, all);
347
519
  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);
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);
352
522
  printList(con, 'Orphan keys', r.orphans, (m) => `${m.key}: "${truncate(m.target ?? '')}"`, all);
353
523
  printList(con, 'Structure mismatches', r.structureMismatches, (m) => `${m.key}: source is ${m.sourceShape}, target is ${m.targetShape}`, all);
354
524
  printList(con, 'Plural shape mismatches', r.pluralShapeMismatches, (m) => m.key, all);
@@ -367,85 +537,185 @@ function escapeAnnotationProperty(text) {
367
537
  }
368
538
  // GitHub shows only a handful of annotations per run; thousands just bury the log.
369
539
  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.
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 = [];
374
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
+ });
375
552
  for (const m of r.missing) {
376
- annotate('error', m.targetPath, `Missing translation for "${m.key}" (locale ${r.locale})`);
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
+ });
377
561
  }
378
562
  for (const m of r.empty) {
379
- annotate('error', m.path, `Empty translation for "${m.key}" (locale ${r.locale})`);
563
+ annotate('error', m.path, `Empty translation for "${m.key}" (locale ${r.locale})`, m.key);
380
564
  }
381
565
  for (const m of r.placeholderMismatches) {
382
- annotate('error', m.path, `Placeholder mismatch for "${m.key}" (locale ${r.locale})`);
566
+ annotate('error', m.path, `Placeholder mismatch for "${m.key}" (locale ${r.locale}): ${placeholderProblem(m)}`, m.key);
383
567
  }
384
568
  for (const m of r.missingPluralCategories) {
385
- annotate('error', m.path, `"${m.key}" lacks plural forms ${m.missing.join(', ')} (locale ${r.locale})`);
569
+ annotate('error', m.path, `"${m.key}" lacks plural forms ${m.missing.join(', ')} (locale ${r.locale})`, m.key);
386
570
  }
387
571
  for (const m of r.placeholderHints) {
388
- annotate('notice', m.path, `Plural form omits ${m.missingInTarget.join(', ')} for "${m.key}" (locale ${r.locale})`);
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);
389
574
  }
390
575
  for (const m of r.orphans) {
391
- annotate('warning', m.path, `Orphan key "${m.key}" (locale ${r.locale}) no longer in source`);
576
+ annotate('warning', m.path, `Orphan key "${m.key}" (locale ${r.locale}) no longer in source`, m.key);
392
577
  }
393
578
  for (const m of r.structureMismatches) {
394
- annotate('error', m.path, `Structure mismatch for "${m.key}" (locale ${r.locale})`);
579
+ annotate('error', m.path, `Structure mismatch for "${m.key}" (locale ${r.locale})`, m.key);
395
580
  }
396
581
  for (const m of r.pluralShapeMismatches) {
397
- annotate('error', m.path, `Plural shape mismatch for "${m.key}" (locale ${r.locale})`);
582
+ annotate('error', m.path, `Plural shape mismatch for "${m.key}" (locale ${r.locale})`, m.key);
398
583
  }
399
584
  for (const m of r.conflictingKeys) {
400
- annotate('error', m.files[0], `"${m.key}" has different values in ${m.files.join(', ')} (locale ${r.locale})`);
585
+ annotate('error', m.files[0], `"${m.key}" has different values in ${m.files.join(', ')} (locale ${r.locale})`, m.key);
401
586
  }
402
587
  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})`);
588
+ annotate('warning', m.path, `"${m.key}" is defined ${m.values.length} times; the last value wins (locale ${r.locale})`, m.key);
404
589
  }
405
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
+ });
406
601
  for (const line of lines.slice(0, MAX_ANNOTATIONS))
407
602
  con.log(line);
408
603
  if (lines.length > MAX_ANNOTATIONS) {
409
604
  con.log(`::warning::${lines.length - MAX_ANNOTATIONS} more findings not shown. Run check --json for the full report.`);
410
605
  }
411
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
+ }
412
679
  export async function check(options = {}, deps = defaultDeps) {
413
680
  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)
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)
417
685
  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
- }));
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
+ }
443
707
  }
444
- else if (options.format === 'github') {
445
- printGithubAnnotations(con, reports);
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.`);
446
712
  }
447
713
  else {
714
+ if (unavailable)
715
+ con.log(chalk.yellow(`⚠ ${unavailableMessage(unavailable)}`));
448
716
  printHumanReport(con, reports, keyCount, Boolean(options.all));
449
717
  }
718
+ if (summaryPath)
719
+ writeStepSummary(deps, summaryPath, changes, reports, sourceLocale);
450
720
  }
451
721
  //# sourceMappingURL=check.js.map