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,265 @@
1
+ /**
2
+ * Command: status
3
+ *
4
+ * Shows the translation pair graph, config summary, installed plugins,
5
+ * TM cache stats, and benchmark scores. A diagnostic tool for understanding
6
+ * how the project is configured before running sync.
7
+ */
8
+
9
+ import { resolveConfig, autoDetectLanguages } from '../config.js';
10
+ import { resolvePairs, QUALITY_TIERS } from '../pairs.js';
11
+ import { loadPlugins, resolvePluginForPair } from '../plugins.js';
12
+ import { DEFAULT_REGISTERS, getLanguageCard, getRegisterPresets, DEFAULT_REGISTER_FALLBACK } from '../registers.js';
13
+ import { loadTM, tmSize, TM_DIR, TM_FILENAME } from '../tm.js';
14
+ import { getMethod } from '../translate.js';
15
+ import { output } from '../output.js';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ // CLI version — read from package.json (the single source of truth), never a
20
+ // hardcoded literal. Mirrors bin/cli.js's --version read.
21
+ const { version: CLI_VERSION } = JSON.parse(
22
+ fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'),
23
+ );
24
+
25
+ /**
26
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
27
+ * @param {string} cwd - Working directory
28
+ * @returns {Promise<number>} Exit code (0 = success, 1 = error)
29
+ */
30
+ async function run(args, cwd) {
31
+ // --json: stdout carries exactly one JSON document. Quiet mode keeps the
32
+ // human status display off stdout.
33
+ const json = !!args.json;
34
+ if (json) output.setMode('quiet');
35
+
36
+ const config = resolveConfig(args, cwd);
37
+
38
+ // Auto-detect languages if not configured
39
+ let languages = config.resolvedLanguages;
40
+ if (Object.keys(languages).length === 0) {
41
+ languages = autoDetectLanguages(config);
42
+ }
43
+ config.resolvedLanguages = languages;
44
+
45
+ const pairs = resolvePairs(config);
46
+
47
+ // Load installed plugins to enrich status display
48
+ const plugins = loadPlugins(cwd);
49
+
50
+ if (json) {
51
+ return runJson(config, pairs, plugins, cwd);
52
+ }
53
+
54
+ output.raw('\n champollion v3 — Translation Status\n');
55
+ output.raw(` Input locale: ${config.inputLocale}`);
56
+ output.raw(` Locales dir: ${config.localesDir}`);
57
+ output.raw(` Default model: ${config.model}`);
58
+ if (config.temperature != null) {
59
+ output.raw(` Temperature: ${config.temperature}`);
60
+ }
61
+ output.raw(` Config version: ${config.version || '2 (legacy)'}`);
62
+
63
+
64
+ // Show installed plugins summary
65
+ if (plugins.size > 0) {
66
+ output.raw(` Plugins: ${plugins.size} installed`);
67
+ }
68
+
69
+ // Show Translation Memory status — gives users immediate feedback
70
+ // that caching is working and how much is cached.
71
+ const tmPath = path.join(cwd, TM_DIR, TM_FILENAME);
72
+ if (fs.existsSync(tmPath)) {
73
+ const tm = loadTM(cwd);
74
+ const count = tmSize(tm);
75
+ const stat = fs.statSync(tmPath);
76
+ const sizeKB = (stat.size / 1024).toFixed(1);
77
+ output.raw(` TM cache: ${count.toLocaleString()} entries (${sizeKB} KB)`);
78
+ } else {
79
+ output.raw(' TM cache: empty (will populate on first sync)');
80
+ }
81
+ output.raw('');
82
+
83
+ if (pairs.size === 0) {
84
+ output.raw(' No translation pairs configured.');
85
+ output.raw(' Run `champollion init` or add languages to your config.\n');
86
+ } else {
87
+ output.raw(` Translation Pairs (${pairs.size}):\n`);
88
+ for (const [pairKey, pairConfig] of pairs) {
89
+ const tier = QUALITY_TIERS[pairConfig.qualityTier];
90
+ const tierLabel = tier ? tier.label : pairConfig.qualityTier;
91
+ const dirLabel = pairConfig.dir === 'rtl' ? ' [RTL]' : '';
92
+ // Show the RESOLVED script decision, not the converter's existence.
93
+ // The old label printed the registry key ("[crk]"), which read as if
94
+ // conversion were happening whether or not it was.
95
+ const res = pairConfig.scriptResolution;
96
+ let scriptLabel = '';
97
+ if (res?.converterKey) scriptLabel = ` [script: ${res.script ?? res.converterKey}]`;
98
+ else if (res?.source === 'choice-required') scriptLabel = ' [script: CHOICE REQUIRED — see `champollion sync`]';
99
+ else if (res?.source === 'default' && pairConfig.scripts) scriptLabel = ` [script: ${res.script} (converter available)]`;
100
+ // Display arrow is a UI-only semantic element, not a data separator
101
+ output.raw(` ${pairKey} → ${pairConfig.name}${dirLabel}${scriptLabel}`);
102
+
103
+ // Resolve display model — direct providers may have model=null in the
104
+ // pair config, meaning the method class picks its own default at runtime.
105
+ // Show the resolved model so users know what will actually be used.
106
+ let displayModel = pairConfig.model;
107
+ if (!displayModel && pairConfig.method !== 'api') {
108
+ try {
109
+ const method = getMethod(pairConfig.method);
110
+ displayModel = method._getDefaultModel?.() || 'auto';
111
+ } catch { displayModel = 'auto'; }
112
+ }
113
+ const modelStr = pairConfig.method === 'api' ? '' : ` | model: ${displayModel || 'auto'}`;
114
+
115
+ // Show benchmarks if plugin has them; otherwise show tier as self-reported
116
+ let qualityStr = '';
117
+ if (pairConfig.methodPlugin) {
118
+ const resolvedPair = resolvePluginForPair(plugins, pairConfig);
119
+ const benchmarks = resolvedPair.pluginBenchmarks;
120
+ if (benchmarks && benchmarks[pairConfig.target]) {
121
+ const bm = benchmarks[pairConfig.target];
122
+ const parts = [];
123
+ if (bm.corpus_chrf) parts.push(`chrF++ ${bm.corpus_chrf}`);
124
+ if (bm.exact_match_rate) parts.push(`exact ${Math.round(bm.exact_match_rate * 100)}%`);
125
+ qualityStr = ` | benchmarks: ${parts.join(', ')}`;
126
+ } else {
127
+ qualityStr = ` | quality: ${tierLabel} (self-reported, no benchmarks)`;
128
+ }
129
+ } else {
130
+ qualityStr = ` | quality: ${tierLabel}`;
131
+ }
132
+ output.raw(` method: ${pairConfig.method}${modelStr}${qualityStr}`);
133
+
134
+ // Plugin badge — show name and version (benchmarks already shown above)
135
+ if (pairConfig.methodPlugin) {
136
+ const resolvedPair = resolvePluginForPair(plugins, pairConfig);
137
+ if (resolvedPair.pluginName) {
138
+ let pluginLine = ` [PLUGIN] ${resolvedPair.pluginName}`;
139
+ if (resolvedPair.pluginVersion) pluginLine += ` v${resolvedPair.pluginVersion}`;
140
+ output.raw(pluginLine);
141
+ }
142
+ }
143
+
144
+ // API badge
145
+ if (pairConfig.method === 'api') {
146
+ output.raw(' [API] Translation runs server-side (IP protected)');
147
+ }
148
+
149
+ // Google Translate badge
150
+ if (pairConfig.method === 'google-translate') {
151
+ output.raw(' Google Cloud Translation API');
152
+ }
153
+ }
154
+ output.raw('');
155
+
156
+ // Show active registers for each pair with structured card info
157
+ output.raw(' Registers:\n');
158
+ for (const [pairKey, pairConfig] of pairs) {
159
+ const card = getLanguageCard(pairConfig.target);
160
+ const presets = getRegisterPresets(pairConfig.target);
161
+ const systemLabel = card?.formality?.system ? ` (${card.formality.system})` : '';
162
+
163
+ // Use registerPreset for direct lookup — no fragile prompt text matching
164
+ const presetKey = pairConfig.registerPreset;
165
+ const matchedPreset = presetKey
166
+ ? presets.find(p => p.key === presetKey)
167
+ : null;
168
+
169
+ if (matchedPreset) {
170
+ // Known preset — show key, label, and formality system
171
+ const marker = matchedPreset.isDefault ? ' ★' : '';
172
+ output.raw(` ${pairConfig.target} ${matchedPreset.key}${marker}${systemLabel} — ${matchedPreset.label}`);
173
+ } else {
174
+ // Custom register text — show truncated text
175
+ const regText = pairConfig.register || DEFAULT_REGISTER_FALLBACK;
176
+ const truncated = regText.length > 55 ? regText.slice(0, 52) + '...' : regText;
177
+ output.raw(` ${pairConfig.target} (custom)${systemLabel} — "${truncated}"`);
178
+ }
179
+ }
180
+ output.raw('');
181
+ }
182
+
183
+ return 0;
184
+ }
185
+
186
+ /**
187
+ * --json: assemble everything the human display computes into a single
188
+ * JSON document — config summary, TM cache stats, pair graph with resolved
189
+ * models/tiers/plugins, and per-pair register info.
190
+ */
191
+ function runJson(config, pairs, plugins, cwd) {
192
+ const tmPath = path.join(cwd, TM_DIR, TM_FILENAME);
193
+ let tm = { exists: false, entries: 0, sizeBytes: 0 };
194
+ if (fs.existsSync(tmPath)) {
195
+ const stat = fs.statSync(tmPath);
196
+ tm = { exists: true, entries: tmSize(loadTM(cwd)), sizeBytes: stat.size };
197
+ }
198
+
199
+ const pairList = [];
200
+ for (const [pairKey, pairConfig] of pairs) {
201
+ const tier = QUALITY_TIERS[pairConfig.qualityTier];
202
+ const resolvedPair = resolvePluginForPair(plugins, pairConfig);
203
+
204
+ // Same display-model resolution as the human path: direct providers may
205
+ // have model=null (the method class picks its own default at runtime).
206
+ let displayModel = pairConfig.model;
207
+ if (!displayModel && pairConfig.method !== 'api') {
208
+ try {
209
+ const method = getMethod(pairConfig.method);
210
+ displayModel = method._getDefaultModel?.() || 'auto';
211
+ } catch { displayModel = 'auto'; }
212
+ }
213
+
214
+ const presets = getRegisterPresets(pairConfig.target);
215
+ const matchedPreset = pairConfig.registerPreset
216
+ ? presets.find(p => p.key === pairConfig.registerPreset)
217
+ : null;
218
+
219
+ pairList.push({
220
+ pair: pairKey,
221
+ target: pairConfig.target,
222
+ name: pairConfig.name,
223
+ method: pairConfig.method,
224
+ model: pairConfig.method === 'api' ? null : (displayModel || 'auto'),
225
+ qualityTier: pairConfig.qualityTier,
226
+ qualityTierLabel: tier ? tier.label : pairConfig.qualityTier,
227
+ dir: pairConfig.dir || 'ltr',
228
+ scripts: pairConfig.scripts || null,
229
+ // Resolved script decision (kept alongside legacy `scripts` for compat)
230
+ script: pairConfig.scriptResolution
231
+ ? {
232
+ resolved: pairConfig.scriptResolution.script,
233
+ source: pairConfig.scriptResolution.source,
234
+ converts: !!pairConfig.scriptResolution.converterKey,
235
+ }
236
+ : null,
237
+ plugin: resolvedPair.pluginName
238
+ ? { name: resolvedPair.pluginName, version: resolvedPair.pluginVersion || null }
239
+ : null,
240
+ benchmarks: resolvedPair.pluginBenchmarks?.[pairConfig.target] || null,
241
+ register: matchedPreset
242
+ ? { preset: matchedPreset.key, label: matchedPreset.label, isDefault: !!matchedPreset.isDefault }
243
+ : { preset: null, custom: pairConfig.register || DEFAULT_REGISTER_FALLBACK },
244
+ });
245
+ }
246
+
247
+ console.log(JSON.stringify({
248
+ command: 'status',
249
+ version: CLI_VERSION,
250
+ inputLocale: config.inputLocale,
251
+ localesDir: config.localesDir,
252
+ defaultModel: config.model,
253
+ temperature: config.temperature ?? null,
254
+ configVersion: config.version || '2 (legacy)',
255
+ format: config.format,
256
+ contentDir: config.contentDir || null,
257
+ plugins: plugins.size,
258
+ tm,
259
+ pairs: pairList,
260
+ }, null, 2));
261
+
262
+ return 0;
263
+ }
264
+
265
+ export { run };
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Command: submit
3
+ *
4
+ * The low-friction CLI front door to Champollion's REVIEW-GATED community
5
+ * submission path. It gathers the fields for a chosen submission type and
6
+ * prints a PRE-FILLED GitHub issue URL (optionally also writing a local
7
+ * submission JSON). The GitHub issue IS the human-review queue: a maintainer
8
+ * reviews each one against IP / license / sovereignty rules before adding the
9
+ * entry to the relevant source-of-truth. Nothing here is auto-approved, and
10
+ * this command never writes to a registry, a card, or a database.
11
+ *
12
+ * Six submission types (see lib/submit.mjs / .github/ISSUE_TEMPLATE/):
13
+ * dataset · resource · method · human-service · external-result · card-correction
14
+ *
15
+ * Interactive wizard when stdin is a TTY; fully scriptable via flags otherwise
16
+ * (--type + --values/--field + --attest, or --yes). See `champollion submit --help`.
17
+ *
18
+ * Exit codes: 0 = URL produced (or cancelled); 1 = invalid/incomplete request.
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import readline from 'node:readline';
24
+ import { output } from '../output.js';
25
+ import {
26
+ SUBMISSION_TYPES,
27
+ ATTESTATION_TEXT,
28
+ DEFAULT_REPO_BASE,
29
+ resolveType,
30
+ repoBaseFromRepository,
31
+ validateSubmission,
32
+ buildIssueUrl,
33
+ buildSubmissionRecord,
34
+ } from '../submit.mjs';
35
+
36
+ function isInteractive() {
37
+ return process.stdin.isTTY === true;
38
+ }
39
+
40
+ function ask(rl, question, defaultValue) {
41
+ return new Promise((resolve) => {
42
+ const suffix = defaultValue ? ` (${defaultValue})` : '';
43
+ rl.question(` ${question}${suffix}: `, (answer) => {
44
+ resolve(answer.trim() || defaultValue || '');
45
+ });
46
+ });
47
+ }
48
+
49
+ /** Resolve the repo base from cli/package.json, falling back to the default. */
50
+ function resolveRepoBase(args) {
51
+ if (args.repo) return repoBaseFromRepository(args.repo);
52
+ try {
53
+ const pkgUrl = new URL('../../package.json', import.meta.url);
54
+ const pkg = JSON.parse(fs.readFileSync(pkgUrl, 'utf-8'));
55
+ const repoUrl = pkg?.repository?.url || pkg?.repository;
56
+ return repoBaseFromRepository(repoUrl);
57
+ } catch {
58
+ return DEFAULT_REPO_BASE;
59
+ }
60
+ }
61
+
62
+ const BOOL_RE = /^(true|yes|1|y|on)$/i;
63
+
64
+ /** Coerce any string-y bool field values (from --values/--field) to real booleans. */
65
+ function coerceBools(type, values) {
66
+ for (const f of type.fields) {
67
+ if (f.kind === 'bool' && typeof values[f.id] === 'string') {
68
+ values[f.id] = BOOL_RE.test(values[f.id].trim());
69
+ }
70
+ }
71
+ return values;
72
+ }
73
+
74
+ // ── Interactive wizard ──────────────────────────────────────────────────────
75
+
76
+ async function pickType(rl, args) {
77
+ const pre = resolveType(args.type);
78
+ if (pre) return pre;
79
+ console.log('');
80
+ console.log(' What are you submitting?');
81
+ console.log('');
82
+ SUBMISSION_TYPES.forEach((t, i) => {
83
+ console.log(` ${i + 1}. ${t.label}`);
84
+ console.log(` ${t.blurb}`);
85
+ });
86
+ console.log('');
87
+ const choice = await ask(rl, 'Choose a type (number or name)', '1');
88
+ return resolveType(choice) || SUBMISSION_TYPES[0];
89
+ }
90
+
91
+ async function runInteractive(rl, args) {
92
+ console.log('');
93
+ console.log(' champollion — Submit to the index');
94
+ console.log(' ════════════════════════════════════════════════');
95
+ console.log('');
96
+ console.log(' Every submission is HUMAN-REVIEWED for IP / license / sovereignty');
97
+ console.log(' compliance before anything is added — nothing is auto-approved. This');
98
+ console.log(' command just gathers the fields and hands you a pre-filled GitHub issue.');
99
+
100
+ const type = await pickType(rl, args);
101
+ console.log('');
102
+ console.log(` ${type.label}`);
103
+ console.log(' ────────────────────────────────────────────────');
104
+
105
+ const values = {};
106
+ for (const f of type.fields) {
107
+ if (f.kind === 'bool') continue; // handled together at the end
108
+ if (f.kind === 'choice') {
109
+ console.log('');
110
+ console.log(` ${f.label}${f.required ? ' (required)' : ''}`);
111
+ f.choices.forEach((c, i) => console.log(` ${i + 1}. ${c}`));
112
+ const pick = await ask(rl, 'Choose', '');
113
+ if (pick) {
114
+ const idx = /^\d+$/.test(pick) ? Number(pick) - 1 : f.choices.findIndex((c) => c.toLowerCase() === pick.toLowerCase());
115
+ values[f.id] = f.choices[idx] != null ? f.choices[idx] : pick;
116
+ }
117
+ } else {
118
+ let v = await ask(rl, `${f.label}${f.required ? ' (required)' : ''}`, '');
119
+ if (!v && f.required) v = await ask(rl, `↳ ${f.id} is required`, '');
120
+ if (v) values[f.id] = v;
121
+ }
122
+ }
123
+
124
+ // Confirmations (attestation + any per-type consent) — must be affirmative.
125
+ console.log('');
126
+ console.log(' Confirmation');
127
+ console.log(' ────────────────────────────────────────────────');
128
+ for (const f of type.fields) {
129
+ if (f.kind !== 'bool') continue;
130
+ const yn = await ask(rl, `${f.label}\n Confirm? (y/N)`, 'N');
131
+ values[f.id] = BOOL_RE.test(yn.trim());
132
+ }
133
+ return { type, values };
134
+ }
135
+
136
+ // ── Non-interactive (flags) ─────────────────────────────────────────────────
137
+
138
+ function gatherFromFlags(type, args) {
139
+ const values = {};
140
+ if (args.values) {
141
+ try {
142
+ const parsed = JSON.parse(args.values);
143
+ if (parsed && typeof parsed === 'object') Object.assign(values, parsed);
144
+ } catch {
145
+ // Surface as a validation error path: leave values empty so required
146
+ // fields are reported missing rather than silently swallowing bad JSON.
147
+ output.warn('--values was not valid JSON; ignoring it.');
148
+ }
149
+ }
150
+ const fieldFlags = args.field == null ? [] : (Array.isArray(args.field) ? args.field : [args.field]);
151
+ for (const pair of fieldFlags) {
152
+ const s = String(pair);
153
+ const eq = s.indexOf('=');
154
+ if (eq === -1) continue;
155
+ values[s.slice(0, eq).trim()] = s.slice(eq + 1);
156
+ }
157
+ // --attest confirms every required confirmation at once; --consent is a
158
+ // narrower alias for the human-service provider-consent box.
159
+ if (args.attest) for (const f of type.fields) if (f.kind === 'bool') values[f.id] = true;
160
+ if (args.consent) values.consent = true;
161
+ return coerceBools(type, values);
162
+ }
163
+
164
+ // ── Main ────────────────────────────────────────────────────────────────────
165
+
166
+ async function run(args, cwd) {
167
+ if (args.help) {
168
+ showHelp();
169
+ return 0;
170
+ }
171
+ if (args.list) {
172
+ if (args.json) {
173
+ console.log(JSON.stringify({
174
+ types: SUBMISSION_TYPES.map((t) => ({
175
+ key: t.key, label: t.label, template: t.template,
176
+ target_ssot: t.ssot,
177
+ fields: t.fields.map((f) => ({ id: f.id, kind: f.kind, required: !!f.required })),
178
+ })),
179
+ attestation: ATTESTATION_TEXT,
180
+ }, null, 2));
181
+ return 0;
182
+ }
183
+ console.log('');
184
+ console.log(' Submission types (champollion submit --type <key>):');
185
+ console.log('');
186
+ SUBMISSION_TYPES.forEach((t, i) => {
187
+ console.log(` ${i + 1}. ${t.key} — ${t.label}`);
188
+ console.log(` ${t.blurb}`);
189
+ console.log(` → reviewed, then added to: ${t.ssot}`);
190
+ });
191
+ console.log('');
192
+ console.log(' Every submission is human-reviewed; nothing is auto-approved.');
193
+ console.log('');
194
+ return 0;
195
+ }
196
+
197
+ const repoBase = resolveRepoBase(args);
198
+
199
+ // Gather the submission (interactive wizard or flags).
200
+ let type;
201
+ let values;
202
+ if (!args.yes && isInteractive()) {
203
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
204
+ try {
205
+ ({ type, values } = await runInteractive(rl, args));
206
+ } finally {
207
+ rl.close();
208
+ }
209
+ } else {
210
+ type = resolveType(args.type);
211
+ if (!type) {
212
+ output.error('Choose a submission type with --type (run `champollion submit --list`).');
213
+ return 1;
214
+ }
215
+ values = gatherFromFlags(type, args);
216
+ }
217
+
218
+ // Validate — collect every problem, fail loud.
219
+ const v = validateSubmission(type, values);
220
+ if (!v.ok) {
221
+ console.error('[ERR] Cannot build this submission:');
222
+ for (const e of v.errors) console.error(` • ${e}`);
223
+ console.error('');
224
+ console.error(' The compliance attestation is required. Re-run and confirm it');
225
+ console.error(' (interactively, or pass --attest with the other fields).');
226
+ return 1;
227
+ }
228
+
229
+ const issueUrl = buildIssueUrl({ type, values, repoBase });
230
+ const generatedAt = new Date().toISOString();
231
+ const record = buildSubmissionRecord({ type, values, repoBase, generatedAt });
232
+
233
+ // Optionally save a local content-free copy.
234
+ let localPath = null;
235
+ if (args.out) {
236
+ localPath = path.resolve(cwd, args.out);
237
+ try {
238
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
239
+ fs.writeFileSync(localPath, JSON.stringify(record, null, 2) + '\n', 'utf-8');
240
+ } catch (e) {
241
+ output.error(`Could not write the local submission file to ${localPath}: ${e.message}`);
242
+ return 1;
243
+ }
244
+ }
245
+
246
+ // ── Report ──
247
+ if (args.json) {
248
+ console.log(JSON.stringify({
249
+ ok: true,
250
+ type: type.key,
251
+ target_ssot: type.ssot,
252
+ title: `${type.titlePrefix}${values[type.fields.find((f) => f.primary)?.id] ?? ''}`.trimEnd(),
253
+ labels: type.labels,
254
+ attestationConfirmed: values.attestation === true,
255
+ issueUrl,
256
+ localPath,
257
+ review: 'human-reviewed — not auto-approved',
258
+ }, null, 2));
259
+ return 0;
260
+ }
261
+
262
+ console.log('');
263
+ output.ok(`Submission ready: ${type.label}`);
264
+ console.log('');
265
+ console.log(' This will be HUMAN-REVIEWED for IP / license / sovereignty compliance');
266
+ console.log(' before anything is added to the index — nothing is auto-approved.');
267
+ console.log('');
268
+ console.log(' Open this pre-filled GitHub issue to file it (review the attestation box');
269
+ console.log(' in the browser, then submit):');
270
+ console.log('');
271
+ console.log(` ${issueUrl}`);
272
+ console.log('');
273
+ if (localPath) {
274
+ console.log(` Saved a local copy: ${localPath}`);
275
+ console.log('');
276
+ }
277
+ return 0;
278
+ }
279
+
280
+ function showHelp() {
281
+ console.log(`
282
+ champollion submit — Propose an entry for the Champollion index (review-gated)
283
+
284
+ USAGE
285
+ champollion submit [options]
286
+
287
+ DESCRIPTION
288
+ Gather the fields for a submission and print a PRE-FILLED GitHub issue URL
289
+ (optionally writing a local submission JSON). The GitHub issue is the
290
+ human-review queue: a maintainer reviews every submission against IP /
291
+ license / sovereignty rules before adding it to a source-of-truth. Nothing
292
+ is auto-approved, and this command never writes to a registry or database.
293
+
294
+ Interactive when run in a terminal; fully scriptable with flags (or --yes).
295
+
296
+ SUBMISSION TYPES (--type)
297
+ dataset A benchmark / evaluation corpus (metadata + fetch pointer only).
298
+ resource A dictionary / archive / app / FST / tool (a pointer, not a copy).
299
+ method A translation method / MT engine / LLM provider.
300
+ human-service An opt-in human translation provider (contact PII stays out-of-band).
301
+ external-result A published result from another system/paper (cited, never re-hosted).
302
+
303
+ OPTIONS
304
+ --type <key> Submission type (see --list). Required when non-interactive.
305
+ --values <json> JSON object of field id → value (e.g. '{"dataset-name":"X"}').
306
+ --field <id=val> Set one field (repeatable): --field source-url=https://…
307
+ --attest Confirm the required compliance attestation (and consent).
308
+ --consent human-service: confirm the provider's listing consent.
309
+ --out <path> Also write a local content-free submission JSON here.
310
+ --repo <url> Override the GitHub repo for the issue URL (advanced/testing).
311
+ --list List the submission types (add --json for JSON).
312
+ --json Machine-readable output (for agents).
313
+ --yes Non-interactive; take values from flags.
314
+
315
+ ATTESTATION (required on every submission)
316
+ "${ATTESTATION_TEXT}"
317
+
318
+ EXAMPLES
319
+ champollion submit # interactive wizard
320
+ champollion submit --list # see the types
321
+ # A dataset submission, fully from flags:
322
+ champollion submit --yes --type dataset --attest \\
323
+ --field dataset-name="GlobalVoices eng-amh" \\
324
+ --field pairs=eng-amh --field license=CC-BY-4.0 \\
325
+ --field source-url=https://globalvoices.org
326
+ # An external result, with a saved local copy:
327
+ champollion submit --yes --type external-result --attest --out ./submission.json \\
328
+ --values '{"system-name":"NLLB-200","pairs":"eng-crk","dataset":"FLORES-200","metric":"chrF++","score":"28.4","citation":"https://arxiv.org/abs/2207.04672"}'
329
+ `);
330
+ }
331
+
332
+ export { run };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Command: sync
3
+ *
4
+ * Translates & syncs all locale files based on the project config.
5
+ * Delegates entirely to lib/sync.runSync — this module just bridges
6
+ * CLI arguments to the sync engine's options object.
7
+ */
8
+
9
+ import { runSync } from '../sync.js';
10
+ import { output } from '../output.js';
11
+
12
+ /**
13
+ * Map a runSync result to a process exit code.
14
+ *
15
+ * Exit codes (documented contract for CI pipelines + agents):
16
+ * 0 — clean: everything translated, verification passed.
17
+ * 1 — catastrophic: failures occurred and NOTHING was translated.
18
+ * This is the bad-key / 401 case — every locale failed, so
19
+ * totalProcessed is 0. The old gate (`totalFailed > 0 &&
20
+ * totalProcessed > 0`) returned 0 here, so a `sync` wired into a
21
+ * pre-deploy hook went green while silently shipping English
22
+ * fallbacks. Any failure with zero progress must be loud.
23
+ * 2 — partial: some keys translated but others failed, OR verification
24
+ * found errors. The run did real work but isn't clean.
25
+ * ALSO used for a --max-cost abort: the pre-run estimate exceeded the
26
+ * cap (or was unknowable — unknown ≠ free), so the sync deliberately
27
+ * stopped BEFORE any API call. Not catastrophic (nothing broke,
28
+ * nothing was spent), but definitely not a clean pass either.
29
+ *
30
+ * Pure and total (never throws) so it can be unit-tested directly.
31
+ *
32
+ * @param {{ totalProcessed?: number, totalFailed?: number, verifyErrors?: number, maxCostAborted?: boolean } | null | undefined} result
33
+ * @returns {number} exit code
34
+ */
35
+ function computeExitCode(result) {
36
+ if (!result) return 0;
37
+
38
+ // --max-cost abort: deliberate pre-run stop, exit 2 by documented contract.
39
+ if (result.maxCostAborted) return 2;
40
+
41
+ const processed = result.totalProcessed || 0;
42
+ const failed = result.totalFailed || 0;
43
+ const verifyErrors = result.verifyErrors || 0;
44
+
45
+ // Catastrophic: failures with no successful work. A fully-failed sync
46
+ // (bad key, every locale errored) must never exit 0.
47
+ if (failed > 0 && processed === 0) return 1;
48
+
49
+ // Partial: real work done but something failed, or files didn't verify.
50
+ if (failed > 0 || verifyErrors > 0) return 2;
51
+
52
+ return 0;
53
+ }
54
+
55
+ /**
56
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
57
+ * @param {string} cwd - Working directory
58
+ * @returns {Promise<number>} Exit code: 0 = success, 1 = error, 2 = partial failure
59
+ */
60
+ async function run(args, cwd) {
61
+ // Set output mode before any sync work begins.
62
+ // --json: machine-readable NDJSON, one JSON object per line.
63
+ // --quiet: suppress info/ok messages, show only warnings and errors.
64
+ if (args.json) output.setMode('json');
65
+ else if (args.quiet) output.setMode('quiet');
66
+
67
+ let result;
68
+ try {
69
+ result = await runSync({
70
+ dryRun: !!args.dry,
71
+ cwd,
72
+ cliArgs: args,
73
+ });
74
+ } catch (err) {
75
+ // In --json mode every outcome must be machine-parseable — including a
76
+ // hard error (missing locales dir, preflight failure). Emit a structured
77
+ // summary object instead of letting the dispatcher print a bare [ERR]
78
+ // line that an agent would have to regex.
79
+ if (args.json) {
80
+ output.summary({ command: 'sync', ok: false, error: err.message });
81
+ return 1;
82
+ }
83
+ throw err;
84
+ }
85
+
86
+ return computeExitCode(result);
87
+ }
88
+
89
+ export { run, computeExitCode };