@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.
- package/README.md +21 -4
- package/dist/cli.js +4 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +392 -120
- 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,129 @@ export async function runCheck(options = {}, deps = defaultDeps) {
|
|
|
280
331
|
}
|
|
281
332
|
return report;
|
|
282
333
|
});
|
|
283
|
-
|
|
284
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
}));
|
|
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 (
|
|
445
|
-
|
|
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
|