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,138 @@
1
+ /**
2
+ * Command: wrap
3
+ *
4
+ * Auto-wraps hardcoded user-facing strings in t() calls.
5
+ * Includes safety gates:
6
+ * 1. Git-clean check (skip in dry-run)
7
+ * 2. Automatic backup to .champollion-backup/
8
+ * 3. Diff preview before each file write
9
+ * 4. --undo support to restore from backup
10
+ *
11
+ * After wrapping, adds the extracted keys to locale files.
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { resolveConfig } from '../config.js';
17
+ import { detectFramework, walkDir } from '../lint.js';
18
+ import {
19
+ checkGitClean, createBackup, restoreFromBackup,
20
+ processFile, generateDiff, addKeysToLocales,
21
+ } from '../autofix.js';
22
+ import { output } from '../output.js';
23
+
24
+ /**
25
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
26
+ * @param {string} cwd - Working directory
27
+ * @returns {Promise<number>} Exit code (0 = success, 1 = error)
28
+ */
29
+ async function run(args, cwd) {
30
+ // Gate: Undo mode — restore from backup and exit
31
+ if (args.undo) {
32
+ const { restored, errors } = restoreFromBackup(cwd);
33
+ if (errors.length > 0) {
34
+ for (const err of errors) output.error(err);
35
+ return 1;
36
+ }
37
+ output.ok(`Restored ${restored} file(s) from .champollion-backup/`);
38
+ return 0;
39
+ }
40
+
41
+ const isDry = !!args.dry;
42
+
43
+ // Gate: Git-clean check (skip in dry-run mode)
44
+ if (!isDry) {
45
+ const { clean, status } = checkGitClean(cwd);
46
+ if (!clean) {
47
+ output.error('Git working tree is not clean. Commit or stash first.');
48
+ output.raw(` ${status.split('\n').slice(0, 5).join('\n ')}`);
49
+ return 1;
50
+ }
51
+ }
52
+
53
+ const config = resolveConfig(args, cwd);
54
+ const framework = detectFramework(cwd);
55
+ const minLength = parseInt(args['min-length'] || 2, 10);
56
+
57
+ output.raw(`\n champollion wrap${isDry ? ' (dry run)' : ''}`);
58
+ output.raw(` Framework: ${framework.name}`);
59
+ output.raw('');
60
+
61
+ // Find source files
62
+ let sourceFiles = [];
63
+ const srcDir = args.src || null;
64
+ if (srcDir) {
65
+ sourceFiles = walkDir(path.resolve(cwd, srcDir), framework.extensions, ['node_modules', '.next', 'dist', 'build', '.git']);
66
+ } else {
67
+ for (const dir of framework.srcDirs) {
68
+ sourceFiles.push(...walkDir(path.resolve(cwd, dir), framework.extensions, ['node_modules', '.next', 'dist', 'build', '.git']));
69
+ }
70
+ }
71
+
72
+ if (sourceFiles.length === 0) {
73
+ output.info('No source files found to process.');
74
+ return 0;
75
+ }
76
+
77
+ // Gate: Backup (only if not dry-run)
78
+ if (!isDry) {
79
+ createBackup(sourceFiles, cwd);
80
+ output.info('Backup created at .champollion-backup/');
81
+ }
82
+
83
+ let totalFixes = 0;
84
+ let totalAmbiguous = 0;
85
+ const allFixes = [];
86
+
87
+ for (const filePath of sourceFiles) {
88
+ const content = fs.readFileSync(filePath, 'utf-8');
89
+ const relPath = path.relative(cwd, filePath);
90
+
91
+ const { modified, fixes, ambiguous } = processFile(
92
+ content, framework.name, framework, minLength
93
+ );
94
+
95
+ if (fixes.length === 0 && ambiguous.length === 0) continue;
96
+
97
+ // Show diff for applied fixes
98
+ if (fixes.length > 0) {
99
+ const diff = generateDiff(content, modified, relPath);
100
+ if (diff) output.raw(diff);
101
+ }
102
+
103
+ // Report ambiguous cases that need human review
104
+ for (const item of ambiguous) {
105
+ output.warn(`${relPath}:${item.line} — "${item.text}" (${item.reason})`);
106
+ }
107
+
108
+ // Write only if not dry-run
109
+ if (!isDry && fixes.length > 0) {
110
+ fs.writeFileSync(filePath, modified, 'utf-8');
111
+ }
112
+
113
+ totalFixes += fixes.length;
114
+ totalAmbiguous += ambiguous.length;
115
+ allFixes.push(...fixes);
116
+ }
117
+
118
+ // Add extracted keys to locale files (only if not dry-run)
119
+ if (!isDry && allFixes.length > 0) {
120
+ const targetLocales = config.languages || [];
121
+ addKeysToLocales(allFixes, config.localesDir, config.inputLocale, targetLocales);
122
+ output.info(`Added ${allFixes.length} key(s) to locale files`);
123
+ }
124
+
125
+ output.raw('');
126
+ output.ok(`${totalFixes} fix(es) applied${isDry ? ' (dry run)' : ''}`);
127
+ if (totalAmbiguous > 0) {
128
+ output.warn(`${totalAmbiguous} ambiguous case(s) flagged for review`);
129
+ }
130
+ if (!isDry && totalFixes > 0) {
131
+ output.raw(' Run `champollion wrap --undo` to revert');
132
+ }
133
+ output.raw('');
134
+
135
+ return 0;
136
+ }
137
+
138
+ export { run };
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Command: xliff
3
+ *
4
+ * Exports and imports XLIFF 1.2 files for professional translator review.
5
+ *
6
+ * Subcommands:
7
+ * export Generate a .xliff file from source + target locale files.
8
+ * import Merge reviewed translations from a .xliff file back into locale files.
9
+ *
10
+ * WHY THIS EXISTS:
11
+ * XLIFF is the industry-standard exchange format between translation tools
12
+ * and CAT (Computer-Assisted Translation) platforms. This command lets users:
13
+ * 1. Export translations for professional review in memoQ/SDL Trados/Phrase
14
+ * 2. Import reviewed translations back, then run sync to fill gaps
15
+ * 3. Integrate champollion into existing localization workflows
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { resolveConfig } from '../config.js';
21
+ import { exportXLIFF, importXLIFF } from '../xliff.js';
22
+ import { compileNoTranslate } from '../no-translate.js';
23
+ import { flattenKeys, setNestedValue } from '../flatten.js';
24
+ import { readLocaleFile, writeLocaleFile, detectFormatFromDir, detectYAMLStyle, getExtension } from '../format.js';
25
+ import { output } from '../output.js';
26
+
27
+ /** Default output directory for exported XLIFF files */
28
+ const XLIFF_DIR = '.champollion/xliff';
29
+
30
+ /**
31
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
32
+ * @param {string} cwd - Working directory
33
+ * @returns {Promise<number>} Exit code (0 = success, 1 = error)
34
+ */
35
+ async function run(args, cwd) {
36
+ const sub = args._[1];
37
+
38
+ // --json: stdout carries exactly one JSON document. Quiet mode keeps the
39
+ // human raws off stdout; warnings/errors still reach stderr.
40
+ if (args.json) output.setMode('quiet');
41
+
42
+ if (!sub || sub === 'help') {
43
+ printUsage();
44
+ return 0;
45
+ }
46
+
47
+ if (sub === 'export') {
48
+ return runExport(args, cwd);
49
+ }
50
+
51
+ if (sub === 'import') {
52
+ return runImport(args, cwd);
53
+ }
54
+
55
+ output.error(`Unknown subcommand: "${sub}". Run "champollion xliff --help" for usage.`);
56
+ return 1;
57
+ }
58
+
59
+ // -----------------------------------------------------------------
60
+ // xliff export
61
+ // -----------------------------------------------------------------
62
+
63
+ /**
64
+ * Export source + target locale as XLIFF 1.2.
65
+ *
66
+ * Reads the source locale and the specified target locale,
67
+ * generates XLIFF, and writes it to .champollion/xliff/<locale>.xliff
68
+ * (or a custom path via --out).
69
+ */
70
+ function runExport(args, cwd) {
71
+ const locale = args.locale;
72
+ if (!locale) {
73
+ output.error('Missing required --locale flag. Example: champollion xliff export --locale fr');
74
+ return 1;
75
+ }
76
+
77
+ const config = resolveConfig(args, cwd);
78
+ const format = config.format !== 'auto'
79
+ ? config.format
80
+ : detectFormatFromDir(config.localesDir);
81
+ const ext = getExtension(format);
82
+
83
+ // Load source locale
84
+ const sourcePath = path.join(config.localesDir, `${config.inputLocale}${ext}`);
85
+ if (!fs.existsSync(sourcePath)) {
86
+ output.error(`Source locale file not found: ${sourcePath}`);
87
+ return 1;
88
+ }
89
+ const sourceRaw = readLocaleFile(sourcePath, format);
90
+ const sourceFlat = format === 'json' ? flattenKeys(sourceRaw) : { ...sourceRaw };
91
+
92
+ // Load target locale (may not exist yet — that's fine, all targets will be empty)
93
+ const targetPath = path.join(config.localesDir, `${locale}${ext}`);
94
+ let targetFlat = {};
95
+ if (fs.existsSync(targetPath)) {
96
+ const targetRaw = readLocaleFile(targetPath, format);
97
+ targetFlat = format === 'json' ? flattenKeys(targetRaw) : { ...targetRaw };
98
+ }
99
+
100
+ // Generate XLIFF
101
+ const xliff = exportXLIFF({
102
+ sourceLocale: config.inputLocale,
103
+ targetLocale: locale,
104
+ sourceFlat,
105
+ targetFlat,
106
+ original: `${config.inputLocale}${ext}`,
107
+ noTranslate: compileNoTranslate(config),
108
+ });
109
+
110
+ // Determine output path
111
+ const outDir = args.out || path.join(cwd, XLIFF_DIR);
112
+ const outPath = args.out
113
+ ? (args.out.endsWith('.xliff') ? args.out : path.join(args.out, `${locale}.xliff`))
114
+ : path.join(outDir, `${locale}.xliff`);
115
+
116
+ // Write
117
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
118
+ fs.writeFileSync(outPath, xliff, 'utf-8');
119
+
120
+ const keyCount = Object.keys(sourceFlat).filter(k => typeof sourceFlat[k] === 'string').length;
121
+ const translatedCount = Object.keys(targetFlat).filter(k =>
122
+ typeof targetFlat[k] === 'string' && targetFlat[k].length > 0
123
+ ).length;
124
+
125
+ if (args.json) {
126
+ console.log(JSON.stringify({
127
+ command: 'xliff',
128
+ action: 'export',
129
+ locale,
130
+ path: outPath,
131
+ exported: keyCount,
132
+ translated: translatedCount,
133
+ pending: keyCount - translatedCount,
134
+ }, null, 2));
135
+ return 0;
136
+ }
137
+
138
+ output.raw(`\n ✓ Exported XLIFF 1.2 for ${config.inputLocale} → ${locale}`);
139
+ output.raw(` Keys: ${keyCount}`);
140
+ output.raw(` Translated: ${translatedCount}`);
141
+ output.raw(` Pending: ${keyCount - translatedCount}`);
142
+ output.raw(` Written to: ${path.relative(cwd, outPath)}`);
143
+ output.raw('');
144
+ output.raw(' Send this file to your translator or open it in a CAT tool');
145
+ output.raw(' (memoQ, SDL Trados, Phrase, etc.). When reviewed, import it back:');
146
+ output.raw(` champollion xliff import ${path.relative(cwd, outPath)}\n`);
147
+
148
+ return 0;
149
+ }
150
+
151
+ // -----------------------------------------------------------------
152
+ // xliff import
153
+ // -----------------------------------------------------------------
154
+
155
+ /**
156
+ * Import reviewed translations from an XLIFF file back into the locale file.
157
+ *
158
+ * Reads the XLIFF, extracts target translations, and merges them
159
+ * into the corresponding locale file. Only keys present in the
160
+ * XLIFF are overwritten — existing translations for other keys
161
+ * are preserved.
162
+ */
163
+ function runImport(args, cwd) {
164
+ const xliffPath = args._[2];
165
+ if (!xliffPath) {
166
+ output.error('Missing XLIFF file path. Example: champollion xliff import .champollion/xliff/fr.xliff');
167
+ return 1;
168
+ }
169
+
170
+ const resolvedPath = path.resolve(cwd, xliffPath);
171
+ if (!fs.existsSync(resolvedPath)) {
172
+ output.error(`XLIFF file not found: ${resolvedPath}`);
173
+ return 1;
174
+ }
175
+
176
+ const xliffContent = fs.readFileSync(resolvedPath, 'utf-8');
177
+ const { translations, metadata } = importXLIFF(xliffContent);
178
+
179
+ if (Object.keys(translations).length === 0) {
180
+ if (args.json) {
181
+ console.log(JSON.stringify({
182
+ command: 'xliff', action: 'import', locale: metadata.targetLocale || null,
183
+ path: resolvedPath, imported: 0, updated: 0, added: 0, unchanged: 0,
184
+ note: 'No translated entries found in the XLIFF file.',
185
+ }, null, 2));
186
+ return 0;
187
+ }
188
+ output.raw('\n No translated entries found in the XLIFF file.');
189
+ output.raw(' Make sure the XLIFF contains <target> elements with content.\n');
190
+ return 0;
191
+ }
192
+
193
+ const locale = metadata.targetLocale;
194
+ if (!locale) {
195
+ output.error('Could not determine target locale from XLIFF metadata.');
196
+ output.error('The <file> element must have a target-language attribute.');
197
+ return 1;
198
+ }
199
+
200
+ const config = resolveConfig(args, cwd);
201
+ const format = config.format !== 'auto'
202
+ ? config.format
203
+ : detectFormatFromDir(config.localesDir);
204
+ const ext = getExtension(format);
205
+ const targetPath = path.join(config.localesDir, `${locale}${ext}`);
206
+
207
+ const dryRun = args.dry || false;
208
+
209
+ // Load existing target locale (or start fresh)
210
+ let existingFlat = {};
211
+ let existingRaw = {};
212
+ if (fs.existsSync(targetPath)) {
213
+ existingRaw = readLocaleFile(targetPath, format);
214
+ existingFlat = format === 'json' ? flattenKeys(existingRaw) : { ...existingRaw };
215
+ }
216
+
217
+ // Merge: XLIFF translations overwrite existing values
218
+ let updated = 0;
219
+ let added = 0;
220
+ for (const [key, value] of Object.entries(translations)) {
221
+ if (key in existingFlat) {
222
+ if (existingFlat[key] !== value) {
223
+ updated++;
224
+ }
225
+ } else {
226
+ added++;
227
+ }
228
+ existingFlat[key] = value;
229
+ }
230
+
231
+ const skipped = Object.keys(translations).length - updated - added;
232
+
233
+ if (dryRun) {
234
+ if (args.json) {
235
+ console.log(JSON.stringify({
236
+ command: 'xliff', action: 'import', dryRun: true, locale, path: targetPath,
237
+ imported: Object.keys(translations).length, updated, added, unchanged: skipped,
238
+ }, null, 2));
239
+ return 0;
240
+ }
241
+ output.raw(`\n [DRY RUN] Would import ${Object.keys(translations).length} translations for ${locale}`);
242
+ output.raw(` Updated: ${updated} (changed from existing)`);
243
+ output.raw(` Added: ${added} (new keys)`);
244
+ output.raw(` Unchanged: ${skipped}`);
245
+ output.raw(` Target: ${path.relative(cwd, targetPath)}\n`);
246
+ return 0;
247
+ }
248
+
249
+ // Write back
250
+ if (format === 'json') {
251
+ // Re-nest the flat map into JSON structure using setNestedValue
252
+ const nested = {};
253
+ for (const [key, value] of Object.entries(existingFlat)) {
254
+ setNestedValue(nested, key, value);
255
+ }
256
+ fs.writeFileSync(targetPath, JSON.stringify(nested, null, 2) + '\n', 'utf-8');
257
+ } else {
258
+ // TOML/YAML — write the merged flat map back through the exact same
259
+ // writer sync uses (lib/format.js writeLocaleFile), so an imported file
260
+ // keeps the serialization style of a synced project. YAML needs the
261
+ // style probe sync performs on the SOURCE locale file (Hugo plural
262
+ // sub-keys vs standard nesting); fall back to the target file when the
263
+ // source is missing.
264
+ let yamlStyle = null;
265
+ if (format === 'yaml') {
266
+ const sourcePath = path.join(config.localesDir, `${config.inputLocale}${ext}`);
267
+ const stylePath = fs.existsSync(sourcePath) ? sourcePath
268
+ : (fs.existsSync(targetPath) ? targetPath : null);
269
+ yamlStyle = stylePath ? detectYAMLStyle(fs.readFileSync(stylePath, 'utf-8')) : null;
270
+ }
271
+ writeLocaleFile(targetPath, existingFlat, format, existingFlat, yamlStyle);
272
+ }
273
+
274
+ if (args.json) {
275
+ console.log(JSON.stringify({
276
+ command: 'xliff', action: 'import', dryRun: false, locale, path: targetPath,
277
+ imported: Object.keys(translations).length, updated, added, unchanged: skipped,
278
+ }, null, 2));
279
+ return 0;
280
+ }
281
+
282
+ output.raw(`\n ✓ Imported ${Object.keys(translations).length} translations for ${locale}`);
283
+ output.raw(` Updated: ${updated} (changed from existing)`);
284
+ output.raw(` Added: ${added} (new keys)`);
285
+ output.raw(` Unchanged: ${skipped}`);
286
+ output.raw(` Written to: ${path.relative(cwd, targetPath)}\n`);
287
+
288
+ return 0;
289
+ }
290
+
291
+ // -----------------------------------------------------------------
292
+ // Usage
293
+ // -----------------------------------------------------------------
294
+
295
+ function printUsage() {
296
+ output.raw(`
297
+ champollion xliff — XLIFF 1.2 export/import for professional review
298
+
299
+ SUBCOMMANDS
300
+ export Generate a .xliff file for a target locale
301
+ import Merge reviewed .xliff translations back into locale files
302
+
303
+ OPTIONS
304
+ --locale <code> Target locale for export (required for export)
305
+ --out <path> Custom output path or directory (export)
306
+ --dry Preview import without writing files
307
+ --json Single JSON document output (export/import)
308
+ --config <path> Path to config file
309
+
310
+ Import writes back in the project's locale format (JSON, TOML, or YAML)
311
+ using the same serializers sync uses.
312
+
313
+ WORKFLOW
314
+ 1. Export: champollion xliff export --locale fr
315
+ 2. Review: Send .champollion/xliff/fr.xliff to translator or CAT tool
316
+ 3. Import: champollion xliff import .champollion/xliff/fr.xliff
317
+ 4. Sync: champollion sync (fills remaining gaps)
318
+
319
+ EXAMPLES
320
+ champollion xliff export --locale fr
321
+ champollion xliff export --locale ja --out ./review/
322
+ champollion xliff import .champollion/xliff/fr.xliff
323
+ champollion xliff import ./reviewed.xliff --dry
324
+ `);
325
+ }
326
+
327
+ export { run };
@@ -0,0 +1,235 @@
1
+ /**
2
+ * commercial-eligibility.js — the ONE answer to "may this method be routed to
3
+ * in a COMMERCIAL lane?", enforced at routing time.
4
+ *
5
+ * WHY THIS EXISTS. `commercialReady` was declared in three places that had
6
+ * already drifted: the cross-runtime SSOT (`shared/method-registry.json`),
7
+ * a hardcoded table in `lib/provenance.js`, and each method loader's own
8
+ * `getProvenance()`. LibreTranslate was `AGPL-3.0` + `commercialReady: true`
9
+ * in two of them and `false` in the SSOT — i.e. the AGPL boundary CLAUDE.md
10
+ * calls a never-cross rule was crossable by reading the wrong table. This
11
+ * module makes the SSOT win everywhere and makes the answer enforceable
12
+ * instead of advisory.
13
+ *
14
+ * THE RULES, in precedence order:
15
+ *
16
+ * 1. **The shared registry wins.** If `shared/method-registry.json` has an
17
+ * entry for the method (by canonical key OR `cli_name`), its
18
+ * `commercialReady` is the answer. Nothing overrides it upward — a
19
+ * plugin cannot declare itself commercial-ready past an SSOT `false`.
20
+ * 2. **A plugin may only ever restrict.** `pluginProvenance.commercialReady
21
+ * === false` blocks even when the registry says true (a coached plugin
22
+ * can carry NC coaching data the engine knows nothing about).
23
+ * 3. **Methods the registry deliberately does not model** — CLI-only
24
+ * pseudo-methods with no cross-runtime adapter — are declared in
25
+ * CLI_ONLY below, each with its reason.
26
+ * 4. **Unknown is INELIGIBLE.** No registry entry, no declaration, no
27
+ * plugin provenance → not routable commercially. Fail-safe, matching
28
+ * `license-gate.mjs`'s doctrine for sources ("an unknown / unstated /
29
+ * mixed source is treated as RESTRICTED").
30
+ *
31
+ * SCOPE. This gates the COMMERCIAL lane only. Champollion's default lane is
32
+ * non-commercial, where NC and copyleft engines are perfectly usable — that
33
+ * is the open project, and nothing here restricts it. Same orthogonality
34
+ * `license-gate.mjs` already applies to corpora: use context is a descriptor,
35
+ * not a single bucket.
36
+ *
37
+ * @module commercial-eligibility
38
+ */
39
+
40
+ import { manifestEntries, cliNameFor } from './method-manifest.js';
41
+ import { USE_CONTEXTS } from './license-gate.mjs';
42
+
43
+ /**
44
+ * Methods with no entry in the shared registry, because they are CLI-only
45
+ * constructs rather than engines with a cross-runtime adapter.
46
+ *
47
+ * Every entry states WHY. An engine that belongs in the registry must go in
48
+ * the registry — this table is not a bypass for it.
49
+ */
50
+ const CLI_ONLY = {
51
+ // Plain LLM translation through the user's own provider key. The engine
52
+ // itself carries no external data dependency; the provider's ToS governs,
53
+ // and every LLM provider in the registry is commercialReady.
54
+ 'llm-coached': {
55
+ eligible: true,
56
+ license: 'Provider ToS (per LLM provider)',
57
+ reason: 'the method carries no data dependency of its own — its COACHING '
58
+ + 'data does, and that rides pluginProvenance (rule 2), which can only '
59
+ + 'restrict',
60
+ },
61
+
62
+ // Human-in-the-loop review. No external resource at all.
63
+ 'human-review': {
64
+ eligible: true,
65
+ license: 'n/a',
66
+ reason: 'no external resource — human review of output already produced',
67
+ },
68
+
69
+ // Points at a remote champollion-serve endpoint. Whatever is BEHIND it is
70
+ // declared by the plugin manifest, so with no declaration there is nothing
71
+ // to stand on.
72
+ api: {
73
+ eligible: false,
74
+ license: 'unknown (declared by the plugin manifest)',
75
+ reason: 'a remote endpoint\'s provenance is only knowable from its plugin '
76
+ + 'manifest — supply one declaring commercialReady, or route it in the '
77
+ + 'non-commercial lane',
78
+ },
79
+
80
+ // Arbitrary user-supplied Python method directory.
81
+ external: {
82
+ eligible: false,
83
+ license: 'unknown (user-supplied plugin)',
84
+ reason: 'a user-supplied plugin\'s dependencies are unknown to us',
85
+ },
86
+
87
+ // The Plains Cree FST pipeline: AGPL FST invoked as a separate tool, plus
88
+ // the Wolvengrey dictionary, which is index-only and permanently
89
+ // non-redistributable (founder ruling 2026-07-19).
90
+ 'fst-gated': {
91
+ eligible: false,
92
+ license: 'AGPL-3.0-or-later + PROPRIETARY dictionary',
93
+ reason: 'depends on an AGPL FST and a proprietary dictionary under a '
94
+ + 'pending agreement',
95
+ },
96
+ };
97
+
98
+ /**
99
+ * Look the method up in the shared registry by canonical key or CLI alias.
100
+ *
101
+ * @param {string} methodName
102
+ * @returns {{ name: string, entry: object } | null} null when the registry is
103
+ * absent (standalone package with no bundled shared/) or the name is unknown
104
+ */
105
+ function registryEntryFor(methodName) {
106
+ for (const [name, entry] of Object.entries(manifestEntries())) {
107
+ if (name === methodName || cliNameFor(name, entry) === methodName) {
108
+ return { name, entry };
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Resolve whether a method may be routed to commercially.
116
+ *
117
+ * Pure and offline — reads the bundled registry only. Never throws; callers
118
+ * that want an exception use assertRoutable().
119
+ *
120
+ * @param {string} methodName - CLI method name or canonical registry key
121
+ * @param {object} [opts]
122
+ * @param {object|null} [opts.pluginProvenance] - The plugin manifest's own
123
+ * provenance declaration, when the pair config carries one. May only restrict.
124
+ * @returns {{ eligible: boolean, source: string, license: string|null,
125
+ * reason: string }} `source` is where the verdict came from, so a report can
126
+ * cite it: 'registry' | 'cli-only' | 'plugin' | 'unknown'
127
+ */
128
+ export function resolveCommercialEligibility(methodName, { pluginProvenance = null } = {}) {
129
+ const pluginBlocks = !!pluginProvenance
130
+ && typeof pluginProvenance === 'object'
131
+ && pluginProvenance.commercialReady === false;
132
+
133
+ const found = registryEntryFor(methodName);
134
+ if (found) {
135
+ const { name, entry } = found;
136
+ const eligible = entry.commercialReady === true && !pluginBlocks;
137
+ let reason;
138
+ if (pluginBlocks) {
139
+ reason = 'the installed plugin declares itself not commercial-ready';
140
+ } else if (entry.commercialReady === true) {
141
+ reason = `cleared in the shared method registry (${name})`;
142
+ } else {
143
+ reason = `not commercial-ready in the shared method registry (${name})`;
144
+ }
145
+ return {
146
+ eligible,
147
+ source: pluginBlocks ? 'plugin' : 'registry',
148
+ license: entry.license || null,
149
+ reason,
150
+ };
151
+ }
152
+
153
+ const declared = CLI_ONLY[methodName];
154
+ if (declared) {
155
+ const eligible = declared.eligible && !pluginBlocks;
156
+ return {
157
+ eligible,
158
+ source: pluginBlocks ? 'plugin' : 'cli-only',
159
+ license: declared.license,
160
+ reason: pluginBlocks
161
+ ? 'the installed plugin declares itself not commercial-ready'
162
+ : declared.reason,
163
+ };
164
+ }
165
+
166
+ // Rule 4. A plugin declaring itself ready is NOT enough to clear an
167
+ // otherwise-unknown method — that would let any manifest self-certify.
168
+ return {
169
+ eligible: false,
170
+ source: 'unknown',
171
+ license: null,
172
+ reason: `"${methodName}" has no recorded commercial eligibility — unknown `
173
+ + 'methods are treated as restricted',
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Error thrown when a commercial route is refused. Carries the structured
179
+ * verdict so an API layer can render it without re-deriving anything.
180
+ */
181
+ export class CommercialRouteBlockedError extends Error {
182
+ /**
183
+ * @param {string} methodName
184
+ * @param {{eligible:boolean, source:string, license:string|null, reason:string}} verdict
185
+ */
186
+ constructor(methodName, verdict) {
187
+ super(
188
+ `Method "${methodName}" is not cleared for the commercial lane: `
189
+ + `${verdict.reason}`
190
+ + (verdict.license ? ` (license: ${verdict.license})` : '')
191
+ + '. Route it in the non-commercial lane, or use a method whose license '
192
+ + 'permits commercial use (`champollion recommend <src> <tgt> '
193
+ + '--use commercial` lists them).'
194
+ );
195
+ this.name = 'CommercialRouteBlockedError';
196
+ this.methodName = methodName;
197
+ this.verdict = verdict;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Enforcement point. Throws when the lane is commercial and the method is not
203
+ * cleared; returns the verdict otherwise.
204
+ *
205
+ * The non-commercial lane is the default and is never blocked here — NC and
206
+ * copyleft engines are legitimate there, which is the whole point of the open
207
+ * project.
208
+ *
209
+ * @param {string} methodName
210
+ * @param {object} [opts]
211
+ * @param {('commercial'|'non-commercial')} [opts.useContext='non-commercial']
212
+ * @param {object|null} [opts.pluginProvenance]
213
+ * @returns {{eligible:boolean, source:string, license:string|null, reason:string}}
214
+ * @throws {CommercialRouteBlockedError}
215
+ */
216
+ export function assertRoutable(methodName, { useContext = USE_CONTEXTS.NON_COMMERCIAL, pluginProvenance = null } = {}) {
217
+ const verdict = resolveCommercialEligibility(methodName, { pluginProvenance });
218
+ if (useContext === USE_CONTEXTS.COMMERCIAL && !verdict.eligible) {
219
+ throw new CommercialRouteBlockedError(methodName, verdict);
220
+ }
221
+ return verdict;
222
+ }
223
+
224
+ /**
225
+ * Convenience boolean for report surfaces that only need the answer.
226
+ *
227
+ * @param {string} methodName
228
+ * @param {object} [opts]
229
+ * @returns {boolean}
230
+ */
231
+ export function isCommercialEligible(methodName, opts = {}) {
232
+ return resolveCommercialEligibility(methodName, opts).eligible;
233
+ }
234
+
235
+ export { CLI_ONLY as CLI_ONLY_ELIGIBILITY };