@hagicode/hagi18n 0.1.0-dev.1.1.45d0d2f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,679 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import yaml from "js-yaml";
4
+ import { DEFAULT_BASE_LOCALE, resolveHagi18nConfig } from "./config.js";
5
+ const PLACEHOLDER_PATTERN = /{{\s*[^}]+\s*}}/g;
6
+ const PROTECTED_TOKEN_PATTERN = /[\uE000\uE001]/u;
7
+ const LOCALE_ALIAS_MAP = new Map([
8
+ ["zh", "zh-CN"],
9
+ ["zh-cn", "zh-CN"],
10
+ ["en", "en-US"],
11
+ ["en-us", "en-US"]
12
+ ]);
13
+ function isPlainObject(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+ function cloneValue(value) {
17
+ return structuredClone(value);
18
+ }
19
+ function compareValues(left, right) {
20
+ return JSON.stringify(left) === JSON.stringify(right);
21
+ }
22
+ export function normalizeLocaleName(value) {
23
+ if (typeof value !== "string") {
24
+ return null;
25
+ }
26
+ const trimmed = value.trim();
27
+ if (!trimmed) {
28
+ return null;
29
+ }
30
+ return LOCALE_ALIAS_MAP.get(trimmed.toLowerCase()) ?? trimmed;
31
+ }
32
+ function resolveExistingLocale(locales, requestedLocale) {
33
+ const normalizedRequestedLocale = normalizeLocaleName(requestedLocale);
34
+ if (!normalizedRequestedLocale) {
35
+ return null;
36
+ }
37
+ const exactMatch = locales.find((locale) => locale === normalizedRequestedLocale);
38
+ if (exactMatch) {
39
+ return exactMatch;
40
+ }
41
+ return (locales.find((locale) => locale.toLowerCase() === normalizedRequestedLocale.toLowerCase()) ?? null);
42
+ }
43
+ function resolveTargetLocales(locales, baseLocale, targetLocales, includeBaseLocale = false) {
44
+ if (!targetLocales || targetLocales.length === 0) {
45
+ return includeBaseLocale
46
+ ? [...locales]
47
+ : locales.filter((locale) => locale !== baseLocale);
48
+ }
49
+ const resolvedTargets = [];
50
+ for (const targetLocale of targetLocales) {
51
+ const resolvedLocale = resolveExistingLocale(locales, targetLocale);
52
+ if (!resolvedLocale) {
53
+ throw new Error(`Target locale '${targetLocale}' was not found. Available locales: ${locales.join(", ")}`);
54
+ }
55
+ if (!includeBaseLocale && resolvedLocale === baseLocale) {
56
+ continue;
57
+ }
58
+ if (!resolvedTargets.includes(resolvedLocale)) {
59
+ resolvedTargets.push(resolvedLocale);
60
+ }
61
+ }
62
+ return resolvedTargets;
63
+ }
64
+ function isDoctorRuleAllowed(allowlist, ruleId, relativePath) {
65
+ return allowlist[ruleId]?.includes(relativePath) ?? false;
66
+ }
67
+ export async function listLocaleDirectories(localesRoot) {
68
+ const entries = await fs.readdir(localesRoot, { withFileTypes: true });
69
+ return entries
70
+ .filter((entry) => entry.isDirectory())
71
+ .map((entry) => entry.name)
72
+ .sort((left, right) => left.localeCompare(right));
73
+ }
74
+ export async function walkYamlFiles(directory, prefix = "") {
75
+ const entries = await fs.readdir(directory, { withFileTypes: true });
76
+ const files = [];
77
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
78
+ const relativePath = prefix ? path.posix.join(prefix, entry.name) : entry.name;
79
+ const absolutePath = path.join(directory, entry.name);
80
+ if (entry.isDirectory()) {
81
+ files.push(...(await walkYamlFiles(absolutePath, relativePath)));
82
+ continue;
83
+ }
84
+ if (entry.isFile() && (entry.name.endsWith(".yml") || entry.name.endsWith(".yaml"))) {
85
+ files.push(relativePath);
86
+ }
87
+ }
88
+ return files;
89
+ }
90
+ export async function readYamlLocaleFile(localesRoot, locale, relativeFilePath) {
91
+ const absolutePath = path.join(localesRoot, locale, relativeFilePath);
92
+ const raw = await fs.readFile(absolutePath, "utf8");
93
+ const parsed = yaml.load(raw);
94
+ if (parsed === undefined) {
95
+ return {
96
+ absolutePath,
97
+ raw,
98
+ data: {}
99
+ };
100
+ }
101
+ if (!isPlainObject(parsed)) {
102
+ throw new Error(`Top-level YAML document must be a mapping object: ${relativeFilePath}`);
103
+ }
104
+ return {
105
+ absolutePath,
106
+ raw,
107
+ data: parsed
108
+ };
109
+ }
110
+ export function collectScalarPaths(value, prefix = [], output = []) {
111
+ if (Array.isArray(value)) {
112
+ value.forEach((item, index) => collectScalarPaths(item, [...prefix, String(index)], output));
113
+ return output;
114
+ }
115
+ if (isPlainObject(value)) {
116
+ for (const [key, child] of Object.entries(value)) {
117
+ collectScalarPaths(child, [...prefix, key], output);
118
+ }
119
+ return output;
120
+ }
121
+ output.push(prefix.join("."));
122
+ return output;
123
+ }
124
+ export function collectPlaceholders(value, prefix = [], output = new Map()) {
125
+ if (typeof value === "string") {
126
+ const key = prefix.join(".");
127
+ const matches = [...value.matchAll(PLACEHOLDER_PATTERN)]
128
+ .map((match) => match[0])
129
+ .sort((left, right) => left.localeCompare(right));
130
+ output.set(key, matches);
131
+ return output;
132
+ }
133
+ if (Array.isArray(value)) {
134
+ value.forEach((item, index) => collectPlaceholders(item, [...prefix, String(index)], output));
135
+ return output;
136
+ }
137
+ if (isPlainObject(value)) {
138
+ for (const [key, child] of Object.entries(value)) {
139
+ collectPlaceholders(child, [...prefix, key], output);
140
+ }
141
+ }
142
+ return output;
143
+ }
144
+ export function difference(source, other) {
145
+ const otherSet = new Set(other);
146
+ return source.filter((item) => !otherSet.has(item));
147
+ }
148
+ export function collectPlaceholderDifferences(basePlaceholders, currentPlaceholders) {
149
+ const differences = [];
150
+ for (const [pathKey, expectedPlaceholders] of basePlaceholders.entries()) {
151
+ const actualPlaceholders = currentPlaceholders.get(pathKey) ?? [];
152
+ if (expectedPlaceholders.length !== actualPlaceholders.length) {
153
+ differences.push({
154
+ path: pathKey,
155
+ expected: expectedPlaceholders,
156
+ actual: actualPlaceholders
157
+ });
158
+ continue;
159
+ }
160
+ const isSame = expectedPlaceholders.every((placeholder, index) => placeholder === actualPlaceholders[index]);
161
+ if (!isSame) {
162
+ differences.push({
163
+ path: pathKey,
164
+ expected: expectedPlaceholders,
165
+ actual: actualPlaceholders
166
+ });
167
+ }
168
+ }
169
+ return differences;
170
+ }
171
+ export function createAuditResult(locale) {
172
+ return {
173
+ locale,
174
+ missingFiles: [],
175
+ extraFiles: [],
176
+ filesWithProtectedTokens: [],
177
+ parseErrors: [],
178
+ missingKeys: [],
179
+ extraKeys: [],
180
+ placeholderMismatches: []
181
+ };
182
+ }
183
+ export function auditHasIssues(result) {
184
+ return (result.missingFiles.length > 0 ||
185
+ result.extraFiles.length > 0 ||
186
+ result.filesWithProtectedTokens.length > 0 ||
187
+ result.parseErrors.length > 0 ||
188
+ result.missingKeys.length > 0 ||
189
+ result.extraKeys.length > 0 ||
190
+ result.placeholderMismatches.length > 0);
191
+ }
192
+ async function resolveAuditInputs(options) {
193
+ const resolvedConfig = await resolveHagi18nConfig(options);
194
+ const locales = await listLocaleDirectories(resolvedConfig.localesRoot);
195
+ if (locales.length === 0) {
196
+ throw new Error(`No locale directories found under ${resolvedConfig.localesRoot}`);
197
+ }
198
+ const resolvedBaseLocale = resolveExistingLocale(locales, options.baseLocale ?? resolvedConfig.baseLocale ?? DEFAULT_BASE_LOCALE);
199
+ if (!resolvedBaseLocale) {
200
+ throw new Error(`Base locale '${options.baseLocale ?? resolvedConfig.baseLocale}' was not found. Available locales: ${locales.join(", ")}`);
201
+ }
202
+ const selectedLocales = resolveTargetLocales(locales, resolvedBaseLocale, options.targetLocales ?? resolvedConfig.targetLocales, true);
203
+ return {
204
+ config: resolvedConfig,
205
+ locales,
206
+ resolvedBaseLocale,
207
+ selectedLocales
208
+ };
209
+ }
210
+ export async function auditLocaleTree(options = {}) {
211
+ const { config, locales, resolvedBaseLocale, selectedLocales } = await resolveAuditInputs(options);
212
+ const baseFiles = await walkYamlFiles(path.join(config.localesRoot, resolvedBaseLocale));
213
+ const results = [];
214
+ for (const locale of selectedLocales) {
215
+ const result = createAuditResult(locale);
216
+ const localeDirectory = path.join(config.localesRoot, locale);
217
+ const localeFiles = await walkYamlFiles(localeDirectory);
218
+ result.missingFiles = difference(baseFiles, localeFiles);
219
+ result.extraFiles = difference(localeFiles, baseFiles);
220
+ const comparableFiles = baseFiles.filter((file) => localeFiles.includes(file));
221
+ for (const relativeFilePath of comparableFiles) {
222
+ try {
223
+ const baseDocument = await readYamlLocaleFile(config.localesRoot, resolvedBaseLocale, relativeFilePath);
224
+ const currentDocument = await readYamlLocaleFile(config.localesRoot, locale, relativeFilePath);
225
+ if (PROTECTED_TOKEN_PATTERN.test(currentDocument.raw)) {
226
+ result.filesWithProtectedTokens.push(relativeFilePath);
227
+ }
228
+ const baseScalarPaths = collectScalarPaths(baseDocument.data).sort((left, right) => left.localeCompare(right));
229
+ const currentScalarPaths = collectScalarPaths(currentDocument.data).sort((left, right) => left.localeCompare(right));
230
+ for (const missingPath of difference(baseScalarPaths, currentScalarPaths)) {
231
+ result.missingKeys.push({ file: relativeFilePath, path: missingPath });
232
+ }
233
+ for (const extraPath of difference(currentScalarPaths, baseScalarPaths)) {
234
+ result.extraKeys.push({ file: relativeFilePath, path: extraPath });
235
+ }
236
+ const placeholderDifferences = collectPlaceholderDifferences(collectPlaceholders(baseDocument.data), collectPlaceholders(currentDocument.data));
237
+ for (const differenceItem of placeholderDifferences) {
238
+ result.placeholderMismatches.push({
239
+ file: relativeFilePath,
240
+ ...differenceItem
241
+ });
242
+ }
243
+ }
244
+ catch (error) {
245
+ result.parseErrors.push({
246
+ file: relativeFilePath,
247
+ message: error instanceof Error ? error.message : String(error)
248
+ });
249
+ }
250
+ }
251
+ results.push(result);
252
+ }
253
+ return {
254
+ localesRoot: config.localesRoot,
255
+ baseLocale: resolvedBaseLocale,
256
+ locales: selectedLocales,
257
+ allLocales: locales,
258
+ baseFileCount: baseFiles.length,
259
+ results,
260
+ hasIssues: results.some(auditHasIssues)
261
+ };
262
+ }
263
+ function syncNode(baseValue, targetValue, pathSegments = [], addedPaths = []) {
264
+ if (Array.isArray(baseValue)) {
265
+ if (!Array.isArray(targetValue)) {
266
+ addedPaths.push(pathSegments.join("."));
267
+ return cloneValue(baseValue);
268
+ }
269
+ const result = [];
270
+ for (let index = 0; index < baseValue.length; index += 1) {
271
+ const childPath = [...pathSegments, String(index)];
272
+ if (index < targetValue.length) {
273
+ result[index] = syncNode(baseValue[index], targetValue[index], childPath, addedPaths);
274
+ }
275
+ else {
276
+ addedPaths.push(childPath.join("."));
277
+ result[index] = cloneValue(baseValue[index]);
278
+ }
279
+ }
280
+ for (let index = baseValue.length; index < targetValue.length; index += 1) {
281
+ result[index] = cloneValue(targetValue[index]);
282
+ }
283
+ return result;
284
+ }
285
+ if (isPlainObject(baseValue)) {
286
+ if (!isPlainObject(targetValue)) {
287
+ addedPaths.push(pathSegments.join("."));
288
+ return cloneValue(baseValue);
289
+ }
290
+ const result = {};
291
+ for (const [key, childBaseValue] of Object.entries(baseValue)) {
292
+ const childPath = [...pathSegments, key];
293
+ if (Object.hasOwn(targetValue, key)) {
294
+ result[key] = syncNode(childBaseValue, targetValue[key], childPath, addedPaths);
295
+ }
296
+ else {
297
+ addedPaths.push(childPath.join("."));
298
+ result[key] = cloneValue(childBaseValue);
299
+ }
300
+ }
301
+ for (const [key, childTargetValue] of Object.entries(targetValue)) {
302
+ if (!Object.hasOwn(baseValue, key)) {
303
+ result[key] = cloneValue(childTargetValue);
304
+ }
305
+ }
306
+ return result;
307
+ }
308
+ return targetValue === undefined ? cloneValue(baseValue) : cloneValue(targetValue);
309
+ }
310
+ function pruneNode(baseValue, targetValue, pathSegments = [], removedPaths = []) {
311
+ if (Array.isArray(baseValue)) {
312
+ if (!Array.isArray(targetValue)) {
313
+ return cloneValue(targetValue);
314
+ }
315
+ const result = [];
316
+ const sharedLength = Math.min(baseValue.length, targetValue.length);
317
+ for (let index = 0; index < sharedLength; index += 1) {
318
+ result[index] = pruneNode(baseValue[index], targetValue[index], [...pathSegments, String(index)], removedPaths);
319
+ }
320
+ for (let index = sharedLength; index < targetValue.length; index += 1) {
321
+ removedPaths.push([...pathSegments, String(index)].join("."));
322
+ }
323
+ return result;
324
+ }
325
+ if (isPlainObject(baseValue)) {
326
+ if (!isPlainObject(targetValue)) {
327
+ return cloneValue(targetValue);
328
+ }
329
+ const result = {};
330
+ for (const [key, childTargetValue] of Object.entries(targetValue)) {
331
+ const childPath = [...pathSegments, key];
332
+ if (Object.hasOwn(baseValue, key)) {
333
+ result[key] = pruneNode(baseValue[key], childTargetValue, childPath, removedPaths);
334
+ }
335
+ else {
336
+ removedPaths.push(childPath.join("."));
337
+ }
338
+ }
339
+ return result;
340
+ }
341
+ return cloneValue(targetValue);
342
+ }
343
+ function dumpYamlDocument(value) {
344
+ return `${yaml.dump(value, {
345
+ lineWidth: -1,
346
+ noRefs: true,
347
+ sortKeys: false
348
+ })}`;
349
+ }
350
+ async function writeYamlLocaleFile(absolutePath, value) {
351
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
352
+ await fs.writeFile(absolutePath, dumpYamlDocument(value), "utf8");
353
+ }
354
+ function createMutationSummary(command, localesRoot, baseLocale, targetLocales, dryRun) {
355
+ return {
356
+ command,
357
+ localesRoot,
358
+ baseLocale,
359
+ targetLocales,
360
+ dryRun,
361
+ changedFiles: [],
362
+ removedFiles: [],
363
+ parseErrors: [],
364
+ totals: {
365
+ createdFiles: 0,
366
+ updatedFiles: 0,
367
+ removedFiles: 0,
368
+ addedPaths: 0,
369
+ removedPaths: 0
370
+ },
371
+ hasIssues: false
372
+ };
373
+ }
374
+ async function resolveMutationInputs(options, command) {
375
+ const config = await resolveHagi18nConfig(options);
376
+ const locales = await listLocaleDirectories(config.localesRoot);
377
+ const resolvedBaseLocale = resolveExistingLocale(locales, options.baseLocale ?? config.baseLocale);
378
+ if (!resolvedBaseLocale) {
379
+ throw new Error(`Base locale '${options.baseLocale ?? config.baseLocale}' was not found. Available locales: ${locales.join(", ")}`);
380
+ }
381
+ const resolvedTargets = resolveTargetLocales(locales, resolvedBaseLocale, options.targetLocales ?? config.targetLocales);
382
+ return {
383
+ config,
384
+ resolvedBaseLocale,
385
+ resolvedTargets,
386
+ summary: createMutationSummary(command, config.localesRoot, resolvedBaseLocale, resolvedTargets, options.dryRun ?? true)
387
+ };
388
+ }
389
+ export async function syncLocaleTree(options = {}) {
390
+ const { config, resolvedBaseLocale, resolvedTargets, summary } = await resolveMutationInputs(options, "sync");
391
+ const baseFiles = await walkYamlFiles(path.join(config.localesRoot, resolvedBaseLocale));
392
+ for (const locale of resolvedTargets) {
393
+ for (const relativeFilePath of baseFiles) {
394
+ const absoluteTargetPath = path.join(config.localesRoot, locale, relativeFilePath);
395
+ try {
396
+ const baseDocument = await readYamlLocaleFile(config.localesRoot, resolvedBaseLocale, relativeFilePath);
397
+ let targetDocument = null;
398
+ try {
399
+ targetDocument = await readYamlLocaleFile(config.localesRoot, locale, relativeFilePath);
400
+ }
401
+ catch (error) {
402
+ if ((error &&
403
+ typeof error === "object" &&
404
+ "code" in error &&
405
+ error.code === "ENOENT") ||
406
+ String(error).includes("ENOENT")) {
407
+ targetDocument = null;
408
+ }
409
+ else {
410
+ throw error;
411
+ }
412
+ }
413
+ if (!targetDocument) {
414
+ summary.changedFiles.push({
415
+ locale,
416
+ file: relativeFilePath,
417
+ action: "create",
418
+ addedPaths: ["<entire-file>"]
419
+ });
420
+ summary.totals.createdFiles += 1;
421
+ summary.totals.addedPaths += 1;
422
+ if (!summary.dryRun) {
423
+ await writeYamlLocaleFile(absoluteTargetPath, baseDocument.data);
424
+ }
425
+ continue;
426
+ }
427
+ const addedPaths = [];
428
+ const merged = syncNode(baseDocument.data, targetDocument.data, [], addedPaths);
429
+ if (addedPaths.length === 0 || compareValues(merged, targetDocument.data)) {
430
+ continue;
431
+ }
432
+ summary.changedFiles.push({
433
+ locale,
434
+ file: relativeFilePath,
435
+ action: "update",
436
+ addedPaths
437
+ });
438
+ summary.totals.updatedFiles += 1;
439
+ summary.totals.addedPaths += addedPaths.length;
440
+ if (!summary.dryRun) {
441
+ await writeYamlLocaleFile(absoluteTargetPath, merged);
442
+ }
443
+ }
444
+ catch (error) {
445
+ summary.parseErrors.push({
446
+ locale,
447
+ file: relativeFilePath,
448
+ message: error instanceof Error ? error.message : String(error)
449
+ });
450
+ }
451
+ }
452
+ }
453
+ summary.hasIssues = summary.parseErrors.length > 0;
454
+ return summary;
455
+ }
456
+ export async function pruneLocaleTree(options = {}) {
457
+ const { config, resolvedBaseLocale, resolvedTargets, summary } = await resolveMutationInputs(options, "prune");
458
+ const baseFiles = await walkYamlFiles(path.join(config.localesRoot, resolvedBaseLocale));
459
+ for (const locale of resolvedTargets) {
460
+ const localeRoot = path.join(config.localesRoot, locale);
461
+ const localeFiles = await walkYamlFiles(localeRoot);
462
+ const extraFiles = difference(localeFiles, baseFiles);
463
+ for (const relativeFilePath of extraFiles) {
464
+ summary.removedFiles.push({
465
+ locale,
466
+ file: relativeFilePath,
467
+ action: "remove-file"
468
+ });
469
+ summary.totals.removedFiles += 1;
470
+ if (!summary.dryRun) {
471
+ await fs.rm(path.join(localeRoot, relativeFilePath), { force: true });
472
+ }
473
+ }
474
+ const comparableFiles = baseFiles.filter((file) => localeFiles.includes(file));
475
+ for (const relativeFilePath of comparableFiles) {
476
+ try {
477
+ const baseDocument = await readYamlLocaleFile(config.localesRoot, resolvedBaseLocale, relativeFilePath);
478
+ const targetDocument = await readYamlLocaleFile(config.localesRoot, locale, relativeFilePath);
479
+ const removedPaths = [];
480
+ const pruned = pruneNode(baseDocument.data, targetDocument.data, [], removedPaths);
481
+ if (removedPaths.length === 0 || compareValues(pruned, targetDocument.data)) {
482
+ continue;
483
+ }
484
+ summary.changedFiles.push({
485
+ locale,
486
+ file: relativeFilePath,
487
+ action: "update",
488
+ removedPaths
489
+ });
490
+ summary.totals.updatedFiles += 1;
491
+ summary.totals.removedPaths += removedPaths.length;
492
+ if (!summary.dryRun) {
493
+ await writeYamlLocaleFile(path.join(config.localesRoot, locale, relativeFilePath), pruned);
494
+ }
495
+ }
496
+ catch (error) {
497
+ summary.parseErrors.push({
498
+ locale,
499
+ file: relativeFilePath,
500
+ message: error instanceof Error ? error.message : String(error)
501
+ });
502
+ }
503
+ }
504
+ }
505
+ summary.hasIssues = summary.parseErrors.length > 0;
506
+ return summary;
507
+ }
508
+ async function walkRepositoryFiles(rootDirectory, excludedDirectories, textExtensions, excludedPathPrefixes, prefix = "") {
509
+ const entries = await fs.readdir(rootDirectory, { withFileTypes: true });
510
+ const files = [];
511
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
512
+ const relativePath = prefix ? path.posix.join(prefix, entry.name) : entry.name;
513
+ const absolutePath = path.join(rootDirectory, entry.name);
514
+ if (entry.isDirectory()) {
515
+ if (excludedDirectories.has(entry.name)) {
516
+ continue;
517
+ }
518
+ files.push(...(await walkRepositoryFiles(absolutePath, excludedDirectories, textExtensions, excludedPathPrefixes, relativePath)));
519
+ continue;
520
+ }
521
+ if (!entry.isFile()) {
522
+ continue;
523
+ }
524
+ if (excludedPathPrefixes.some((pathPrefix) => relativePath.startsWith(pathPrefix))) {
525
+ continue;
526
+ }
527
+ if (textExtensions.has(path.extname(entry.name))) {
528
+ files.push(relativePath);
529
+ }
530
+ }
531
+ return files;
532
+ }
533
+ async function scanRepositoryForLegacyLocaleReferences(repoRoot, scanRules, allowlist, excludedDirectories, textFileExtensions, excludedPathPrefixes) {
534
+ const files = await walkRepositoryFiles(repoRoot, new Set(excludedDirectories), new Set(textFileExtensions), excludedPathPrefixes);
535
+ const issues = [];
536
+ for (const relativePath of files) {
537
+ const absolutePath = path.join(repoRoot, relativePath);
538
+ const sourceText = await fs.readFile(absolutePath, "utf8");
539
+ const lines = sourceText.split(/\r?\n/u);
540
+ lines.forEach((line, index) => {
541
+ for (const rule of scanRules) {
542
+ rule.regex.lastIndex = 0;
543
+ if (!rule.regex.test(line) || isDoctorRuleAllowed(allowlist, rule.id, relativePath)) {
544
+ continue;
545
+ }
546
+ issues.push({
547
+ ruleId: rule.id,
548
+ file: relativePath,
549
+ line: index + 1,
550
+ message: rule.message,
551
+ snippet: line.trim()
552
+ });
553
+ }
554
+ });
555
+ }
556
+ return issues;
557
+ }
558
+ export async function doctorLocaleTree(options = {}) {
559
+ const config = await resolveHagi18nConfig(options);
560
+ const audit = await auditLocaleTree(options);
561
+ const legacyReferenceIssues = await scanRepositoryForLegacyLocaleReferences(config.repoRoot, config.doctor.scanRules, config.doctor.allowlist, config.doctor.excludedDirectories, config.doctor.textFileExtensions, config.doctor.excludedPathPrefixes);
562
+ const affectedFiles = [...new Set(legacyReferenceIssues.map((issue) => issue.file))].sort((left, right) => left.localeCompare(right));
563
+ return {
564
+ repoRoot: config.repoRoot,
565
+ localesRoot: config.localesRoot,
566
+ baseLocale: audit.baseLocale,
567
+ locales: audit.locales,
568
+ audit,
569
+ legacyReferenceIssues,
570
+ totals: {
571
+ legacyReferenceIssues: legacyReferenceIssues.length,
572
+ affectedFiles: affectedFiles.length
573
+ },
574
+ hasIssues: audit.hasIssues || legacyReferenceIssues.length > 0
575
+ };
576
+ }
577
+ function formatListSection(title, items, formatter = (item) => String(item)) {
578
+ if (items.length === 0) {
579
+ return [];
580
+ }
581
+ return [
582
+ ` ${title}: ${items.length}`,
583
+ ...items.slice(0, 20).map((item) => ` - ${formatter(item)}`),
584
+ ...(items.length > 20 ? [` - ...and ${items.length - 20} more`] : [])
585
+ ];
586
+ }
587
+ export function formatAuditSummary(summary) {
588
+ const lines = [
589
+ `Base locale: ${summary.baseLocale}`,
590
+ `Locales: ${summary.locales.join(", ")}`,
591
+ `Files audited from base locale: ${summary.baseFileCount}`,
592
+ ""
593
+ ];
594
+ for (const result of summary.results) {
595
+ lines.push(`${result.locale}: ${auditHasIssues(result) ? "issues found" : "ok"}`);
596
+ const sections = [
597
+ ...formatListSection("Missing files", result.missingFiles),
598
+ ...formatListSection("Extra files", result.extraFiles),
599
+ ...formatListSection("Protected token files", result.filesWithProtectedTokens),
600
+ ...formatListSection("Parse errors", result.parseErrors, (item) => `${item.file}: ${item.message}`),
601
+ ...formatListSection("Missing keys", result.missingKeys, (item) => `${item.file} -> ${item.path}`),
602
+ ...formatListSection("Extra keys", result.extraKeys, (item) => `${item.file} -> ${item.path}`),
603
+ ...formatListSection("Placeholder mismatches", result.placeholderMismatches, (item) => `${item.file} -> ${item.path} | expected ${JSON.stringify(item.expected)} | actual ${JSON.stringify(item.actual)}`)
604
+ ];
605
+ if (sections.length === 0) {
606
+ lines.push(" No issues.");
607
+ }
608
+ else {
609
+ lines.push(...sections);
610
+ }
611
+ lines.push("");
612
+ }
613
+ const localesWithIssues = summary.results
614
+ .filter(auditHasIssues)
615
+ .map((result) => result.locale);
616
+ if (localesWithIssues.length === 0) {
617
+ lines.push("Locale audit passed.");
618
+ }
619
+ else {
620
+ lines.push(`Locale audit failed for: ${localesWithIssues.join(", ")}`);
621
+ }
622
+ return lines.join("\n");
623
+ }
624
+ export function formatMutationSummary(summary) {
625
+ const lines = [
626
+ `Command: ${summary.command}`,
627
+ `Base locale: ${summary.baseLocale}`,
628
+ `Target locales: ${summary.targetLocales.join(", ") || "(none)"}`,
629
+ `Mode: ${summary.dryRun ? "dry-run" : "write"}`,
630
+ ""
631
+ ];
632
+ if (summary.changedFiles.length === 0 &&
633
+ summary.removedFiles.length === 0 &&
634
+ summary.parseErrors.length === 0) {
635
+ lines.push("No changes.");
636
+ }
637
+ else {
638
+ lines.push(...formatListSection("Changed files", summary.changedFiles, (item) => {
639
+ if (item.action === "create") {
640
+ return `${item.locale}/${item.file} -> create`;
641
+ }
642
+ const details = item.addedPaths
643
+ ? `added ${item.addedPaths.length} path(s)`
644
+ : `removed ${item.removedPaths?.length ?? 0} path(s)`;
645
+ return `${item.locale}/${item.file} -> ${details}`;
646
+ }));
647
+ lines.push(...formatListSection("Removed files", summary.removedFiles, (item) => `${item.locale}/${item.file}`));
648
+ lines.push(...formatListSection("Parse errors", summary.parseErrors, (item) => `${item.locale}/${item.file}: ${item.message}`));
649
+ }
650
+ lines.push("");
651
+ lines.push(`Totals: createdFiles=${summary.totals.createdFiles}, updatedFiles=${summary.totals.updatedFiles}, removedFiles=${summary.totals.removedFiles}, addedPaths=${summary.totals.addedPaths}, removedPaths=${summary.totals.removedPaths}`);
652
+ return lines.join("\n");
653
+ }
654
+ export function formatDoctorSummary(summary) {
655
+ const lines = [
656
+ `Base locale: ${summary.baseLocale}`,
657
+ `Locales: ${summary.locales.join(", ")}`,
658
+ `Legacy reference issues: ${summary.totals.legacyReferenceIssues}`,
659
+ `Affected files: ${summary.totals.affectedFiles}`,
660
+ "",
661
+ formatAuditSummary(summary.audit)
662
+ ];
663
+ if (summary.legacyReferenceIssues.length === 0) {
664
+ lines.push("");
665
+ lines.push("Legacy locale reference scan passed.");
666
+ return lines.join("\n");
667
+ }
668
+ lines.push("");
669
+ lines.push("Legacy locale references:");
670
+ for (const issue of summary.legacyReferenceIssues.slice(0, 50)) {
671
+ lines.push(` - ${issue.file}:${issue.line} [${issue.ruleId}] ${issue.message}`);
672
+ lines.push(` ${issue.snippet}`);
673
+ }
674
+ if (summary.legacyReferenceIssues.length > 50) {
675
+ lines.push(` - ...and ${summary.legacyReferenceIssues.length - 50} more`);
676
+ }
677
+ return lines.join("\n");
678
+ }
679
+ //# sourceMappingURL=locale-toolkit.js.map