champollion 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +41 -26
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +82 -37
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +34 -0
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +286 -85
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1709 -101
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +632 -125
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +15 -9
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +230 -17
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +284 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1136 -345
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +194 -35
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +727 -79
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +975 -262
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +1102 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +575 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +257 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +71 -129
  70. package/lib/methods/llm.js +87 -33
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +102 -55
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  102. package/lib/segment.js +24 -8
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +996 -21
  117. package/lib/verify.js +1332 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +1 -1
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/cards-fallback.json +1 -1
  123. package/shared/curated-orthography-conventions.json +26 -8
  124. package/shared/gettext-plural-forms.json +45 -0
  125. package/shared/licenses.json +99 -3
  126. package/shared/method-registry.json +2 -0
  127. package/shared/metric-registry.json +96 -18
  128. package/shared/retired-model-aliases.json +9 -0
  129. package/shared/schemas/champollion-plugin.schema.json +4 -0
  130. package/shared/schemas/corpora-card.schema.json +8 -2
  131. package/shared/schemas/human-services.schema.json +1 -1
  132. package/shared/schemas/method-index-record.schema.json +67 -0
  133. package/shared/schemas/method-registry.schema.json +4 -0
  134. package/shared/schemas/metric-registry.schema.json +55 -1
  135. package/shared/schemas/retired-model-aliases.schema.json +32 -0
  136. package/shared/docent/corpus.json +0 -11739
  137. package/shared/model-aliases.json +0 -7
  138. package/shared/schemas/model-aliases.schema.json +0 -27
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Request capture — see the exact request a method would send, without
3
+ * sending it (`champollion sync --dry --show-prompt [key]`).
4
+ *
5
+ * WHY: nothing showed whether a gettext `msgctxt`, a `#.` comment or any
6
+ * other per-key instruction actually reaches the model (Round 5, Django
7
+ * persona). Rebuilding "what the prompt probably looks like" beside the real
8
+ * code would be a second prompt builder that drifts; instead the methods'
9
+ * own transports hand their finished request here, at the point where they
10
+ * would call fetch(), and return as if the call failed — no retry, nothing
11
+ * sent, nothing billed.
12
+ *
13
+ * Scoped with AsyncLocalStorage: only code running inside captureRequests()
14
+ * is captured, so a capture never swallows an unrelated call. Inside a
15
+ * capture, fetch() itself is refused (defence in depth: a path that is not
16
+ * hooked fails closed instead of reaching the network).
17
+ *
18
+ * Secrets are redacted before anything is recorded: authorization-type
19
+ * headers and key-bearing URL parameters print as <redacted>.
20
+ */
21
+
22
+ import { AsyncLocalStorage } from 'node:async_hooks';
23
+
24
+ const store = new AsyncLocalStorage();
25
+
26
+ /** The key a preview passes when none is configured (it never leaves the process). */
27
+ export const PREVIEW_KEY = 'preview-no-key';
28
+
29
+ const SECRET_HEADERS = new Set([
30
+ 'authorization', 'x-api-key', 'api-key', 'x-goog-api-key', 'ocp-apim-subscription-key',
31
+ 'x-champollion-key', 'proxy-authorization', 'cookie',
32
+ ]);
33
+ const SECRET_PARAMS = new Set(['key', 'api_key', 'apikey', 'token', 'access_token', 'auth_key']);
34
+
35
+ function redactHeaders(headers) {
36
+ const out = {};
37
+ for (const [k, v] of Object.entries(headers || {})) {
38
+ out[k] = SECRET_HEADERS.has(k.toLowerCase()) ? '<redacted>' : v;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function redactUrl(url) {
44
+ try {
45
+ const u = new URL(String(url));
46
+ for (const name of [...u.searchParams.keys()]) {
47
+ if (SECRET_PARAMS.has(name.toLowerCase())) u.searchParams.set(name, '<redacted>');
48
+ }
49
+ return u.toString().replace(/%3Credacted%3E/g, '<redacted>');
50
+ } catch {
51
+ return String(url);
52
+ }
53
+ }
54
+
55
+ /** True inside captureRequests(): the caller must record, not send. */
56
+ export function isCapturing() {
57
+ return store.getStore() !== undefined;
58
+ }
59
+
60
+ /**
61
+ * Record the request a transport is about to send. Returns true when it was
62
+ * captured (the transport must then return without calling fetch), false
63
+ * when no capture is active (send as normal).
64
+ *
65
+ * @param {{ url: string, method?: string, headers?: object, body?: unknown }} request
66
+ * @returns {boolean}
67
+ */
68
+ export function captureRequest({ url, method = 'POST', headers = {}, body }) {
69
+ const sink = store.getStore();
70
+ if (!sink) return false;
71
+ let parsed = body;
72
+ if (typeof body === 'string') {
73
+ try { parsed = JSON.parse(body); } catch { parsed = body; }
74
+ }
75
+ sink.push({ url: redactUrl(url), method, headers: redactHeaders(headers), body: parsed });
76
+ return true;
77
+ }
78
+
79
+ let fetchGuarded = false;
80
+ /** Inside a capture fetch() is refused; outside, it is the real fetch. Installed once. */
81
+ function guardFetch() {
82
+ if (fetchGuarded || typeof globalThis.fetch !== 'function') return;
83
+ const realFetch = globalThis.fetch;
84
+ globalThis.fetch = function guardedFetch(...args) {
85
+ if (store.getStore()) {
86
+ return Promise.reject(new Error('request preview: nothing is sent while showing a request'));
87
+ }
88
+ return realFetch.apply(this, args);
89
+ };
90
+ fetchGuarded = true;
91
+ }
92
+
93
+ /**
94
+ * Run fn with capture on; return the requests its transports would have sent.
95
+ *
96
+ * @param {() => Promise<unknown>} fn
97
+ * @returns {Promise<Array<{ url: string, method: string, headers: object, body: unknown }>>}
98
+ */
99
+ export async function captureRequests(fn) {
100
+ guardFetch();
101
+ const sink = [];
102
+ await store.run(sink, fn);
103
+ return sink;
104
+ }
@@ -27,11 +27,11 @@ const TILDE_MAX_BATCH = 25;
27
27
  class TildeMethod extends TranslationMethod {
28
28
  constructor(options = {}) {
29
29
  super('tilde', options);
30
+ this.translatesRawText = true; // see base.js
30
31
  }
31
32
 
32
33
  _resolveApiKey(options = {}) {
33
34
  return options.tildeApiKey
34
- || getEnvOrFileVar('TILDE_API_KEY')
35
35
  || getEnvOrFileVar('TILDE_API_KEY', options.cwd);
36
36
  }
37
37
 
@@ -28,6 +28,7 @@ const TRANSLATED_MAX_BATCH = 50;
28
28
  class TranslatedMethod extends TranslationMethod {
29
29
  constructor(options = {}) {
30
30
  super('translated', options);
31
+ this.translatesRawText = true; // see base.js
31
32
  this._client = options.laraClient || null; // injectable for tests
32
33
  }
33
34
 
@@ -40,10 +41,8 @@ class TranslatedMethod extends TranslationMethod {
40
41
  */
41
42
  _resolveCredentials(options = {}) {
42
43
  const id = options.laraAccessKeyId
43
- || getEnvOrFileVar('LARA_ACCESS_KEY_ID')
44
44
  || getEnvOrFileVar('LARA_ACCESS_KEY_ID', options.cwd);
45
45
  const secret = options.laraAccessKeySecret
46
- || getEnvOrFileVar('LARA_ACCESS_KEY_SECRET')
47
46
  || getEnvOrFileVar('LARA_ACCESS_KEY_SECRET', options.cwd);
48
47
  return id && secret ? { id, secret } : null;
49
48
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What to tell someone whose method cannot run for want of a key — ONE helper
3
+ * for every place that says it (the sync preflight, real and dry; a method
4
+ * that returned nothing; the content and Docusaurus lanes; `models`).
5
+ *
6
+ * On a CI runner the fix is a repository secret passed to the step, not
7
+ * `export …` or `.env.local`: the Round 8 fix reached one of these places and
8
+ * a real CI run still printed the laptop advice from the others (Round 9,
9
+ * i18next persona). Off CI, the method's own setup help stands as before.
10
+ */
11
+
12
+ /**
13
+ * Is this a CI runner? `CI` or `GITHUB_ACTIONS` set to anything but an empty
14
+ * string, "false" or "0" (a developer's shell may export CI=false).
15
+ *
16
+ * @param {object} [env]
17
+ * @returns {boolean}
18
+ */
19
+ export function onCIRunner(env = process.env) {
20
+ const on = (v) => typeof v === 'string' && v.trim() !== '' && !/^(false|0)$/i.test(v.trim());
21
+ return on(env.CI) || on(env.GITHUB_ACTIONS);
22
+ }
23
+
24
+ /**
25
+ * The secret names a readiness reason names, in its first parenthesis:
26
+ * "No OpenRouter API key (OPENROUTER_API_KEY)." → OPENROUTER_API_KEY
27
+ * "No Google Translate API key (GOOGLE_TRANSLATE_API_KEY (or GOOGLE_API_KEY))." → the first (canonical) one
28
+ * "No Lara credentials (LARA_ACCESS_KEY_ID + LARA_ACCESS_KEY_SECRET)." → both (a key pair)
29
+ *
30
+ * @param {Iterable<string>} reasons
31
+ * @returns {string[]} distinct names, in order
32
+ */
33
+ export function secretNamesIn(reasons) {
34
+ const names = [];
35
+ for (const reason of reasons || []) {
36
+ const m = /\(([A-Z][A-Z0-9]*_[A-Z0-9_]+(?:\s*\+\s*[A-Z][A-Z0-9]*_[A-Z0-9_]+)*)/.exec(String(reason));
37
+ if (!m) continue;
38
+ for (const n of m[1].split('+').map(x => x.trim())) if (!names.includes(n)) names.push(n);
39
+ }
40
+ return names;
41
+ }
42
+
43
+ /**
44
+ * "In CI: add a repository secret named X and pass it to the sync step (env: X: ${{ secrets.X }})."
45
+ *
46
+ * @param {string[]} names
47
+ * @param {string} [step] - what the secret is passed to
48
+ * @returns {string|null} null when no name is known
49
+ */
50
+ export function ciSecretLine(names, step = 'the sync step') {
51
+ if (!names || names.length === 0) return null;
52
+ const plural = names.length > 1;
53
+ return `In CI: add ${plural ? 'repository secrets' : 'a repository secret'} named ${names.join(' and ')} and pass `
54
+ + `${plural ? 'them' : 'it'} to ${step} (env: ${names.map(n => `${n}: \${{ secrets.${n} }}`).join(', ')}).`;
55
+ }
56
+
57
+ /**
58
+ * The advice lines for a missing key: on a CI runner, the repository-secret
59
+ * line INSTEAD of the shell advice; elsewhere, the method's own setup help
60
+ * (export …, .env.local). A reason that names no secret (a model server that
61
+ * does not answer) keeps the setup help everywhere — its reason already says
62
+ * what to do on a runner (lib/methods/local.js).
63
+ *
64
+ * @param {object} p
65
+ * @param {Iterable<string>} p.reasons - readiness reasons
66
+ * @param {string[]} [p.setupHelp] - the method's getSetupHelp() lines
67
+ * @param {string} [p.indent]
68
+ * @param {object} [p.env]
69
+ * @param {string} [p.step] - what the secret is passed to (default: the sync step)
70
+ * @returns {string[]}
71
+ */
72
+ export function missingKeyAdvice({ reasons, setupHelp = [], indent = ' ', env = process.env, step = 'the sync step' }) {
73
+ const line = ciSecretLine(secretNamesIn(reasons), step);
74
+ if (onCIRunner(env) && line) return [`${indent}${line}`];
75
+ return [...setupHelp];
76
+ }
77
+
78
+ /**
79
+ * The setup help for a method that returned nothing: when its key is missing
80
+ * (its readiness check says so), the missing-key advice above; when the key
81
+ * is there, the method's own HTTP-failure help.
82
+ *
83
+ * @param {object} method - a method instance (lib/methods/base.js)
84
+ * @param {{ apiKey?: string|null, cwd?: string }} [context]
85
+ * @returns {Promise<string[]>}
86
+ */
87
+ export async function methodSetupAdvice(method, context = {}) {
88
+ const help = method.getSetupHelp();
89
+ let readiness = { ready: true };
90
+ try { readiness = await method.checkReadiness({ apiKey: context.apiKey ?? null, cwd: context.cwd }); } catch { /* the help stands */ }
91
+ if (readiness && readiness.ready === false) return missingKeyAdvice({ reasons: [readiness.reason], setupHelp: help });
92
+ return help;
93
+ }
package/lib/models.js CHANGED
@@ -24,77 +24,109 @@ import fs from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { getEnvOrFileVar } from './api-key.js';
27
+ // A cycle (config.js imports this module) that is safe: the default is read
28
+ // only inside requireExactModelId, at call time, never while modules load.
29
+ import { DEFAULT_OPENROUTER_MODEL } from './config.js';
27
30
 
28
31
  // Per-process cache: provider name → model ID array (or null if fetch failed)
29
32
  const _modelCache = new Map();
30
33
 
31
- // Lazy-loaded alias map (loaded once from shared/model-aliases.json)
32
- let _aliasCache = null;
34
+ // Lazy-loaded retired-name table (loaded once from shared/retired-model-aliases.json)
35
+ let _retiredCache = null;
33
36
 
34
37
  /**
35
- * Load the model alias map from shared/model-aliases.json.
38
+ * Where to look up the models a name could mean — said in every refusal.
39
+ * `champollion models` lists a direct provider's own names; OpenRouter's
40
+ * catalogue is its public model list.
41
+ */
42
+ const MODEL_LIST_HINT = 'List models: https://openrouter.ai/models (OpenRouter slugs), '
43
+ + 'or champollion models --method <gemini|openai|anthropic> (a direct provider\'s own names).';
44
+
45
+ /**
46
+ * Load the RETIRED short model names from shared/retired-model-aliases.json.
36
47
  *
37
- * Resolves the path relative to the monorepo root (two levels up from cli/lib/).
38
- * Returns an empty object if the file doesn't exist or fails to parse,
39
- * so callers can always safely check `aliases[name]`.
48
+ * Founder ruling 2026-10-05: "slugs should be specific, NOT ALIASES — for all
49
+ * models, all slugs, no aliasing." These names no longer resolve to anything.
50
+ * The table exists only so a refusal can say which exact slug the old name
51
+ * used to stand for — it is never used to map a name to a model.
40
52
  *
41
- * @returns {Object<string, string>} Short name → full OpenRouter slug
53
+ * Prefers the package-bundled copy (cli/shared/, shipped via sync:shared),
54
+ * then the monorepo-root SSOT for in-repo dev. A missing or unreadable table
55
+ * leaves the refusal of floating ids intact and only loses the "used to stand
56
+ * for" hint for a retired name — which then fails as an unknown model at the
57
+ * provider, still never resolved.
58
+ *
59
+ * @returns {Object<string, string>} Retired short name → the slug it stood for
42
60
  */
43
- function _loadAliases() {
44
- if (_aliasCache) return _aliasCache;
45
-
46
- try {
47
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
48
- // Prefer the package-bundled copy (cli/shared/, shipped via the prepack
49
- // build-cards-fallback.mjs), then fall back to the monorepo-root SSOT for
50
- // in-repo dev. Without the package copy, an installed CLI found no alias map
51
- // and `--model <alias>` silently failed to resolve.
52
- const aliasPaths = [
53
- path.resolve(__dirname, '..', 'shared', 'model-aliases.json'),
54
- path.resolve(__dirname, '..', '..', 'shared', 'model-aliases.json'),
55
- ];
56
- let raw = null;
57
- for (const p of aliasPaths) {
58
- try { raw = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
61
+ function _loadRetiredAliases() {
62
+ if (_retiredCache) return _retiredCache;
63
+ _retiredCache = Object.create(null);
64
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
65
+ const candidates = [
66
+ path.resolve(__dirname, '..', 'shared', 'retired-model-aliases.json'),
67
+ path.resolve(__dirname, '..', '..', 'shared', 'retired-model-aliases.json'),
68
+ ];
69
+ for (const p of candidates) {
70
+ let raw;
71
+ try { raw = fs.readFileSync(p, 'utf-8'); } catch { continue; }
72
+ const retired = JSON.parse(raw).retired || {};
73
+ for (const [key, value] of Object.entries(retired)) {
74
+ if (typeof value === 'string') _retiredCache[key] = value;
59
75
  }
60
- const parsed = raw ? JSON.parse(raw) : {};
61
- // Strip metadata keys (e.g., _comment) — only keep actual aliases
62
- _aliasCache = {};
63
- for (const [key, value] of Object.entries(parsed)) {
64
- if (!key.startsWith('_') && typeof value === 'string') {
65
- _aliasCache[key] = value;
66
- }
67
- }
68
- } catch {
69
- _aliasCache = {};
76
+ break;
70
77
  }
78
+ return _retiredCache;
79
+ }
71
80
 
72
- return _aliasCache;
81
+ /**
82
+ * True for a floating id — one that names whatever model a provider points it
83
+ * at today, so a run could not say which model translated: OpenRouter's
84
+ * "~vendor/…" router ids and any "…-latest" / "…:latest" name.
85
+ *
86
+ * @param {string} id
87
+ * @returns {boolean}
88
+ */
89
+ function isFloatingModelId(id) {
90
+ return typeof id === 'string' && (id.trim().startsWith('~') || /[-:]latest$/i.test(id.trim()));
73
91
  }
74
92
 
75
93
  /**
76
- * Resolve a model name or alias to a full OpenRouter slug.
94
+ * Check that a configured model is an EXACT model slug, and return it unchanged.
77
95
  *
78
- * Resolution order:
79
- * 1. If contains '/' → already a full slug, pass through
80
- * 2. Check shared/model-aliases.json for a matching alias
81
- * 3. Pass through as-is (user may know what they're doing)
96
+ * Founder ruling 2026-10-05: every model is named by its exact provider slug
97
+ * ("google/gemini-3.5-flash", "anthropic/claude-sonnet-4.6", or a direct
98
+ * provider's own exact name, "gpt-5.5"). Nothing resolves a short name:
99
+ * - a retired alias ("gemini-flash", "gpt", …) is REFUSED, naming the exact
100
+ * slug it used to stand for;
101
+ * - a floating id ("~google/gemini-flash-latest", "…-latest") is REFUSED.
102
+ * Anything else passes through as written — the provider judges whether the
103
+ * model exists (direct providers also check it against their model list).
82
104
  *
83
- * @param {string} nameOrSlug - User-provided model identifier
84
- * @returns {string} Full OpenRouter model slug
105
+ * @param {string|null|undefined} id - The model as written
106
+ * @param {{ from?: string|null }} [where] - Where it was set ("from --model")
107
+ * @returns {string|null|undefined} The same id
108
+ * @throws {Error} code CHAMPOLLION_MODEL_ID
85
109
  */
86
- function resolveModel(nameOrSlug) {
87
- if (!nameOrSlug || typeof nameOrSlug !== 'string') return nameOrSlug;
88
-
89
- // Already a full slug (contains provider prefix)
90
- if (nameOrSlug.includes('/')) return nameOrSlug;
91
-
92
- // Check aliases
93
- const aliases = _loadAliases();
94
- if (aliases[nameOrSlug]) return aliases[nameOrSlug];
95
-
96
- // Pass through — could be a provider-specific model name (e.g., gemini-2.0-flash)
97
- return nameOrSlug;
110
+ function requireExactModelId(id, { from = null } = {}) {
111
+ if (!id || typeof id !== 'string') return id;
112
+ const at = from ? ` (${from})` : '';
113
+ const retired = _loadRetiredAliases();
114
+ if (Object.prototype.hasOwnProperty.call(retired, id)) {
115
+ const err = new Error(
116
+ `"${id}"${at} is not a model id — Champollion takes exact model slugs only, no aliases. `
117
+ + `Did you mean ${retired[id]} (what "${id}" used to stand for)? ${MODEL_LIST_HINT}`);
118
+ err.code = 'CHAMPOLLION_MODEL_ID';
119
+ throw err;
120
+ }
121
+ if (isFloatingModelId(id)) {
122
+ const err = new Error(
123
+ `"${id}"${at} is a floating model id — it names whatever model the provider points it at today, `
124
+ + 'so a run could not say which model translated. Champollion takes exact model slugs only: '
125
+ + `name the model itself (e.g. "${DEFAULT_OPENROUTER_MODEL}", the CLI default). ${MODEL_LIST_HINT}`);
126
+ err.code = 'CHAMPOLLION_MODEL_ID';
127
+ throw err;
128
+ }
129
+ return id;
98
130
  }
99
131
 
100
132
  /**
@@ -183,7 +215,10 @@ async function fetchAvailableModels(provider, apiKey) {
183
215
  if (cached !== undefined) return cached;
184
216
 
185
217
  try {
186
- const models = await config.fetch(apiKey);
218
+ // Floating ids ("gemini-flash-latest", "chatgpt-4o-latest") are listed by
219
+ // the providers but refused everywhere (requireExactModelId) — never offer
220
+ // one, so neither `champollion models` nor init's picker suggests it.
221
+ const models = (await config.fetch(apiKey))?.filter(id => !isFloatingModelId(id)) ?? null;
187
222
  if (models && models.length > 0) {
188
223
  _modelCache.set(provider, models);
189
224
  return models;
@@ -219,6 +254,16 @@ function getProviderLabel(provider) {
219
254
  return PROVIDERS[provider]?.label || provider;
220
255
  }
221
256
 
257
+ /**
258
+ * The environment variable a provider's key is read from (null: unknown).
259
+ *
260
+ * @param {string} provider - Provider name
261
+ * @returns {string|null}
262
+ */
263
+ function getProviderEnvVar(provider) {
264
+ return PROVIDERS[provider]?.envVar || null;
265
+ }
266
+
222
267
  /**
223
268
  * Check if a provider has model listing support.
224
269
  *
@@ -248,9 +293,11 @@ function clearModelCache() {
248
293
 
249
294
  export {
250
295
  fetchAvailableModels,
251
- resolveModel,
296
+ requireExactModelId,
297
+ isFloatingModelId,
252
298
  resolveProviderApiKey,
253
299
  getProviderLabel,
300
+ getProviderEnvVar,
254
301
  isListableProvider,
255
302
  getListableProviders,
256
303
  clearModelCache,
@@ -0,0 +1,32 @@
1
+ /**
2
+ * name-rules.js — the ONE wording every translation prompt uses for names.
3
+ *
4
+ * The old rule ("proper nouns, product names, and technical terms … role
5
+ * descriptions … should stay in English") let models keep short descriptive
6
+ * labels in English: on the 2026-08-28 dogfood run "Translation CLI" and
7
+ * "Evaluation harness" came back verbatim in Arabic and Thai and failed
8
+ * verification. Names stay; descriptions get translated; a project can
9
+ * declare its real names (config.protectedTerms) so the model never has to
10
+ * guess.
11
+ */
12
+
13
+ /**
14
+ * Prompt rule lines about names, each starting with "- ".
15
+ *
16
+ * @param {string[]} [protectedTerms] - config.protectedTerms
17
+ * @returns {string} Newline-joined rule lines (no trailing newline)
18
+ */
19
+ export function nameRules(protectedTerms = []) {
20
+ const terms = (protectedTerms || []).filter(t => typeof t === 'string' && t.trim());
21
+ const lines = [
22
+ '- Proper names (people, companies, products, places) stay exactly as written.',
23
+ ];
24
+ if (terms.length > 0) {
25
+ lines.push(`- Keep these exactly as written wherever they appear: ${terms.map(t => JSON.stringify(t)).join(', ')}.`);
26
+ }
27
+ lines.push(
28
+ '- Descriptive labels and phrases are NOT names, even when short or capitalized (e.g. "Translation CLI", "Evaluation harness", "Getting Started") — translate them.',
29
+ '- Widely used technical acronyms (API, URL, JSON, CLI) may stay as written inside a translated phrase.',
30
+ );
31
+ return lines.join('\n');
32
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * named-keys.js — keys named for a redo (`--redo keys:` / `--force-keys`)
3
+ * must exist. ONE rule for every sync path (key-value files, Docusaurus UI
4
+ * strings), so the two cannot drift apart.
5
+ *
6
+ * A name that matched nothing used to do nothing in silence ("fully synced
7
+ * … 0 keys", exit 0 — in CI exactly a success), so a typo or a gettext msgid
8
+ * that only exists WITH a context looked like a finished repair (Round 7,
9
+ * Django persona). Now:
10
+ * - no name matches: the run stops before anything is translated or sent
11
+ * (exit 1), naming the closest keys that exist;
12
+ * - some names match: those are redone, then the run fails (exit 1)
13
+ * naming the rest — said again at the very end, where CI looks.
14
+ *
15
+ * Each lane says what its key space is (`known`) — the key-value lane adds
16
+ * the plural forms a target gets and the namespace prefix; the Docusaurus
17
+ * lane collects every source JSON file's message ids.
18
+ */
19
+
20
+ import { fromTypedContext } from './locale-layout.js';
21
+ import { splitKeyList } from './redo.js';
22
+ import { output } from './output.js';
23
+ import { estimateCost } from './pairs.js';
24
+ import { costLabel } from './cost-label.js';
25
+ import { editDistance } from './edit-distance.js';
26
+
27
+ /** A key as a person reads and types it: the gettext context separator as ␄. */
28
+ export const shownKey = (k) => String(k).replace(/\u0004/g, '\u2404');
29
+
30
+ /** "`button␄Cancel` (or type button\x04Cancel)" — both spellings, for a key with a context. */
31
+ function bothSpellings(k) {
32
+ const shown = shownKey(k);
33
+ return /\u0004/.test(k) ? `\`${shown}\` (or type \`${String(k).replace(/\u0004/g, '\\x04')}\`)` : `\`${shown}\``;
34
+ }
35
+
36
+ /**
37
+ * Which of the names match a key in `known`, and — for each that does not —
38
+ * the closest keys that do: every context variant of a gettext msgid
39
+ * (`button␄Cancel`, `status␄Cancel` for "Cancel"), the same key in another
40
+ * case, near spellings.
41
+ *
42
+ * @param {string[]} names - As typed (␄ or \x04 for a gettext context)
43
+ * @param {Set<string>} known - Every key a name may match, in the lane's key space
44
+ * @param {object} [opts]
45
+ * @param {(k: string) => string} [opts.bare] - A key without its namespace
46
+ * prefix (key-value layouts with several files); identity otherwise
47
+ * @param {boolean} [opts.bareMatches] - A name with no namespace names that
48
+ * key in every file that has it
49
+ * @returns {{ matched: string[], missed: Array<{ name: string, closest: string[] }> }}
50
+ */
51
+ export function checkNamedKeys(names, known, { bare = (k) => k, bareMatches = false } = {}) {
52
+ // A name with no namespace of its own (bare(name) === name) names that key
53
+ // in every file that has it.
54
+ const matches = (name) => known.has(name)
55
+ || (bareMatches && bare(name) === name && [...known].some(k => bare(k) === name));
56
+ const matched = [];
57
+ const missed = [];
58
+ for (const raw of names) {
59
+ const name = fromTypedContext(raw);
60
+ if (matches(name)) { matched.push(raw); continue; }
61
+ const want = bare(name);
62
+ const msgid = want.includes('\u0004') ? want.slice(want.indexOf('\u0004') + 1) : want;
63
+ const lower = want.toLowerCase();
64
+ const max = Math.max(2, Math.floor(want.length / 5));
65
+ const scored = [];
66
+ for (const k of known) {
67
+ const kb = bare(k);
68
+ const kMsgid = kb.includes('\u0004') ? kb.slice(kb.indexOf('\u0004') + 1) : kb;
69
+ let score = null;
70
+ if (kMsgid === msgid) score = 0; // the same msgid, with (another) context
71
+ else if (kb.toLowerCase() === lower || kMsgid.toLowerCase() === msgid.toLowerCase()) score = 1;
72
+ else {
73
+ const d = editDistance(kb.toLowerCase(), lower, max);
74
+ if (d <= max) score = 1 + d;
75
+ }
76
+ if (score !== null) scored.push([score, k]);
77
+ }
78
+ scored.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
79
+ missed.push({ name: raw, closest: scored.slice(0, 8).map(([, k]) => k) });
80
+ }
81
+ return { matched, missed };
82
+ }
83
+
84
+ /** The error lines for named keys that matched nothing. */
85
+ export function describeUnmatchedKeys(missed, { flag }) {
86
+ const lines = missed.map(({ name, closest }) => `${flag} names "${shownKey(fromTypedContext(name))}", which matches no key in the source files`
87
+ + (closest.length > 0 ? ` — closest: ${closest.map(bothSpellings).join(', ')}` : ' (and nothing close to it)') + '.');
88
+ if (missed.some(m => m.closest.some(k => k.includes('\u0004')))) {
89
+ lines.push('A gettext entry with a context is named msgctxt␄msgid; type the ␄ as \\x04 if you cannot (both spellings work).');
90
+ }
91
+ return lines;
92
+ }
93
+
94
+ /** How the run's named keys were given, for its messages. */
95
+ export function namedKeyFlag(cliArgs) {
96
+ return cliArgs.redo !== undefined ? '--redo keys:' : '--force-keys';
97
+ }
98
+
99
+ /**
100
+ * The rule, applied before any work. With no name matching it throws (the
101
+ * run exits 1, nothing translated or sent). With some matching, it prints
102
+ * the misses, narrows `cliArgs['force-keys']` and `config.forceKeys` to the
103
+ * names that exist, and returns the misses — the caller reports them again
104
+ * at the end (reportUnmatchedKeys) and the run exits 1.
105
+ *
106
+ * @param {object} p
107
+ * @param {object} p.cliArgs - Parsed CLI args (mutated: force-keys narrowed)
108
+ * @param {object} p.config - Resolved config (mutated: forceKeys narrowed)
109
+ * @param {Set<string>|(() => Set<string>)} p.known - The lane's key space (lazy: built only when keys are named)
110
+ * @param {(k: string) => string} [p.bare]
111
+ * @param {boolean} [p.bareMatches]
112
+ * @returns {Array<{ name: string, closest: string[] }>} The names that matched nothing
113
+ */
114
+ export function applyNamedKeyRule({ cliArgs, config, known, bare, bareMatches = false }) {
115
+ const named = splitKeyList(cliArgs['force-keys'] || '');
116
+ if (named.length === 0) return [];
117
+ const keys = typeof known === 'function' ? known() : known;
118
+ const { matched, missed } = checkNamedKeys(named, keys, { bare, bareMatches });
119
+ if (missed.length === 0) return [];
120
+ const lines = describeUnmatchedKeys(missed, { flag: namedKeyFlag(cliArgs) });
121
+ if (matched.length === 0) {
122
+ throw new Error(`${lines.join('\n')}\nNothing was translated or sent.`);
123
+ }
124
+ for (const l of lines) output.error(l);
125
+ output.error(`Redoing the ${matched.length} named key(s) that exist; the run then fails (exit 1) for the ${missed.length} that do not.`);
126
+ cliArgs['force-keys'] = matched.map(k => k.replace(/,/g, '\\,')).join(',');
127
+ // --redo all / --force re-queues every key anyway; the narrowed names only
128
+ // decide what a named redo replaces.
129
+ config.forceKeys = cliArgs.force ? config.forceKeys : splitKeyList(cliArgs['force-keys']);
130
+ return missed;
131
+ }
132
+
133
+ /** Said again at the end of the run, where a reader (or CI) looks for the verdict. */
134
+ export function reportUnmatchedKeys(missed, cliArgs) {
135
+ if (!missed || missed.length === 0) return;
136
+ for (const l of describeUnmatchedKeys(missed, { flag: namedKeyFlag(cliArgs) })) output.error(l);
137
+ }
138
+
139
+ /** The --json summary's `unmatchedKeys`: each name with the closest keys that exist. */
140
+ export function unmatchedKeysSummary(missed) {
141
+ return (missed || []).map(m => ({ name: m.name, closest: m.closest.map(shownKey) }));
142
+ }
143
+
144
+ /**
145
+ * Keys NAMED for a redo that the cache answered: the model was not asked, and
146
+ * a redo without --fresh said nothing about it — the persona's `--redo
147
+ * keys:<k>` wrote back the text it already had (Round 7, Django). Said, with
148
+ * the --fresh command and what it costs — by every key lane (key-value files,
149
+ * Docusaurus UI strings), in the same words.
150
+ *
151
+ * @param {object} p
152
+ * @param {string} p.filename - The file as the run names it
153
+ * @param {string[]} p.keys - The named keys the cache served
154
+ * @param {string[]} [p.shown] - The same keys as a person names them (default: keys)
155
+ * @param {object} [p.served] - key → the text the cache served
156
+ * @param {object} [p.onDisk] - key → the text the file held before the run
157
+ * @param {object} p.pairConfig
158
+ * @param {string|null} [p.cwd]
159
+ * @param {boolean} [p.dryRun]
160
+ * @param {string} p.command - The `--redo keys: … --fresh` command that asks the model again
161
+ */
162
+ export async function reportNamedFromCache({ filename, keys, shown = keys, served = {}, onDisk = {}, pairConfig, cwd = null, dryRun = false, command }) {
163
+ if (keys.length === 0) return;
164
+ const same = keys.filter(k => typeof served[k] === 'string' && served[k] === onDisk[k]);
165
+ let price = 'cost unknown';
166
+ try { price = costLabel(await estimateCost(keys.length, pairConfig, { cwd })); } catch { /* unknown */ }
167
+ const sample = `${shown.slice(0, 3).join(', ')}${shown.length > 3 ? `, +${shown.length - 3} more` : ''}`;
168
+ output.info(`${filename} — ${keys.length} key(s) named for a redo ${dryRun ? 'would be' : 'were'} served from the cache, `
169
+ + `not asked again (${sample})${same.length === keys.length ? ' — the file already holds that text' : ''}: `
170
+ + 'a redo re-checks and re-writes what the cache holds, at no cost. To ask the model again: '
171
+ + `\`${command}\` (sends ${keys.length} key(s) — ${price}).`);
172
+ }
@@ -73,10 +73,11 @@ function isBareUrl(value) {
73
73
  * - a pattern with no wildcard is an exact dot-path
74
74
  *
75
75
  * @param {string} pattern - e.g. '**.url', 'pages.software.*.repo'
76
+ * @param {string} [sep='.'] - Segment separator ('/' for file paths — see file-scope.js)
76
77
  * @returns {(keySegments: string[]) => boolean} Segment-array matcher
77
78
  */
78
- function compilePattern(pattern) {
79
- const patternSegments = pattern.split('.');
79
+ function compilePattern(pattern, sep = '.') {
80
+ const patternSegments = pattern.split(sep);
80
81
 
81
82
  // Per-segment regexes, built once. `**` is handled structurally below and
82
83
  // never reaches this map.
@@ -88,7 +89,7 @@ function compilePattern(pattern) {
88
89
  const source = '^' + seg
89
90
  .split('*')
90
91
  .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
91
- .join('[^.]*') + '$';
92
+ .join(`[^${sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}]*`) + '$';
92
93
  const re = new RegExp(source);
93
94
  return (s) => re.test(s);
94
95
  });