@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.
- package/README.md +21 -4
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +389 -119
- package/dist/commands/check.js.map +1 -1
- package/dist/utils/check-changes.js +48 -0
- package/dist/utils/check-changes.js.map +1 -0
- package/dist/utils/check-git.js +93 -0
- package/dist/utils/check-git.js.map +1 -0
- package/dist/utils/check-summary.js +71 -0
- package/dist/utils/check-summary.js.map +1 -0
- package/dist/utils/ci-context.js +10 -0
- package/dist/utils/ci-context.js.map +1 -0
- package/dist/utils/key-lines.js +221 -0
- package/dist/utils/key-lines.js.map +1 -0
- package/dist/utils/placeholders.js +27 -2
- package/dist/utils/placeholders.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/check.js
CHANGED
|
@@ -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
|
-
|
|
132
|
-
const {
|
|
133
|
-
const
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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 (
|
|
445
|
-
|
|
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
|