locon 1.4.6 → 1.5.0

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,594 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Audits locon translation assets against the code and against each other.
4
+ *
5
+ * locon looks a string up by its value in the project language, so a typo in a
6
+ * `<LText>` child is not a compile error — it silently renders the source
7
+ * language to someone who cannot read it. Nothing else in a typical toolchain
8
+ * catches that, or any of the other failure modes below.
9
+ *
10
+ * 1. every phrase passed to `l()` / `<LText>` resolves
11
+ * 2. every locale has exactly the keys the source locale has
12
+ * 3. placeholders match (with a documented exception for plurals)
13
+ * 4. no unreachable duplicate values
14
+ * 5. report orphaned keys nothing in the code asks for (optionally strict)
15
+ * 6. the code addresses strings by source phrase, not by key
16
+ * 7. no stray characters from another script
17
+ *
18
+ * Usage:
19
+ * node check-locon-assets.mjs [--source de] [--assets src/i18n/assets]
20
+ * [--src app,src] [--locales de,en,fr]
21
+ * [--allow-keys a,b]
22
+ * [--allow-implicit-count ar:day_one]
23
+ * [--strict-orphans] [--strict-plurals]
24
+ *
25
+ * Exits non-zero for correctness failures, so it works as a CI step. Orphans
26
+ * are advisory unless `--strict-orphans` is passed because dynamic usages
27
+ * cannot be proven by a lightweight source scan.
28
+ */
29
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
30
+ import { join, resolve } from 'node:path'
31
+
32
+ const BOOLEAN_ARGS = new Set(['help', 'strict-orphans', 'strict-plurals'])
33
+ const args = new Map()
34
+
35
+ for (let index = 2; index < process.argv.length; index += 1) {
36
+ const arg = process.argv[index]
37
+
38
+ if (!arg.startsWith('--')) {
39
+ console.error(`Unexpected argument: ${arg}`)
40
+ process.exit(2)
41
+ }
42
+
43
+ const name = arg.slice(2)
44
+ if (BOOLEAN_ARGS.has(name)) {
45
+ args.set(name, true)
46
+ continue
47
+ }
48
+
49
+ const value = process.argv[index + 1]
50
+ if (!value || value.startsWith('--')) {
51
+ console.error(`${arg} requires a value`)
52
+ process.exit(2)
53
+ }
54
+
55
+ args.set(name, value)
56
+ index += 1
57
+ }
58
+
59
+ if (args.has('help')) {
60
+ console.log(`Usage: node check-locon-assets.mjs [options]
61
+
62
+ --root <path> project root (default: current directory)
63
+ --source <locale> project/source locale (default: en)
64
+ --assets <path> assets directory relative to root
65
+ --src <paths> comma-separated source directories
66
+ --locales <codes> comma-separated locales the app declares
67
+ --allow-keys <keys> direct keys intentionally used by code
68
+ --allow-implicit-count <items> locale:key pairs allowed to omit {count}
69
+ --strict-orphans make orphaned keys fatal
70
+ --strict-plurals make missing selectable categories fatal`)
71
+ process.exit(0)
72
+ }
73
+
74
+ const ROOT = resolve(args.get('root') ?? process.cwd())
75
+ const SOURCE_LOCALE = args.get('source') ?? 'en'
76
+ const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/
77
+ const STRICT_ORPHANS = args.has('strict-orphans')
78
+ const STRICT_PLURALS = args.has('strict-plurals')
79
+
80
+ const csv = (value) =>
81
+ (value ?? '')
82
+ .split(',')
83
+ .map((item) => item.trim())
84
+ .filter(Boolean)
85
+
86
+ /** Keys the code may address directly, because their value is shadowed. */
87
+ const ALLOWED_KEYS = new Set(csv(args.get('allow-keys')))
88
+ /** Locale/key pairs whose wording states the exact number without `{count}`. */
89
+ const ALLOWED_IMPLICIT_COUNT = new Set(csv(args.get('allow-implicit-count')))
90
+ const DECLARED_LOCALES = new Set(csv(args.get('locales')))
91
+
92
+ /** Finds the assets directory when not given one. */
93
+ function findAssets() {
94
+ if (args.get('assets')) return resolve(ROOT, args.get('assets'))
95
+ const guesses = ['src/i18n/assets', 'src/assets/i18n', 'assets/i18n', 'src/locales', 'locales']
96
+ const hit = guesses.find((g) => existsSync(join(ROOT, g, `${SOURCE_LOCALE}.json`)))
97
+ if (!hit) {
98
+ console.error(
99
+ `Could not find the assets directory. Pass --assets <path> (looked for ${SOURCE_LOCALE}.json in: ${guesses.join(', ')})`,
100
+ )
101
+ process.exit(2)
102
+ }
103
+ return join(ROOT, hit)
104
+ }
105
+
106
+ const ASSET_DIR = findAssets()
107
+ const SOURCE_DIRS = csv(args.get('src') ?? 'app,src')
108
+
109
+ const problems = []
110
+ const note = (message) => problems.push(message)
111
+
112
+ /** Parses the deliberately flat `{ key: string }` asset format without hiding duplicate keys. */
113
+ function parseAsset(locale) {
114
+ const path = join(ASSET_DIR, `${locale}.json`)
115
+ const text = readFileSync(path, 'utf8')
116
+ let index = 0
117
+
118
+ const skipWhitespace = () => {
119
+ while (/\s/.test(text[index] ?? '')) index += 1
120
+ }
121
+
122
+ const readString = () => {
123
+ if (text[index] !== '"') throw new Error(`expected a JSON string at character ${index + 1}`)
124
+ const start = index
125
+ index += 1
126
+
127
+ while (index < text.length) {
128
+ if (text[index] === '\\') {
129
+ index += 2
130
+ continue
131
+ }
132
+ if (text[index] === '"') {
133
+ index += 1
134
+ return JSON.parse(text.slice(start, index))
135
+ }
136
+ index += 1
137
+ }
138
+
139
+ throw new Error(`unterminated JSON string at character ${start + 1}`)
140
+ }
141
+
142
+ try {
143
+ skipWhitespace()
144
+ if (text[index] !== '{') throw new Error('asset root must be an object')
145
+ index += 1
146
+
147
+ const result = {}
148
+ const seen = new Set()
149
+
150
+ while (true) {
151
+ skipWhitespace()
152
+ if (text[index] === '}') {
153
+ index += 1
154
+ break
155
+ }
156
+
157
+ const key = readString()
158
+ skipWhitespace()
159
+ if (text[index] !== ':') throw new Error(`expected ':' after "${key}"`)
160
+ index += 1
161
+ skipWhitespace()
162
+ const value = readString()
163
+
164
+ if (seen.has(key)) note(`${locale}.json has duplicate key: ${key}`)
165
+ seen.add(key)
166
+ result[key] = value
167
+
168
+ skipWhitespace()
169
+ if (text[index] === ',') {
170
+ index += 1
171
+ skipWhitespace()
172
+ if (text[index] === '}') throw new Error(`trailing comma after "${key}"`)
173
+ continue
174
+ }
175
+ if (text[index] !== '}') throw new Error(`expected ',' or '}' after "${key}"`)
176
+ }
177
+
178
+ skipWhitespace()
179
+ if (index !== text.length) throw new Error(`unexpected content at character ${index + 1}`)
180
+ return result
181
+ } catch (error) {
182
+ console.error(`${locale}.json is not a flat string asset: ${error.message}`)
183
+ process.exit(2)
184
+ }
185
+ }
186
+
187
+ // ── assets ────────────────────────────────────────────────────────────
188
+ const locales = readdirSync(ASSET_DIR)
189
+ .filter((f) => f.endsWith('.json'))
190
+ .map((f) => f.replace('.json', ''))
191
+ .sort()
192
+
193
+ const assets = Object.fromEntries(locales.map((locale) => [locale, parseAsset(locale)]))
194
+
195
+ if (DECLARED_LOCALES.size) {
196
+ const missingFiles = [...DECLARED_LOCALES].filter((locale) => !assets[locale])
197
+ const undeclaredFiles = locales.filter((locale) => !DECLARED_LOCALES.has(locale))
198
+
199
+ if (missingFiles.length) note(`declared locales missing asset files: ${missingFiles.join(', ')}`)
200
+ if (undeclaredFiles.length) note(`asset files not present in declared locales: ${undeclaredFiles.join(', ')}`)
201
+ }
202
+
203
+ const source = assets[SOURCE_LOCALE]
204
+ if (!source) {
205
+ console.error(`Source locale "${SOURCE_LOCALE}.json" not found in ${ASSET_DIR}`)
206
+ process.exit(2)
207
+ }
208
+
209
+ const sourceKeys = Object.keys(source)
210
+ const pluralFamilies = new Set(
211
+ sourceKeys.filter((key) => PLURAL_SUFFIX.test(key)).map((key) => key.replace(PLURAL_SUFFIX, '')),
212
+ )
213
+
214
+ for (const family of pluralFamilies) {
215
+ if (!(`${family}_other` in source)) {
216
+ note(`source plural family "${family}" must define ${family}_other as its catch-all`)
217
+ }
218
+ }
219
+
220
+ const valueToKey = new Map()
221
+ const shadowed = []
222
+ for (const key of sourceKeys) {
223
+ const value = source[key]
224
+ if (valueToKey.has(value)) shadowed.push([value, valueToKey.get(value), key])
225
+ else valueToKey.set(value, key)
226
+ }
227
+
228
+ // ── source files ──────────────────────────────────────────────────────
229
+ function walk(dir) {
230
+ if (!existsSync(dir)) return []
231
+ return readdirSync(dir).flatMap((entry) => {
232
+ if (entry === 'node_modules' || entry.startsWith('.')) return []
233
+ const full = join(dir, entry)
234
+ if (statSync(full).isDirectory()) return walk(full)
235
+ return /\.[jt]sx?$/.test(full) ? [full] : []
236
+ })
237
+ }
238
+
239
+ const files = SOURCE_DIRS.flatMap((dir) => walk(join(ROOT, dir)))
240
+
241
+ /** JSX collapses newlines and indentation in text children into single spaces. */
242
+ const collapse = (text) => text.replace(/\s+/g, ' ').trim()
243
+
244
+ const used = []
245
+ /** Every string literal, for usage detection only (arrays, consts, …). */
246
+ const literals = new Set()
247
+
248
+ /** Reads a static JS string/template literal at `start`, without evaluating it. */
249
+ function readLiteralAt(code, start) {
250
+ const quote = code[start]
251
+ if (!["'", '"', '`'].includes(quote)) return null
252
+
253
+ let raw = ''
254
+ for (let index = start + 1; index < code.length; index += 1) {
255
+ const char = code[index]
256
+
257
+ if (char === '\\') {
258
+ raw += char + (code[index + 1] ?? '')
259
+ index += 1
260
+ continue
261
+ }
262
+ if (char === quote) {
263
+ // Interpolated templates are dynamic and cannot be audited statically.
264
+ if (quote === '`' && raw.includes('${')) return null
265
+
266
+ const value = raw
267
+ .replace(/\\u\{([0-9a-f]+)\}/gi, (_, point) => String.fromCodePoint(Number.parseInt(point, 16)))
268
+ .replace(/\\u([0-9a-f]{4})/gi, (_, point) => String.fromCharCode(Number.parseInt(point, 16)))
269
+ .replace(/\\x([0-9a-f]{2})/gi, (_, point) => String.fromCharCode(Number.parseInt(point, 16)))
270
+ .replace(/\\n/g, '\n')
271
+ .replace(/\\r/g, '\r')
272
+ .replace(/\\t/g, '\t')
273
+ .replace(/\\(['"`\\])/g, '$1')
274
+
275
+ return { value, end: index + 1 }
276
+ }
277
+ if (quote !== '`' && (char === '\n' || char === '\r')) return null
278
+ raw += char
279
+ }
280
+
281
+ return null
282
+ }
283
+
284
+ /**
285
+ * Masks comments and string contents while preserving offsets and quote marks.
286
+ * This keeps call/JSX scans out of comments and example strings; callers still
287
+ * read the real literal from `code` at the matching offset.
288
+ */
289
+ function maskNonCode(code, { keepStringContents = false } = {}) {
290
+ const masked = [...code]
291
+
292
+ for (let index = 0; index < code.length; index += 1) {
293
+ const quote = code[index]
294
+
295
+ if (["'", '"', '`'].includes(quote)) {
296
+ for (let cursor = index + 1; cursor < code.length; cursor += 1) {
297
+ if (code[cursor] === '\\') {
298
+ if (!keepStringContents) {
299
+ masked[cursor] = ' '
300
+ if (cursor + 1 < code.length) masked[cursor + 1] = ' '
301
+ }
302
+ cursor += 1
303
+ continue
304
+ }
305
+ if (code[cursor] === quote) {
306
+ index = cursor
307
+ break
308
+ }
309
+ if (!keepStringContents && code[cursor] !== '\n' && code[cursor] !== '\r') masked[cursor] = ' '
310
+ }
311
+ continue
312
+ }
313
+
314
+ if (code[index] === '/' && code[index + 1] === '/') {
315
+ masked[index] = ' '
316
+ masked[index + 1] = ' '
317
+ index += 2
318
+ while (index < code.length && code[index] !== '\n') {
319
+ masked[index] = ' '
320
+ index += 1
321
+ }
322
+ continue
323
+ }
324
+
325
+ if (code[index] === '/' && code[index + 1] === '*') {
326
+ masked[index] = ' '
327
+ masked[index + 1] = ' '
328
+ index += 2
329
+ while (index < code.length && !(code[index] === '*' && code[index + 1] === '/')) {
330
+ if (code[index] !== '\n' && code[index] !== '\r') masked[index] = ' '
331
+ index += 1
332
+ }
333
+ if (index < code.length) {
334
+ masked[index] = ' '
335
+ masked[index + 1] = ' '
336
+ index += 1
337
+ }
338
+ }
339
+ }
340
+
341
+ return masked.join('')
342
+ }
343
+
344
+ /** Returns a static literal used as an attribute value, if present. */
345
+ function attributeLiteral(attributes, name) {
346
+ const match = new RegExp(`\\b${name}\\s*=\\s*(?:\\{\\s*)?`).exec(attributes)
347
+ return match ? readLiteralAt(attributes, match.index + match[0].length)?.value : undefined
348
+ }
349
+
350
+ for (const file of files) {
351
+ const code = readFileSync(file, 'utf8')
352
+ const commentFree = maskNonCode(code, { keepStringContents: true })
353
+ const executable = maskNonCode(code)
354
+
355
+ // All static literals count for orphan detection, including arrays and
356
+ // constants referenced indirectly by the UI.
357
+ for (let index = 0; index < commentFree.length; index += 1) {
358
+ const literal = readLiteralAt(commentFree, index)
359
+ if (!literal) continue
360
+ literals.add(literal.value)
361
+ index = literal.end - 1
362
+ }
363
+
364
+ // `l()` accepts a source phrase as its first argument. Support every static
365
+ // JS quote style; interpolated templates remain intentionally dynamic.
366
+ for (const match of executable.matchAll(/\bl\s*\(\s*/g)) {
367
+ const literal = readLiteralAt(code, match.index + match[0].length)
368
+ if (literal) used.push({ phrase: literal.value, file })
369
+ }
370
+
371
+ // `lIn(locale, phrase)` uses its second argument as the lookup input. This
372
+ // deliberately handles the normal identifier/static-locale forms without
373
+ // pretending a regex is a complete TypeScript parser.
374
+ for (const match of executable.matchAll(/\blIn\s*\(\s*[^,\n]+,\s*/g)) {
375
+ const literal = readLiteralAt(code, match.index + match[0].length)
376
+ if (literal) used.push({ phrase: literal.value, file })
377
+ }
378
+
379
+ for (const match of executable.matchAll(/<LText\b([^>]*)>([\s\S]*?)<\/LText>/g)) {
380
+ const openingTagEnd = match[0].indexOf('>')
381
+ const attributes = code.slice(match.index + '<LText'.length, match.index + openingTagEnd)
382
+ const childStart = match.index + openingTagEnd + 1
383
+ const childEnd = match.index + match[0].length - '</LText>'.length
384
+ const child = code.slice(childStart, childEnd)
385
+ const assetKey = attributeLiteral(attributes, 'assetKey')
386
+
387
+ if (assetKey) {
388
+ used.push({ phrase: assetKey, file, directKey: true })
389
+ } else if (!/[<>{}]/.test(match[2])) {
390
+ used.push({ phrase: collapse(child), file })
391
+ }
392
+ }
393
+
394
+ for (const match of executable.matchAll(/<LText\b([^>]*)\/>/g)) {
395
+ const attributes = code.slice(match.index + '<LText'.length, match.index + match[0].length - 2)
396
+ const assetKey = attributeLiteral(attributes, 'assetKey')
397
+ if (assetKey) used.push({ phrase: assetKey, file, directKey: true })
398
+ }
399
+ }
400
+
401
+ const resolvedKeys = new Set()
402
+
403
+ for (const { phrase, file, directKey } of used) {
404
+ if (!phrase) continue
405
+ const rel = file.replace(`${ROOT}/`, '')
406
+ const isKey = phrase in source
407
+ const isValue = valueToKey.has(phrase)
408
+ const base = phrase.replace(PLURAL_SUFFIX, '')
409
+ const isPluralBase = sourceKeys.some((key) => key.replace(PLURAL_SUFFIX, '') === base && key !== base)
410
+
411
+ if (!isKey && !isValue && !isPluralBase) {
412
+ note(`unresolved ${directKey ? 'asset key' : 'phrase'} in ${rel}: "${phrase}"`)
413
+ }
414
+ if (isValue) resolvedKeys.add(valueToKey.get(phrase))
415
+ if (isKey) resolvedKeys.add(phrase)
416
+ if (isPluralBase) {
417
+ for (const key of sourceKeys) {
418
+ if (key.replace(PLURAL_SUFFIX, '') === base) resolvedKeys.add(key)
419
+ }
420
+ }
421
+ if (!directKey && isKey && !isValue && !ALLOWED_KEYS.has(phrase)) {
422
+ note(`key used instead of source phrase in ${rel}: ${phrase} — write l('${source[phrase]}')`)
423
+ }
424
+ }
425
+
426
+ // A shadowed duplicate is a bug only when nothing addresses it by key.
427
+ for (const [value, first, second] of shadowed) {
428
+ if (!resolvedKeys.has(second) && !literals.has(second)) {
429
+ note(`unreachable duplicate: "${value}" is both ${first} and ${second} — only the first resolves`)
430
+ }
431
+ }
432
+
433
+ // ── locale parity ─────────────────────────────────────────────────────
434
+ const placeholders = (value) => new Set([...value.matchAll(/\{(\w+)\}/g)].map((m) => m[1]))
435
+ const usedImplicitCount = new Set()
436
+ const pluralGaps = []
437
+
438
+ for (const locale of locales) {
439
+ const target = assets[locale]
440
+
441
+ if (locale !== SOURCE_LOCALE) {
442
+ const missing = sourceKeys.filter((key) => !(key in target))
443
+ const unknown = Object.keys(target).filter(
444
+ (key) =>
445
+ !(key in source) &&
446
+ !(
447
+ PLURAL_SUFFIX.test(key) &&
448
+ sourceKeys.some((k) => k.replace(PLURAL_SUFFIX, '') === key.replace(PLURAL_SUFFIX, ''))
449
+ ),
450
+ )
451
+
452
+ if (missing.length) {
453
+ note(
454
+ `${locale}.json missing ${missing.length} keys: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ' …' : ''}`,
455
+ )
456
+ }
457
+ if (unknown.length) note(`${locale}.json has unknown keys: ${unknown.slice(0, 5).join(', ')}`)
458
+ }
459
+
460
+ for (const key of Object.keys(target)) {
461
+ const sourceKey = PLURAL_SUFFIX.test(key) ? key.replace(PLURAL_SUFFIX, '_other') : key
462
+ if (!(sourceKey in source)) continue
463
+
464
+ const want = placeholders(source[sourceKey])
465
+ const got = placeholders(target[key])
466
+ const introduced = [...got].filter((p) => !want.has(p))
467
+ const dropped = [...want].filter((p) => !got.has(p))
468
+
469
+ if (introduced.length) note(`${locale}.json unknown placeholder in ${key}: {${introduced.join('}, {')}}`)
470
+ if (dropped.length) {
471
+ const allowance = `${locale}:${key}`
472
+ if (dropped.length === 1 && dropped[0] === 'count' && ALLOWED_IMPLICIT_COUNT.has(allowance)) {
473
+ usedImplicitCount.add(allowance)
474
+ } else {
475
+ note(
476
+ `${locale}.json missing placeholder in ${key}: {${dropped.join('}, {')}}` +
477
+ (dropped.includes('count') ? ` — allow intentional wording with --allow-implicit-count ${allowance}` : ''),
478
+ )
479
+ }
480
+ }
481
+ }
482
+
483
+ try {
484
+ const selectable = new Intl.PluralRules(locale.replace(/_/g, '-')).resolvedOptions().pluralCategories
485
+ for (const family of pluralFamilies) {
486
+ const present = new Set(
487
+ Object.keys(target)
488
+ .filter((key) => key.replace(PLURAL_SUFFIX, '') === family && PLURAL_SUFFIX.test(key))
489
+ .map((key) => key.match(PLURAL_SUFFIX)[1]),
490
+ )
491
+ const missing = selectable.filter((category) => !present.has(category))
492
+ if (missing.length) pluralGaps.push(`${locale}:${family} missing ${missing.join(', ')}`)
493
+ }
494
+ } catch {
495
+ note(`${locale}.json filename is not a locale supported by Intl.PluralRules`)
496
+ }
497
+ }
498
+
499
+ for (const allowance of ALLOWED_IMPLICIT_COUNT) {
500
+ if (!usedImplicitCount.has(allowance)) note(`unused --allow-implicit-count entry: ${allowance}`)
501
+ }
502
+
503
+ // ── script sanity ─────────────────────────────────────────────────────
504
+ const FOREIGN_SCRIPTS = [
505
+ {
506
+ name: 'Han',
507
+ pattern: /\p{Script=Han}/u,
508
+ languages: ['ja', 'ko', 'zh'],
509
+ scripts: ['hani', 'hans', 'hant', 'jpan', 'kore'],
510
+ },
511
+ { name: 'Japanese Kana', pattern: /[\p{Script=Hiragana}\p{Script=Katakana}]/u, languages: ['ja'], scripts: ['jpan'] },
512
+ { name: 'Hangul', pattern: /\p{Script=Hangul}/u, languages: ['ko'], scripts: ['kore'] },
513
+ {
514
+ name: 'Cyrillic',
515
+ pattern: /\p{Script=Cyrillic}/u,
516
+ languages: ['ru', 'uk', 'be', 'bg', 'sr', 'mk', 'kk'],
517
+ scripts: ['cyrl'],
518
+ },
519
+ {
520
+ name: 'Arabic',
521
+ pattern: /\p{Script=Arabic}/u,
522
+ languages: ['ar', 'azb', 'ckb', 'fa', 'ks', 'lrc', 'mzn', 'ps', 'sd', 'ug', 'ur'],
523
+ scripts: ['arab', 'aran'],
524
+ },
525
+ { name: 'Hebrew', pattern: /\p{Script=Hebrew}/u, languages: ['he', 'yi'], scripts: ['hebr'] },
526
+ { name: 'Devanagari', pattern: /\p{Script=Devanagari}/u, languages: ['hi', 'mr', 'ne'], scripts: ['deva'] },
527
+ { name: 'Thai', pattern: /\p{Script=Thai}/u, languages: ['th'], scripts: ['thai'] },
528
+ { name: 'Greek', pattern: /\p{Script=Greek}/u, languages: ['el'], scripts: ['grek'] },
529
+ ]
530
+
531
+ for (const locale of locales) {
532
+ const subtags = locale.toLowerCase().split(/[-_]/)
533
+ const language = subtags[0]
534
+ const explicitScript = subtags.slice(1).find((subtag) => /^[a-z]{4}$/.test(subtag))
535
+
536
+ for (const { name, pattern, languages, scripts } of FOREIGN_SCRIPTS) {
537
+ const allowed = explicitScript ? scripts.includes(explicitScript) : languages.includes(language)
538
+ if (allowed) continue
539
+ for (const [key, value] of Object.entries(assets[locale])) {
540
+ if (pattern.test(value)) note(`${locale}.json contains ${name} characters in ${key}: "${value}"`)
541
+ }
542
+ }
543
+ }
544
+
545
+ // ── orphans ───────────────────────────────────────────────────────────
546
+ const usedPhrases = new Set([...used.map((u) => u.phrase), ...resolvedKeys])
547
+
548
+ const pluralFamilyUsed = (key) => {
549
+ const base = key.replace(PLURAL_SUFFIX, '')
550
+ if (base === key) return false
551
+ return sourceKeys.some(
552
+ (sibling) =>
553
+ sibling.replace(PLURAL_SUFFIX, '') === base &&
554
+ (usedPhrases.has(sibling) || usedPhrases.has(source[sibling]) || literals.has(source[sibling])),
555
+ )
556
+ }
557
+
558
+ const orphans = files.length
559
+ ? sourceKeys.filter(
560
+ (key) =>
561
+ !usedPhrases.has(key) && !usedPhrases.has(source[key]) && !literals.has(source[key]) && !pluralFamilyUsed(key),
562
+ )
563
+ : []
564
+
565
+ // ── report ────────────────────────────────────────────────────────────
566
+ console.log(`source: ${SOURCE_LOCALE}.json (${sourceKeys.length} keys)`)
567
+ console.log(`locales: ${locales.join(', ')}`)
568
+ console.log(`code: ${files.length} files, ${usedPhrases.size} distinct phrases`)
569
+
570
+ if (orphans.length) {
571
+ console.log(`\norphaned keys (${orphans.length}) — nothing in the code asks for these:`)
572
+ for (const key of orphans) console.log(` ${key} = "${source[key]}"`)
573
+ }
574
+
575
+ if (pluralGaps.length) {
576
+ console.log(`\nplural fallbacks (${pluralGaps.length}) — these categories currently use _other:`)
577
+ for (const gap of pluralGaps) console.log(` ${gap}`)
578
+ }
579
+
580
+ if (STRICT_ORPHANS && orphans.length) {
581
+ note(`${orphans.length} orphaned key(s); remove them or rerun without --strict-orphans`)
582
+ }
583
+
584
+ if (STRICT_PLURALS && pluralGaps.length) {
585
+ note(`${pluralGaps.length} plural category gap(s); add forms or rerun without --strict-plurals`)
586
+ }
587
+
588
+ if (problems.length) {
589
+ console.log(`\n${problems.length} problem(s):`)
590
+ for (const problem of problems) console.log(` ✗ ${problem}`)
591
+ process.exit(1)
592
+ }
593
+
594
+ console.log('\n✓ assets and code agree')