champollion 0.3.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.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
@@ -0,0 +1,1185 @@
1
+ /**
2
+ * Language card registry — structured language metadata and register presets.
3
+ *
4
+ * ARCHITECTURE (v7 — Dynamic Cards):
5
+ *
6
+ * Each language has a single card containing ALL metadata: runtime
7
+ * fields (code, registers, formality, methodSupport, rules) AND
8
+ * reference data (linguisticChallenges, encyclopedic, resources,
9
+ * typology, vitality). Cards come from three tiers:
10
+ *
11
+ * 1. REPO MODE — shared/language-cards/<code>.json exists (git
12
+ * checkout): every card is loaded eagerly at module init, exactly
13
+ * as in v6. No network is ever touched. Tests run in this mode.
14
+ *
15
+ * 2. PACKAGED MODE — the npm tarball excludes the ~72 MB card
16
+ * directory and ships shared/cards-fallback.json instead (core
17
+ * languages + all genera/ parents + a full code/alias manifest,
18
+ * built by scripts/build-cards-fallback.mjs). Core languages work
19
+ * offline. Long-tail lookups are served from the per-user cache
20
+ * (~/.champollion/cards/) and, on a miss, fetched synchronously
21
+ * from the production Supabase trading-card tables (read-only
22
+ * anon key) via a child process — see lib/cards/. Cached entries
23
+ * are invalidated by per-language updated_at (lib/cards/refresh.js,
24
+ * run by bin/cli.js at most once per day). Set CHAMPOLLION_OFFLINE=1
25
+ * to disable all network use.
26
+ *
27
+ * 3. Genus/family/macrolanguage abstract cards ship in both modes;
28
+ * language cards use "extends" to inherit from them, including
29
+ * cards loaded from the cache or the network.
30
+ *
31
+ * ADDING A NEW LANGUAGE:
32
+ * 1. Create shared/language-cards/<code>.json (all fields)
33
+ * 2. Research the language's formality system and write register presets
34
+ * 3. Validate against shared/schemas/language-card.schema.json
35
+ * 4. Add eval dataset references if available
36
+ * 5. Regenerate the bundle: node scripts/build-cards-fallback.mjs
37
+ *
38
+ * BACKWARD COMPATIBILITY:
39
+ * DEFAULT_REGISTERS is still exported as a backward-compatible proxy.
40
+ * It returns the same shape as the old flat dictionary ({ name, register,
41
+ * dir?, scripts? }) so existing consumers don't break during migration.
42
+ * New code should use getLanguageCard() and getRegister() instead.
43
+ */
44
+
45
+ import fs from 'node:fs';
46
+ import path from 'node:path';
47
+ import { execFileSync } from 'node:child_process';
48
+
49
+ import { CARDS_DIR, FALLBACK_FILE, OFFLINE, isValidCardCode } from './cards/env.js';
50
+ import {
51
+ readCachedCard,
52
+ writeCachedCard,
53
+ listCachedCodes,
54
+ readState,
55
+ inBackoff,
56
+ isTombstoned,
57
+ tombstone,
58
+ noteNetworkFailure,
59
+ } from './cards/cache.js';
60
+ import { fetchRemoteCard } from './cards/remote.js';
61
+ import { normalizeCard } from './cards/reader.js';
62
+
63
+ // -----------------------------------------------------------------
64
+ // Card loading — full directory (repo) or bundled fallback (package)
65
+ // -----------------------------------------------------------------
66
+
67
+ /** Child script used for synchronous fetches (see _tryFetchRemoteSync). */
68
+ const FETCH_CHILD = path.join(import.meta.dirname, 'cards', 'fetch-card-child.js');
69
+
70
+ // NOTE: The former REFERENCE_DIR and reference tier (shared/language-reference/)
71
+ // has been merged into the runtime cards (v6 architecture). All data is now
72
+ // in shared/language-cards/. The REFERENCE_DIR export is kept temporarily
73
+ // for backward compatibility but points nowhere.
74
+ const REFERENCE_DIR = null;
75
+
76
+ /**
77
+ * In-memory registry of all loaded language cards.
78
+ * Keyed by primary locale code (e.g., 'fr', 'ko', 'x-pirate').
79
+ * @type {Map<string, object>}
80
+ */
81
+ const _cards = new Map();
82
+
83
+ /**
84
+ * Registry of parent/abstract cards (genus and family cards in genera/).
85
+ * Keyed by code (e.g., 'family-algonquian', 'genus-cree').
86
+ * @type {Map<string, object>}
87
+ */
88
+ const _parentCards = new Map();
89
+
90
+ /**
91
+ * Alias map — alternative locale codes that resolve to a primary code.
92
+ * e.g., 'no' → 'nb', 'iw' → 'he', 'zh-CN' → 'zh', 'fil' → 'tl'
93
+ * Built from the `aliases` field in each language card.
94
+ * @type {Map<string, string>}
95
+ */
96
+ const _aliases = new Map();
97
+
98
+ /**
99
+ * Manifest of every concrete card code in the catalogue (packaged
100
+ * mode only — empty in repo mode). Values: { n: name, a?: aliases,
101
+ * d?: dir }. Lets resolveCode() recognize the full catalogue without
102
+ * materializing it, and tells the fetch path which codes exist.
103
+ * @type {Map<string, object>}
104
+ */
105
+ const _manifest = new Map();
106
+
107
+ /**
108
+ * How cards were loaded:
109
+ * 'repo' — full shared/language-cards/ directory
110
+ * 'packaged' — bundled fallback + cache + remote fetch
111
+ * 'none' — neither source available (registry empty)
112
+ * @type {'repo'|'packaged'|'none'}
113
+ */
114
+ let _mode = 'none';
115
+
116
+ /**
117
+ * Codes already attempted (hit or miss) against cache/network in this
118
+ * process — prevents repeated child spawns for the same code.
119
+ * @type {Set<string>}
120
+ */
121
+ const _fetchAttempted = new Set();
122
+
123
+ /**
124
+ * Register a concrete card into the registry (shared by all tiers).
125
+ */
126
+ function _registerCard(card) {
127
+ _cards.set(card.code, card);
128
+ // `codeAliases` — other CODES that resolve to this language, derived from
129
+ // SIL's own ISO 639-1 column: a language holding Part1 'fr' is what 'fr'
130
+ // means. This was briefly called `aliases`, which invited confusion with
131
+ // `alternateNames` (which are NAMES: "Northern Tosk Albanian", not "sq").
132
+ // The two were once merged by mistake and every two-letter lookup broke.
133
+ const codeAliases = card.codeAliases ?? card.aliases;
134
+ if (Array.isArray(codeAliases)) {
135
+ for (const alias of codeAliases) {
136
+ _aliases.set(alias, card.code);
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Load all language card JSON files from the cards directory (recursively).
143
+ * Called once at module initialization. Builds both the primary
144
+ * card registry and the alias lookup table.
145
+ *
146
+ * When the directory is absent (published npm package), falls back to
147
+ * the bundled core set + manifest (shared/cards-fallback.json).
148
+ */
149
+ function _loadCards() {
150
+ if (!fs.existsSync(CARDS_DIR)) {
151
+ _loadFallbackBundle();
152
+ return;
153
+ }
154
+ _mode = 'repo';
155
+
156
+ // The five x-* product locales are configuration, not languages — no source
157
+ // will ever publish x-pirate, and under the fetch-from-source law they have
158
+ // no place in the atlas. They are still product features users invoke, so
159
+ // when the cards directory is the projected atlas (which contains only
160
+ // languages), they are served from the config kernel. A live-corpus dir
161
+ // still carries them as files and wins by being loaded later.
162
+ _loadPromptConfig();
163
+ for (const [code, card] of Object.entries(_cardConfig.conlangs)) {
164
+ if (!_cards.has(code)) _cards.set(code, card);
165
+ }
166
+
167
+ function scan(dir) {
168
+ let entries;
169
+ try {
170
+ entries = fs.readdirSync(dir, { withFileTypes: true });
171
+ } catch {
172
+ return;
173
+ }
174
+
175
+ for (const entry of entries) {
176
+ const resPath = path.join(dir, entry.name);
177
+ if (entry.isDirectory()) {
178
+ scan(resPath);
179
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
180
+ try {
181
+ const raw = fs.readFileSync(resPath, 'utf-8');
182
+ // Normalised at the ONE load site: atlas-shaped cards carry honest
183
+ // names (endonym, textDirection, scripts[]) and attribution
184
+ // envelopes; the 24 modules importing this registry read the legacy
185
+ // vocabulary. The reader adapts so neither the atlas has to lie nor
186
+ // 24 modules have to change mid-cutover. Old-shape cards pass
187
+ // through untouched.
188
+ const card = normalizeCard(JSON.parse(raw));
189
+
190
+ // Data files (e.g., language-tree.json) have a _meta key and no
191
+ // 'code' field — they are reference data, not language cards.
192
+ // Skip them silently instead of logging a noisy warning.
193
+ if (card._meta) continue;
194
+
195
+ if (!card.code) {
196
+ console.error(`[WARN] Language card ${entry.name} missing 'code' field — skipping.`);
197
+ continue;
198
+ }
199
+
200
+ if (
201
+ resPath.includes(path.sep + 'genera' + path.sep) ||
202
+ card.code.startsWith('family-') ||
203
+ card.code.startsWith('genus-') ||
204
+ card.code.startsWith('macrolanguage-')
205
+ ) {
206
+ _parentCards.set(card.code, card);
207
+ } else {
208
+ _registerCard(card);
209
+ }
210
+ } catch (err) {
211
+ console.error(`[WARN] Failed to load language card ${entry.name}: ${err.message}`);
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ scan(CARDS_DIR);
218
+ _applyCodeRouting();
219
+ _loadPromptConfig();
220
+ }
221
+
222
+ /**
223
+ * Translation-prompt CONFIGURATION, loaded once.
224
+ *
225
+ * A card asserts what SOURCES say about a language. What register presets to
226
+ * offer, how to phrase gender guidance, what we call a politeness system —
227
+ * none of that is asserted by anyone, so it lives in shared/catalogue/ and is
228
+ * composed onto the card at read time.
229
+ *
230
+ * The previous corpus kept all of it ON the cards: 1,775 register blocks
231
+ * carrying 110 distinct prompt objects, 2,404 gender blocks of which only 103
232
+ * held anything a runtime reads. Worse, the presets were stamped
233
+ * `source: "grambank-GB415"` — citing Grambank for text we wrote, which is why
234
+ * config and fact became impossible to tell apart.
235
+ *
236
+ * Storage keeps them separate. The runtime VIEW puts them back together, so
237
+ * consumers still see card.registers and card.formality.
238
+ */
239
+ const _presets = { shared: {}, byLanguage: {}, assignment: {} };
240
+ /** Languages DeepL documents formality for — cite-only, from method-coverage.json. */
241
+ const _deeplFormality = new Set();
242
+ /** Per-metric pivot sets + QE models from metric-coverage.json (the register). */
243
+ const _metricCoverage = { pivots: {}, qe: {} };
244
+ /** Gate 3: authored product config, attached to the view, never cited as fact. */
245
+ const _cardConfig = { scriptConverter: {}, conlangs: {}, aliasRouting: {}, evalConfig: {} };
246
+ /** Indexed once at load: entry lists keyed by what each applies to. */
247
+ let _genderGuidance = { family: new Map(), language: new Map() };
248
+
249
+ /**
250
+ * Where the prompt config lives, in both layouts.
251
+ *
252
+ * In the repo it is `shared/catalogue/` at the monorepo root. In a PUBLISHED
253
+ * package there is no monorepo root — npm ships `cli/shared/`, so the same
254
+ * files arrive one level up from `lib/`. Reading only the repo path meant a
255
+ * published install found nothing, and the catch below turned that into
256
+ * silence: every language lost its formality system and register prompts, and
257
+ * `champollion` translated German with no notion of Sie versus du.
258
+ *
259
+ * Both are tried, packaged first, because that is the layout a user has.
260
+ */
261
+ const _CONFIG_DIRS = [
262
+ path.join(import.meta.dirname, '..', 'shared', 'catalogue'),
263
+ path.join(import.meta.dirname, '..', '..', 'shared', 'catalogue'),
264
+ ];
265
+
266
+ /** Read a config file from whichever layout has it. Returns null if neither. */
267
+ function _readConfig(name) {
268
+ for (const dir of _CONFIG_DIRS) {
269
+ try {
270
+ return JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
271
+ } catch { /* try the next layout */ }
272
+ }
273
+ return null;
274
+ }
275
+
276
+ /** Config files that were looked for and not found — reported, never silent. */
277
+ export const missingPromptConfig = [];
278
+
279
+ function _loadPromptConfig() {
280
+ {
281
+ const p = _readConfig('register-presets.json');
282
+ if (p) {
283
+ Object.assign(_presets, {
284
+ shared: p.shared ?? {}, byLanguage: p.byLanguage ?? {}, assignment: p.assignment ?? {},
285
+ });
286
+ } else {
287
+ // Not fatal — a partial checkout is legitimate and cards still work —
288
+ // but recorded, so `champollion doctor` and the tests can see that this
289
+ // install has no register prompts rather than inferring that no
290
+ // language has a politeness system.
291
+ missingPromptConfig.push('register-presets.json');
292
+ }
293
+ }
294
+ {
295
+ const cc = _readConfig('card-config.json');
296
+ if (cc) {
297
+ Object.assign(_cardConfig.scriptConverter, cc.scriptConverter ?? {});
298
+ Object.assign(_cardConfig.conlangs, cc.conlangs ?? {});
299
+ for (const [k, v] of Object.entries(cc.aliasRouting ?? {})) {
300
+ if (!k.startsWith('_')) _cardConfig.aliasRouting[k] = v;
301
+ }
302
+ // Per-language eval wiring (evalStandard/evalMetrics/evalPack/
303
+ // evalDatasets) — CONFIG, ours, attached at read time exactly like the
304
+ // Python twin's _eval_config_for. The atlas deliberately does not
305
+ // project these (they are wiring, not facts about a language), and the
306
+ // JS seam never picked them up, so `champollion card crk` lost its eval
307
+ // section and build-metric-pops' seal check stopped loud at the gap.
308
+ for (const [k, v] of Object.entries(cc.evalConfig ?? {})) {
309
+ if (!k.startsWith('_')) _cardConfig.evalConfig[k] = v;
310
+ }
311
+ } else {
312
+ missingPromptConfig.push('card-config.json');
313
+ }
314
+ }
315
+ {
316
+ // DeepL's formality support is a METHOD capability — whether the service
317
+ // accepts a formality argument for a language — and is deliberately read
318
+ // from method coverage rather than from anything on the language card.
319
+ const mc = _readConfig('method-coverage.json');
320
+ if (mc) {
321
+ const dl = (mc.methods ?? []).find((m) => m.key === 'deepl');
322
+ for (const code of dl?.formalityIso6393 ?? []) _deeplFormality.add(code);
323
+ } else {
324
+ missingPromptConfig.push('method-coverage.json');
325
+ }
326
+ }
327
+ {
328
+ // metric-coverage.json is the register behind the metricModelSupport
329
+ // flattening below: which listed codes are a publisher's PIVOTS (in the
330
+ // list as pass-through languages, never targets) and which metric has a
331
+ // QE checkpoint. Python twin: _metric_pivots() / _metric_qe_models().
332
+ const mcov = _readConfig('metric-coverage.json');
333
+ if (mcov) {
334
+ for (const m of mcov.models ?? []) {
335
+ _metricCoverage.pivots[m.key] = new Set(m.pivots ?? []);
336
+ if (m.qeModel) _metricCoverage.qe[m.key] = m.qeModel;
337
+ }
338
+ } else {
339
+ missingPromptConfig.push('metric-coverage.json');
340
+ }
341
+ }
342
+ {
343
+ const g = _readConfig('gender-guidance.json');
344
+ if (!g) {
345
+ missingPromptConfig.push('gender-guidance.json');
346
+ _genderGuidance = { family: new Map(), language: new Map() };
347
+ return;
348
+ }
349
+ const family = new Map();
350
+ const language = new Map();
351
+ for (const e of g.entries ?? []) {
352
+ // Genus entries are carried but not applied: a genus sits below a family
353
+ // and needs a subgroup match against the ancestry chain, not a name
354
+ // lookup. `applied: false` keeps the gap visible instead of silent.
355
+ if (e.scope === 'genus' || e.applied === false) continue;
356
+ const target = e.scope === 'language' ? language : family;
357
+ for (const key of e.appliesTo ?? []) target.set(key, e.guidance);
358
+ }
359
+ _genderGuidance = { family, language };
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Gender guidance for a language: its own if someone wrote one, otherwise its
365
+ * family's.
366
+ *
367
+ * "Bantu languages use noun class systems" is a statement about the FAMILY. It
368
+ * is true and it is useful prompt text, and it is not a fact about any one
369
+ * language — so it is stored once per family and resolved here, rather than
370
+ * copied onto 3,469 cards the way hub inheritance used to do it.
371
+ *
372
+ * The family is ATTRIBUTED: Glottolog and WALS are separate taxonomies that
373
+ * disagree on 905 cards (Atlantic-Congo vs Niger-Congo, and so on). Both names
374
+ * are tried, because guidance written against one taxonomy's name should not
375
+ * vanish because the other is listed first.
376
+ */
377
+ function _genderGuidanceFor(card) {
378
+ const own = _genderGuidance.language.get(card.code);
379
+ if (own) return own;
380
+ const fam = card.classification?.family;
381
+ const names = fam?.values ? fam.values.map((v) => v.value) : (fam ? [fam] : []);
382
+ for (const n of names) {
383
+ const hit = _genderGuidance.family.get(n);
384
+ if (hit) return hit;
385
+ }
386
+ return null;
387
+ }
388
+
389
+ /** The preset for a language, from either the bespoke set or a shared one. */
390
+ function _presetFor(code) {
391
+ return _presets.byLanguage[code]
392
+ ?? _presets.shared[_presets.assignment[code]]
393
+ ?? null;
394
+ }
395
+
396
+ /**
397
+ * Compose the runtime view: facts from the card, prompts from the config.
398
+ *
399
+ * A card that already carries `registers` keeps them — during migration the
400
+ * live corpus still does, and a card's own values must not be silently
401
+ * replaced by config. Once cutover lands, only the config path is live.
402
+ */
403
+ function _withPromptConfig(card) {
404
+ if (!card) return card;
405
+ const preset = _presetFor(card.code);
406
+ const guidance = _genderGuidanceFor(card);
407
+
408
+ const view = { ...card };
409
+ // ── Migration seam, removed at cutover ────────────────────────────────────
410
+ // The live corpus still carries plural categories at rules.plurals.categories
411
+ // and text direction as `dir`. The projected corpus carries them as the
412
+ // sourced parameters pluralCategories and textDirection. Consumers read the
413
+ // NEW names; this maps a legacy card forward so the two corpora can be tested
414
+ // against the same code without either being broken.
415
+ //
416
+ // ONE mapping, in ONE place, with an end date — not a compatibility layer
417
+ // threaded through the consumers.
418
+ if (!view.pluralCategories && Array.isArray(view.rules?.plurals?.categories)) {
419
+ view.pluralCategories = view.rules.plurals.categories;
420
+ }
421
+ if (!view.textDirection && view.dir === 'rtl') view.textDirection = 'right-to-left';
422
+
423
+ // Eval wiring from card-config.json (Python twin: _eval_config_for +
424
+ // setdefault). A card's own values are never replaced — config fills only
425
+ // what the card does not carry.
426
+ const evalWiring = _cardConfig.evalConfig[view.code];
427
+ if (evalWiring) {
428
+ for (const [k, v] of Object.entries(evalWiring)) {
429
+ if (view[k] === undefined) view[k] = v;
430
+ }
431
+ }
432
+
433
+ // The atlas projects methodSupport as EVIDENCE — {total, byTier, named[]} —
434
+ // because English attracts 5,420 model-card claims and a flat map of those
435
+ // is a log, not an index. The runtime's consumers ask a smaller question:
436
+ // "does this SERVICE cover this language, and does DeepL do formality here?"
437
+ // Service entries are named in full, above the projection cap, precisely so
438
+ // that absence answers that question honestly. This synthesizes the legacy
439
+ // map from them; the evidence stays on the card as methodSupportEvidence.
440
+ if (view.methodSupport && Array.isArray(view.methodSupport.named)) {
441
+ const LEGACY_KEYS = {
442
+ 'google-translate': 'googleTranslate',
443
+ deepl: 'deepl',
444
+ 'microsoft-translator': 'microsoftTranslator',
445
+ libretranslate: 'libreTranslate',
446
+ apertium: 'apertium',
447
+ };
448
+ const flat = {};
449
+ for (const key of Object.values(LEGACY_KEYS)) flat[key] = { supported: false };
450
+ for (const n of view.methodSupport.named) {
451
+ const legacy = LEGACY_KEYS[n.variant];
452
+ if (legacy && n.value === 'service') flat[legacy] = { supported: true };
453
+ }
454
+ flat.deepl.formality = flat.deepl.supported && _deeplFormality.has(view.code);
455
+ // The LLM lane is not list-based coverage: the method registry declares
456
+ // the adapters, and their coverage is open-ended by construction — an LLM
457
+ // will ATTEMPT any language, which is a fact about the method's shape,
458
+ // not a per-language claim anyone fetched. Quality is the leaderboard's
459
+ // question, never this flag's.
460
+ flat.llm = { supported: true };
461
+ view.methodSupportEvidence = view.methodSupport;
462
+ view.methodSupport = flat;
463
+ }
464
+
465
+ // metricModelSupport: same adaptation, twin of the Python adapter's block.
466
+ // The atlas records "which metric model lists this language" as attributed
467
+ // values with a metric variant; the runtime reads a flat map. xlmr membership
468
+ // IS the high tier — the paper publishes the list, not per-language scores
469
+ // (tierMeaning on the register entry). A publisher's PIVOTS (eng/fra for
470
+ // AfriCOMET) are in the list as the languages pairs run through, not as
471
+ // targets — the register marks them; treating a pivot as a target would
472
+ // recommend africomet for French.
473
+ const mms = view.metricModelSupport;
474
+ if (mms && typeof mms === 'object' && Array.isArray(mms.values)) {
475
+ const flatM = {};
476
+ const code3 = view.iso639_3 ?? view.code;
477
+ for (const v of mms.values) {
478
+ const variant = v?.variant;
479
+ if (!variant) continue;
480
+ if (variant === 'xlmr') {
481
+ flatM[variant] = { tier: 'high', model: v.value };
482
+ } else {
483
+ const pivots = _metricCoverage.pivots[variant] ?? new Set();
484
+ flatM[variant] = { supported: !pivots.has(code3), model: v.value };
485
+ if (pivots.has(code3)) flatM[variant].pivot = true;
486
+ if (variant === 'africomet' && _metricCoverage.qe[variant]) {
487
+ flatM.qe = { ...flatM[variant], model: _metricCoverage.qe[variant] };
488
+ }
489
+ }
490
+ }
491
+ view.metricModelSupport = flatM;
492
+ }
493
+
494
+ if (preset && !view.registers) {
495
+ view.registers = preset.registers;
496
+ view.formality = {
497
+ ...(view.formality ?? {}),
498
+ ...(preset.system ? { system: preset.system } : {}),
499
+ ...(preset.default ? { default: preset.default } : {}),
500
+ };
501
+ }
502
+ if (guidance) view.gender = { ...(view.gender ?? {}), inclusiveGuidance: guidance };
503
+ // Gate 3: the converter key is OUR choice of which script conversion the CLI
504
+ // performs for a locale — product config, not a fact about the language, so
505
+ // it rides the view from the config kernel and is never cited.
506
+ if (!view.scriptConverter && _cardConfig.scriptConverter[view.code]) {
507
+ view.scriptConverter = _cardConfig.scriptConverter[view.code];
508
+ }
509
+ return view;
510
+ }
511
+
512
+ /**
513
+ * Route codes that do not identify ONE language.
514
+ *
515
+ * `fr` unambiguously means French, and that alias comes off the card itself.
516
+ * `zh` does not mean one language — it names the Chinese macrolanguage — and
517
+ * deciding it should reach Mandarin is a CHOICE. Those choices live in
518
+ * shared/catalogue/code-routing.json with their rationale, not on a card,
519
+ * because "when someone types zh, give them Mandarin" is not something
520
+ * Glottolog or SIL says and putting it on a card would dress our decision up
521
+ * as an upstream fact.
522
+ *
523
+ * Card-derived aliases win: a real language holding an ISO 639-1 code is never
524
+ * overridden by a routing preference.
525
+ */
526
+ function _applyCodeRouting() {
527
+ const file = path.join(
528
+ import.meta.dirname, '..', '..', 'shared', 'catalogue', 'code-routing.json',
529
+ );
530
+ if (!fs.existsSync(file)) return;
531
+ let routes;
532
+ try {
533
+ ({ routes } = JSON.parse(fs.readFileSync(file, 'utf-8')));
534
+ } catch (err) {
535
+ console.error(`[WARN] code-routing.json is unreadable: ${err.message}`);
536
+ return;
537
+ }
538
+ for (const [from, route] of Object.entries(routes ?? {})) {
539
+ if (_aliases.has(from) || _cards.has(from)) continue;
540
+ if (_cards.has(route?.to)) _aliases.set(from, route.to);
541
+ }
542
+
543
+ // Config ROUTING decisions override derived aliases. ISO maps `no` to the
544
+ // macrolanguage nor; the product routes it to nob, a recorded decision in
545
+ // card-config.json with its reasoning — so it must win over the alias the
546
+ // registries would derive, or the decision silently loses to the derivation.
547
+ for (const [from, to] of Object.entries(_cardConfig.aliasRouting)) {
548
+ if (_cards.has(to)) _aliases.set(from, to);
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Packaged mode: load the bundled fallback (core cards + parents +
554
+ * full manifest) produced by scripts/build-cards-fallback.mjs.
555
+ */
556
+ function _loadFallbackBundle() {
557
+ // PACKAGED MODE NEEDS THE PROMPT CONFIG TOO — and used not to load it.
558
+ //
559
+ // _loadCards() called _loadPromptConfig() only on the repo branch, so the
560
+ // one mode every published user actually runs in loaded no register presets
561
+ // at all. Formality systems, register prompts, script converters and the
562
+ // conlang locales were all absent, silently, for everyone who installed from
563
+ // npm — while every test passed, because tests run in repo mode.
564
+ //
565
+ // Loaded first, so the conlang locales below can come from the config kernel
566
+ // exactly as they do in repo mode.
567
+ _loadPromptConfig();
568
+ for (const [code, card] of Object.entries(_cardConfig.conlangs)) {
569
+ if (!_cards.has(code)) _cards.set(code, card);
570
+ }
571
+
572
+ let bundle;
573
+ try {
574
+ bundle = JSON.parse(fs.readFileSync(FALLBACK_FILE, 'utf-8'));
575
+ } catch {
576
+ return; // _mode stays 'none' — registry is empty, same as v6 without cards
577
+ }
578
+
579
+ for (const card of Object.values(bundle.parents || {})) {
580
+ if (card?.code) _parentCards.set(card.code, card);
581
+ }
582
+ for (const card of Object.values(bundle.cards || {})) {
583
+ if (card?.code) _registerCard(card);
584
+ }
585
+ for (const [code, entry] of Object.entries(bundle.manifest || {})) {
586
+ _manifest.set(code, entry);
587
+ // Manifest aliases cover the whole catalogue, so 'fr'-style lookups
588
+ // resolve even for languages that aren't materialized locally yet.
589
+ if (Array.isArray(entry.a)) {
590
+ for (const alias of entry.a) {
591
+ if (!_aliases.has(alias)) _aliases.set(alias, code);
592
+ }
593
+ }
594
+ }
595
+ _mode = 'packaged';
596
+ }
597
+
598
+ // Load cards at module initialization
599
+ _loadCards();
600
+
601
+ // -----------------------------------------------------------------
602
+ // Dynamic tier (packaged mode) — per-user cache + remote fetch
603
+ // -----------------------------------------------------------------
604
+
605
+ /**
606
+ * Try to materialize a card that isn't bundled: first from the
607
+ * per-user cache (~/.champollion/cards/), then via a synchronous
608
+ * network fetch. Registers the card on success.
609
+ *
610
+ * @param {string} code - already-resolved primary code
611
+ * @returns {boolean} true when the card is now in _cards
612
+ */
613
+ function _loadDynamicCard(code) {
614
+ if (!isValidCardCode(code)) return false;
615
+
616
+ const cached = readCachedCard(code);
617
+ if (cached) {
618
+ _registerCard(cached.card);
619
+ return true;
620
+ }
621
+
622
+ const fetched = _tryFetchRemoteSync(code);
623
+ if (fetched) {
624
+ _registerCard(fetched);
625
+ return true;
626
+ }
627
+ return false;
628
+ }
629
+
630
+ /**
631
+ * Synchronous remote fetch via a short-lived child process (Node has
632
+ * no synchronous HTTP, and getLanguageCard() is part of the stable
633
+ * synchronous API). Guarded so it runs at most once per code per
634
+ * process, never when offline, never inside the failure backoff
635
+ * window, and never for codes prod has confirmed it doesn't have.
636
+ *
637
+ * @returns {object|null} the fetched card, or null
638
+ */
639
+ function _tryFetchRemoteSync(code) {
640
+ if (OFFLINE) return null;
641
+ if (_fetchAttempted.has(code)) return null;
642
+ _fetchAttempted.add(code);
643
+
644
+ const state = readState();
645
+ if (inBackoff(state) || isTombstoned(code, state)) return null;
646
+
647
+ const aliases = _manifest.get(code)?.a;
648
+ let stdout;
649
+ try {
650
+ stdout = execFileSync(
651
+ process.execPath,
652
+ [FETCH_CHILD, code, aliases ? JSON.stringify(aliases) : ''],
653
+ {
654
+ encoding: 'utf-8',
655
+ timeout: 20000,
656
+ maxBuffer: 32 * 1024 * 1024,
657
+ stdio: ['ignore', 'pipe', 'ignore'],
658
+ },
659
+ );
660
+ } catch {
661
+ noteNetworkFailure();
662
+ return null;
663
+ }
664
+
665
+ let result;
666
+ try {
667
+ result = JSON.parse(stdout);
668
+ } catch {
669
+ return null;
670
+ }
671
+
672
+ if (!result.ok) {
673
+ noteNetworkFailure();
674
+ return null;
675
+ }
676
+ if (result.missing) {
677
+ tombstone(code);
678
+ return null;
679
+ }
680
+
681
+ writeCachedCard(code, result.card, result.updatedAt);
682
+ return result.card;
683
+ }
684
+
685
+ /**
686
+ * Prefetch cards for a set of locale codes into the per-user cache
687
+ * (packaged mode; repo mode is a no-op since everything is local).
688
+ *
689
+ * Async batch alternative to the per-miss synchronous fetch — call it
690
+ * up front (e.g. before a sync run over many target locales) to warm
691
+ * the cache in one pass. Network failures are reported, not thrown.
692
+ *
693
+ * @param {string[]} codes - locale codes (aliases are resolved)
694
+ * @returns {Promise<{fetched: string[], missing: string[], failed: string[], skipped: string[]}>}
695
+ */
696
+ async function prefetchLanguageCards(codes) {
697
+ const result = { fetched: [], missing: [], failed: [], skipped: [] };
698
+ if (_mode !== 'packaged' || OFFLINE || !Array.isArray(codes)) {
699
+ result.skipped = Array.isArray(codes) ? [...codes] : [];
700
+ return result;
701
+ }
702
+
703
+ for (const input of codes) {
704
+ const code = resolveCode(input);
705
+ if (!isValidCardCode(code) || _cards.has(code) || readCachedCard(code)) {
706
+ result.skipped.push(input);
707
+ continue;
708
+ }
709
+ try {
710
+ const remote = await fetchRemoteCard(code, { aliases: _manifest.get(code)?.a });
711
+ if (remote.missing) {
712
+ tombstone(code);
713
+ result.missing.push(code);
714
+ } else {
715
+ writeCachedCard(code, remote.card, remote.updatedAt);
716
+ _registerCard(remote.card);
717
+ result.fetched.push(code);
718
+ }
719
+ } catch {
720
+ noteNetworkFailure();
721
+ result.failed.push(code);
722
+ }
723
+ }
724
+ return result;
725
+ }
726
+
727
+ /**
728
+ * Where cards are coming from in this process — for doctor output,
729
+ * debugging, and tests.
730
+ */
731
+ function getCardSourceInfo() {
732
+ return {
733
+ mode: _mode,
734
+ loadedCards: _cards.size,
735
+ parentCards: _parentCards.size,
736
+ manifestEntries: _manifest.size,
737
+ cachedCards: _mode === 'packaged' ? listCachedCodes().length : 0,
738
+ };
739
+ }
740
+
741
+ // -----------------------------------------------------------------
742
+ // Public API — accessor functions for language cards and registers
743
+ // -----------------------------------------------------------------
744
+
745
+ /**
746
+ * Resolve a locale code to its primary ISO 639-3 code, following aliases.
747
+ *
748
+ * Handles:
749
+ * - Direct match: 'fra' → 'fra'
750
+ * - Alias: 'fr' → 'fra', 'no' → 'nob', 'iw' → 'heb'
751
+ * - Base locale fallback: 'deu-AT' → 'deu' (base)
752
+ *
753
+ * @param {string} code - Locale code to resolve
754
+ * @returns {string} Resolved primary code (ISO 639-3)
755
+ */
756
+ function resolveCode(code) {
757
+ // Direct match
758
+ if (_cards.has(code)) return code;
759
+
760
+ // Alias match
761
+ if (_aliases.has(code)) return _aliases.get(code);
762
+
763
+ // Manifest match (packaged mode) — the code is a known primary code
764
+ // even though its card isn't materialized locally yet
765
+ if (_manifest.has(code)) return code;
766
+
767
+ // Base locale fallback — try stripping region (e.g., 'de-AT' → 'de')
768
+ const baseParts = code.split('-');
769
+ if (baseParts.length > 1) {
770
+ const baseCode = baseParts[0];
771
+ if (_cards.has(baseCode)) return baseCode;
772
+ if (_aliases.has(baseCode)) return _aliases.get(baseCode);
773
+ if (_manifest.has(baseCode)) return baseCode;
774
+ }
775
+
776
+ // No match found — return original code (will get null from getLanguageCard)
777
+ return code;
778
+ }
779
+
780
+ /**
781
+ * Deep merge two card objects (child overrides parent).
782
+ *
783
+ * Semantics:
784
+ * - null in child → inherit from parent (don't override)
785
+ * - non-null in child → override parent
786
+ * - objects merge recursively (child fields override, parent fields kept)
787
+ * - arrays in child replace parent arrays entirely
788
+ * - identity fields (code, extends, _migration) are never inherited from parent
789
+ * - hub-only fields (members, supportTier, taxonomyNotes) are never
790
+ * inherited from parent: genera/macrolanguage hub cards double as
791
+ * `extends` templates for their member cards, but those fields
792
+ * describe the hub itself (see derive-taxonomy-fields.mjs header +
793
+ * docs/LANGUAGE_TAXONOMY.md) — e.g. arz must not surface ara's
794
+ * members[] when runtime-resolved
795
+ */
796
+ const _IDENTITY_FIELDS = new Set(['code', 'extends', '_migration', 'aliases', 'iso639_1', 'iso639_3']);
797
+ const _NON_INHERITED_FIELDS = new Set(['members', 'supportTier', 'taxonomyNotes']);
798
+
799
+ function _deepMerge(parent, child) {
800
+ if (!parent) return child || {};
801
+ if (!child) return parent || {};
802
+
803
+ const result = { ...parent };
804
+ // Top-level card merges only (cards carry `code`; nested objects don't):
805
+ // drop hub-only fields coming from the parent. The child's own values,
806
+ // if any, are re-applied by the merge loop below.
807
+ if (Object.prototype.hasOwnProperty.call(parent, 'code')) {
808
+ for (const key of _NON_INHERITED_FIELDS) delete result[key];
809
+ }
810
+
811
+ for (const [key, value] of Object.entries(child)) {
812
+ // Identity fields: always use child's value (even if null)
813
+ if (_IDENTITY_FIELDS.has(key)) {
814
+ result[key] = value;
815
+ continue;
816
+ }
817
+
818
+ // Null in child means "I don't define this" → inherit from parent
819
+ if (value === null || value === undefined) {
820
+ continue;
821
+ }
822
+
823
+ if (
824
+ typeof value === 'object' &&
825
+ !Array.isArray(value) &&
826
+ parent[key] &&
827
+ typeof parent[key] === 'object' &&
828
+ !Array.isArray(parent[key])
829
+ ) {
830
+ result[key] = _deepMerge(parent[key], value);
831
+ } else {
832
+ result[key] = value;
833
+ }
834
+ }
835
+ return result;
836
+ }
837
+
838
+ /**
839
+ * In-memory cache of fully resolved/merged language cards.
840
+ * Keyed by primary locale code.
841
+ * @type {Map<string, object>}
842
+ */
843
+ const _resolvedCards = new Map();
844
+
845
+ /**
846
+ * Get the full language card for a locale code.
847
+ *
848
+ * Follows aliases and falls back to base locale. Returns null if
849
+ * no card exists for this language.
850
+ *
851
+ * Resolves inheritance chains recursively if the 'extends' property is set.
852
+ *
853
+ * @param {string} code - Locale code (e.g., 'fr', 'ko', 'no', 'fr-CA')
854
+ * @returns {object|null} Complete language card, or null
855
+ */
856
+ function getLanguageCard(code) {
857
+ // First check parents (no alias/base resolution needed for parent cards)
858
+ if (_parentCards.has(code)) {
859
+ if (_resolvedCards.has(code)) {
860
+ return _resolvedCards.get(code);
861
+ }
862
+ const rawCard = _parentCards.get(code);
863
+ let resolvedCard = rawCard;
864
+ if (rawCard.extends) {
865
+ const parentCard = getLanguageCard(rawCard.extends);
866
+ if (parentCard) {
867
+ resolvedCard = _deepMerge(parentCard, rawCard);
868
+ }
869
+ }
870
+ const composed = _withPromptConfig(resolvedCard);
871
+ _resolvedCards.set(code, composed);
872
+ return composed;
873
+ }
874
+
875
+ const resolved = resolveCode(code);
876
+ if (!_cards.has(resolved)) {
877
+ // Packaged mode: try the per-user cache, then a synchronous remote
878
+ // fetch. Both register the card into _cards on success.
879
+ if (_mode !== 'packaged' || !_loadDynamicCard(resolved)) return null;
880
+ }
881
+
882
+ if (_resolvedCards.has(resolved)) {
883
+ return _resolvedCards.get(resolved);
884
+ }
885
+
886
+ const rawCard = _cards.get(resolved);
887
+ let resolvedCard = rawCard;
888
+
889
+ if (rawCard.extends) {
890
+ const parentCard = getLanguageCard(rawCard.extends);
891
+ if (parentCard) {
892
+ resolvedCard = _deepMerge(parentCard, rawCard);
893
+ } else {
894
+ console.error(`[WARN] Language card '${resolved}' extends unknown card '${rawCard.extends}'.`);
895
+ }
896
+ }
897
+
898
+ // Facts from the card, prompts from shared/catalogue/. Composed once and
899
+ // cached, so consumers see one object and never learn that two files exist.
900
+ const composed = _withPromptConfig(resolvedCard);
901
+ _resolvedCards.set(resolved, composed);
902
+ return composed;
903
+ }
904
+
905
+ /**
906
+ * Get the active register prompt text for a locale.
907
+ *
908
+ * Resolution order:
909
+ * 1. If presetOrCustom matches a preset key in the card → use that preset's prompt
910
+ * 2. If presetOrCustom is a non-empty string that doesn't match a preset → treat as custom text
911
+ * 3. If presetOrCustom is null/undefined → use the card's default preset
912
+ * 4. If no card exists → return generic fallback
913
+ *
914
+ * @param {string} code - Locale code
915
+ * @param {string|null} [presetOrCustom] - Preset name (e.g., 'formal-vous') or custom register text
916
+ * @returns {string} Register prompt text
917
+ */
918
+
919
+ /** Fallback register text when no card or preset provides one. */
920
+ const DEFAULT_REGISTER_FALLBACK = 'Professional register.';
921
+
922
+ function getRegister(code, presetOrCustom) {
923
+ const card = getLanguageCard(code);
924
+
925
+ if (!card) {
926
+ // No card — return custom text if provided, or generic fallback
927
+ return presetOrCustom || DEFAULT_REGISTER_FALLBACK;
928
+ }
929
+
930
+ // Guard: cards without register presets (e.g., ISO 639-3 stubs from
931
+ // the expanded language-cards directory) should fall through gracefully
932
+ // instead of crashing on Object.keys(null).
933
+ if (!card.registers) {
934
+ return presetOrCustom || DEFAULT_REGISTER_FALLBACK;
935
+ }
936
+
937
+ // No user override — use card's default preset
938
+ if (!presetOrCustom) {
939
+ const defaultKey = card.formality?.default || Object.keys(card.registers)[0];
940
+ return card.registers[defaultKey]?.prompt || DEFAULT_REGISTER_FALLBACK;
941
+ }
942
+
943
+ // Check if it's a preset name
944
+ if (card.registers[presetOrCustom]) {
945
+ return card.registers[presetOrCustom].prompt;
946
+ }
947
+
948
+ // Not a preset name — treat as custom register text (pass-through)
949
+ return presetOrCustom;
950
+ }
951
+
952
+ /**
953
+ * Get all available register presets for a locale.
954
+ *
955
+ * Returns an array of { key, label, description, prompt, isDefault } objects
956
+ * for use in the CLI wizard and status display.
957
+ *
958
+ * @param {string} code - Locale code
959
+ * @returns {Array<{ key: string, label: string, description: string, prompt: string, isDefault: boolean }>}
960
+ */
961
+ function getRegisterPresets(code) {
962
+ const card = getLanguageCard(code);
963
+ if (!card || !card.registers) return [];
964
+
965
+ const defaultKey = card.formality?.default || Object.keys(card.registers)[0];
966
+
967
+ return Object.entries(card.registers).map(([key, preset]) => ({
968
+ key,
969
+ label: preset.label,
970
+ description: preset.description,
971
+ prompt: preset.prompt,
972
+ isDefault: key === defaultKey,
973
+ }));
974
+ }
975
+
976
+ /**
977
+ * Get structured formality metadata for a locale.
978
+ *
979
+ * Used by DeepL and other methods that need to know the formality level
980
+ * without parsing register text strings. Returns the default formality
981
+ * level (e.g., 'formal', 'polite', 'neutral') or null.
982
+ *
983
+ * @param {string} code - Locale code
984
+ * @param {string|null} [presetOrCustom] - Active preset name or custom text
985
+ * @returns {{ system: string, level: string, description: string }|null}
986
+ */
987
+ function getFormality(code, presetOrCustom) {
988
+ const card = getLanguageCard(code);
989
+ if (!card || !card.formality) return null;
990
+
991
+ // Determine which preset is active
992
+ const activeKey = (presetOrCustom && card.registers[presetOrCustom])
993
+ ? presetOrCustom
994
+ : card.formality.default;
995
+
996
+ return {
997
+ system: card.formality.system,
998
+ level: activeKey,
999
+ description: card.formality.description,
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * Get gender-inclusive guidance for a locale.
1005
+ *
1006
+ * Separate from register so it can be appended to any register preset
1007
+ * without duplication.
1008
+ *
1009
+ * @param {string} code - Locale code
1010
+ * @returns {string|null} Gender-inclusive guidance text, or null
1011
+ */
1012
+ function getGenderGuidance(code) {
1013
+ const card = getLanguageCard(code);
1014
+ return card?.gender?.inclusiveGuidance || null;
1015
+ }
1016
+
1017
+ /**
1018
+ * Get all loaded language codes (primary, not aliases).
1019
+ *
1020
+ * Repo mode: every card in shared/language-cards/ (the full catalogue).
1021
+ * Packaged mode: bundled core cards plus whatever the per-user cache
1022
+ * has materialized — i.e. the codes getLanguageCard() can serve without
1023
+ * touching the network. Deliberately NOT the full manifest: callers
1024
+ * iterate this list and fetch each card (validate.js, doctor), which
1025
+ * must never turn into thousands of network round-trips.
1026
+ *
1027
+ * @returns {string[]} Array of primary locale codes
1028
+ */
1029
+ function getAllLanguageCodes() {
1030
+ if (_mode !== 'packaged') return Array.from(_cards.keys());
1031
+ const codes = new Set(_cards.keys());
1032
+ for (const code of listCachedCodes()) codes.add(code);
1033
+ return Array.from(codes);
1034
+ }
1035
+
1036
+ /**
1037
+ * Get method support flags for a locale.
1038
+ *
1039
+ * @param {string} code - Locale code
1040
+ * @returns {object|null} Method support flags, or null if no card
1041
+ */
1042
+ function getMethodSupport(code) {
1043
+ const card = getLanguageCard(code);
1044
+ return card?.methodSupport || null;
1045
+ }
1046
+
1047
+ // -----------------------------------------------------------------
1048
+ // Reference tier — REMOVED (v6: unified cards)
1049
+ // -----------------------------------------------------------------
1050
+
1051
+ /**
1052
+ * Get the full language reference for a locale.
1053
+ *
1054
+ * In v6 (unified cards), this is now just an alias for getLanguageCard().
1055
+ * All reference data (linguisticChallenges, encyclopedic, resources) is
1056
+ * merged directly into the runtime card files.
1057
+ *
1058
+ * Kept for backward compatibility with consumers that called
1059
+ * getLanguageReference() for enriched data.
1060
+ *
1061
+ * @param {string} code - Locale code (e.g., 'fr', 'ko')
1062
+ * @returns {object|null} Complete language card, or null if no card
1063
+ * @deprecated Use getLanguageCard() instead — it returns the same data.
1064
+ */
1065
+ function getLanguageReference(code) {
1066
+ return getLanguageCard(code);
1067
+ }
1068
+
1069
+ // -----------------------------------------------------------------
1070
+ // Backward-compatible DEFAULT_REGISTERS proxy
1071
+ // -----------------------------------------------------------------
1072
+
1073
+ /**
1074
+ * Backward-compatible DEFAULT_REGISTERS export.
1075
+ *
1076
+ * Returns the same shape as the old flat dictionary so all 21 consumer
1077
+ * call sites keep working during migration:
1078
+ * { name: string, register: string, dir?: string, scripts?: string }
1079
+ *
1080
+ * New code should use getLanguageCard() and getRegister() instead.
1081
+ *
1082
+ * WHY a Proxy: We don't want to maintain two data sources. The proxy
1083
+ * dynamically builds the old shape from the language card data on access.
1084
+ * The `ownKeys` trap ensures Object.keys(), Object.entries(), and
1085
+ * for...in loops work correctly.
1086
+ */
1087
+ const DEFAULT_REGISTERS = new Proxy({}, {
1088
+ get(_target, code) {
1089
+ if (typeof code !== 'string') return undefined;
1090
+
1091
+ // Handle common Proxy/inspection traps
1092
+ if (code === Symbol.toPrimitive || code === Symbol.iterator) return undefined;
1093
+ if (code === 'toJSON') {
1094
+ // Support JSON.stringify(DEFAULT_REGISTERS)
1095
+ return () => {
1096
+ const result = {};
1097
+ for (const key of _cards.keys()) {
1098
+ result[key] = DEFAULT_REGISTERS[key];
1099
+ }
1100
+ // Also include aliases that map to different keys for backward compat
1101
+ // The old code had 'no' as a direct entry, now it's an alias to 'nb'
1102
+ return result;
1103
+ };
1104
+ }
1105
+
1106
+ const card = getLanguageCard(code);
1107
+ if (!card) return undefined;
1108
+
1109
+ const defaultRegister = getRegister(code);
1110
+ const entry = {
1111
+ name: card.name,
1112
+ register: defaultRegister,
1113
+ };
1114
+
1115
+ // Only include dir if RTL (matches old behavior — LTR was implied)
1116
+ // `textDirection` is CLDR's own layout.orientation.characterOrder,
1117
+ // projected as a fact. The card no longer carries a bare `dir`.
1118
+ if (card.textDirection === 'right-to-left' || card.dir === 'rtl') {
1119
+ entry.dir = 'rtl';
1120
+ }
1121
+
1122
+ // Map scriptConverter to the old 'scripts' field name
1123
+ if (card.scriptConverter) {
1124
+ entry.scripts = card.scriptConverter;
1125
+ }
1126
+
1127
+ return entry;
1128
+ },
1129
+
1130
+ has(_target, code) {
1131
+ if (typeof code !== 'string') return false;
1132
+ return getLanguageCard(code) !== null;
1133
+ },
1134
+
1135
+ ownKeys() {
1136
+ // Return all locally-available primary codes + aliases for full
1137
+ // backward compat. The old code had 'no' as a key, now it's an
1138
+ // alias to 'nb'. (Set: Proxy ownKeys must not contain duplicates.)
1139
+ const keys = new Set(getAllLanguageCodes());
1140
+ for (const [alias] of _aliases) {
1141
+ keys.add(alias);
1142
+ }
1143
+ return Array.from(keys);
1144
+ },
1145
+
1146
+ getOwnPropertyDescriptor(_target, code) {
1147
+ if (typeof code !== 'string') return undefined;
1148
+ const card = getLanguageCard(code);
1149
+ if (!card) return undefined;
1150
+ return {
1151
+ configurable: true,
1152
+ enumerable: true,
1153
+ value: DEFAULT_REGISTERS[code],
1154
+ };
1155
+ },
1156
+ });
1157
+
1158
+ // -----------------------------------------------------------------
1159
+ // Exports
1160
+ // -----------------------------------------------------------------
1161
+
1162
+ export {
1163
+ // New API — use these in new code
1164
+ getLanguageCard,
1165
+ getLanguageReference,
1166
+ getRegister,
1167
+ getRegisterPresets,
1168
+ getFormality,
1169
+ getGenderGuidance,
1170
+ getAllLanguageCodes,
1171
+ getMethodSupport,
1172
+ resolveCode,
1173
+ DEFAULT_REGISTER_FALLBACK,
1174
+
1175
+ // Dynamic card tier (packaged installs)
1176
+ prefetchLanguageCards,
1177
+ getCardSourceInfo,
1178
+
1179
+ // Backward-compatible export — existing consumers use this
1180
+ DEFAULT_REGISTERS,
1181
+
1182
+ // Internal — for testing
1183
+ CARDS_DIR,
1184
+ REFERENCE_DIR,
1185
+ };