champollion 0.3.3 → 0.4.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 (142) hide show
  1. package/README.md +52 -37
  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 +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  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 +1689 -87
  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 +649 -130
  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 +16 -10
  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 +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +197 -38
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -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 +571 -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 +252 -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 +68 -128
  70. package/lib/methods/llm.js +80 -31
  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 +11 -0
  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 +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  102. package/lib/segment.js +2 -1
  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 +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
package/lib/lint.js CHANGED
@@ -6,8 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { resolveConfig } from './config.js';
9
- import { flattenKeys } from './flatten.js';
10
- import { readLocaleFile, detectFormatFromDir, getExtension } from './format.js';
9
+ import { discoverLocaleLayout, readLocaleFlat } from './locale-layout.js';
11
10
  import { isNonTranslatableString, isNonTranslatableLintExtended } from './string-classify.js';
12
11
  import { output } from './output.js';
13
12
 
@@ -50,6 +49,25 @@ const FRAMEWORKS = {
50
49
  frameworkImport: /from\s+['"]vue-i18n['"]/,
51
50
  translatableAttrs: ['placeholder', 'aria-label', 'alt', 'title', 'content', 'label'],
52
51
  },
52
+ // Docusaurus: copy is translated by <Translate> / translate({ id, message }).
53
+ // Dogfood 2026-08-28 (finding 8): ten pages of champollion.dev were
54
+ // written with raw JSX prose next to an i18n setup that was never used —
55
+ // and the lint called the site "generic", looked only for t('key'), and
56
+ // could not see it.
57
+ docusaurus: {
58
+ name: 'Docusaurus',
59
+ srcDirs: ['src'],
60
+ extensions: ['.tsx', '.jsx', '.ts', '.js'],
61
+ i18nCallPatterns: [
62
+ /<Translate\b[^>]*\bid\s*=\s*['"]([^'"]+)['"]/g,
63
+ /<Translate\b(?![^>]*\bid\s*=)[^>]*>([^<{]+)<\/Translate>/g,
64
+ /\btranslate\(\s*\{[^}]*?\bid\s*:\s*['"`]([^'"`]+)['"`]/g,
65
+ ],
66
+ frameworkImport: /from\s+['"]@docusaurus\/Translate['"]/,
67
+ translatableAttrs: ['placeholder', 'aria-label', 'alt', 'title', 'label'],
68
+ // Text inside these tags IS translated — never flag it.
69
+ wrapperTags: ['Translate'],
70
+ },
53
71
  hugo: {
54
72
  name: 'Hugo',
55
73
  srcDirs: ['layouts', 'themes'],
@@ -62,6 +80,9 @@ const FRAMEWORKS = {
62
80
  },
63
81
  };
64
82
 
83
+ // Tags whose text content is code, never translatable copy.
84
+ const CODE_TAGS = new Set(['code', 'pre', 'kbd', 'samp', 'var']);
85
+
65
86
  // Default generic framework for unknown projects
66
87
  const GENERIC_FRAMEWORK = {
67
88
  name: 'generic',
@@ -85,6 +106,11 @@ const GENERIC_FRAMEWORK = {
85
106
  * @returns {{ name: string, extensions: string[], srcDirs: string[], i18nCallPatterns: RegExp[], frameworkImport: RegExp|null }} Framework detection result
86
107
  */
87
108
  function detectFramework(cwd) {
109
+ // Docusaurus before Hugo: its own config file is unambiguous, while a
110
+ // Docusaurus repo can carry a stray config.yaml.
111
+ const docusaurusConfigs = ['docusaurus.config.js', 'docusaurus.config.ts', 'docusaurus.config.mjs', 'docusaurus.config.cjs'];
112
+ if (docusaurusConfigs.some(cfg => fs.existsSync(path.join(cwd, cfg)))) return FRAMEWORKS.docusaurus;
113
+
88
114
  // Check for Hugo config files first
89
115
  const hugoConfigs = ['hugo.toml', 'hugo.yaml', 'hugo.yml', 'config.toml', 'config.yaml'];
90
116
  for (const cfg of hugoConfigs) {
@@ -97,6 +123,7 @@ function detectFramework(cwd) {
97
123
  try {
98
124
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
99
125
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
126
+ if (allDeps['@docusaurus/core']) return FRAMEWORKS.docusaurus;
100
127
  if (allDeps['next-intl']) return FRAMEWORKS['next-intl'];
101
128
  if (allDeps['react-i18next'] || allDeps['i18next']) return FRAMEWORKS['react-i18next'];
102
129
  if (allDeps['vue-i18n']) return FRAMEWORKS['vue-i18n'];
@@ -236,6 +263,16 @@ function extractHardcodedStrings(content, filePath, framework, minLength) {
236
263
  let match;
237
264
  while ((match = textPattern.exec(line)) !== null) {
238
265
  const text = match[1].trim();
266
+ // Text whose enclosing tag is a translation wrapper (<Translate>) is
267
+ // already translated.
268
+ const open = line.lastIndexOf('<', match.index);
269
+ const tag = open >= 0 ? (line.slice(open).match(/^<\/?([A-Za-z][\w.]*)/) || [])[1] : null;
270
+ if (tag && framework.wrapperTags && framework.wrapperTags.includes(tag)) continue;
271
+ // Code is not copy: <code>cli/shared/x.json</code>, <kbd>Ctrl</kbd>.
272
+ if (tag && CODE_TAGS.has(tag)) continue;
273
+ // No letters at all is not text — usually JS caught between a `>`
274
+ // comparison and a JSX tag (`n > 0 && <Badge`).
275
+ if (!/\p{L}/u.test(text)) continue;
239
276
  if (shouldFlagString(text, minLength)) {
240
277
  results.push({ line: i + 1, text, context: 'jsx-text' });
241
278
  }
@@ -505,6 +542,16 @@ async function runLint(options = {}) {
505
542
  }
506
543
 
507
544
  if (sourceFiles.length === 0) {
545
+ // A lint that checked nothing is not a pass: it used to exit 0 with "No
546
+ // source files found to lint." while verify failed on the same project
547
+ // (Round 4, i18next persona) — a CI gate pointed at the wrong folder
548
+ // stayed green. Name where it looked and how to point it elsewhere.
549
+ const rel = (p) => path.relative(cwd, path.resolve(cwd, p)).split(path.sep).join('/') || '.';
550
+ const lookedIn = srcDir ? [rel(srcDir)] : framework.srcDirs.map(rel);
551
+ const exts = framework.extensions.join(', ');
552
+ const reason = `no ${exts} files in ${lookedIn.map(d => `${d}/`).join(', ')}`
553
+ + `${srcDir ? '' : ` (the ${framework.name} defaults)`}${ignorePatterns.length > 0 ? ', after the ignore list' : ''}`;
554
+ const hint = 'Point it at your code: `champollion lint --src <dir>`, or "lint": { "srcDir": "<dir>" } in champollion.config.json.';
508
555
  if (cliArgs.json) {
509
556
  console.log(JSON.stringify({
510
557
  command: 'lint',
@@ -512,26 +559,34 @@ async function runLint(options = {}) {
512
559
  filesScanned: 0,
513
560
  findings: [],
514
561
  count: 0,
562
+ nothingChecked: true,
563
+ lookedIn,
564
+ extensions: framework.extensions,
565
+ error: `Nothing linted: ${reason}. ${hint}`,
515
566
  }, null, 2));
516
- return 0;
567
+ return warnOnly ? 0 : 1;
517
568
  }
518
- output.info('No source files found to lint.');
519
- return 0;
569
+ output.error(`Nothing linted: ${reason}. ${hint}`);
570
+ return warnOnly ? 0 : 1;
520
571
  }
521
572
 
522
573
  output.info(`Scanning ${sourceFiles.length} file(s)...`);
523
574
 
524
- // 3. Load locale keys for cross-referencing
525
- const format = config.format !== 'auto'
526
- ? config.format
527
- : detectFormatFromDir(config.localesDir);
528
- const ext = getExtension(format);
529
- const sourcePath = path.join(config.localesDir, `${config.inputLocale}${ext}`);
530
-
575
+ // 3. Load locale keys for cross-referencing — every source file of the
576
+ // layout (one for flat projects; every namespace file for i18next's
577
+ // folder-per-locale shape). Keys stay bare: code calls t('nav.home')
578
+ // with the namespace chosen elsewhere (useTranslation('common')), and
579
+ // an explicit t('common:nav.home') is reduced to its key below.
580
+ // Docusaurus keeps its historical lookup (no flat source file there).
531
581
  let localeKeys = {};
532
- if (fs.existsSync(sourcePath)) {
533
- const raw = readLocaleFile(sourcePath, format);
534
- localeKeys = format === 'json' ? flattenKeys(raw) : raw;
582
+ let namespaces = new Set();
583
+ if (config.format !== 'docusaurus') {
584
+ const layout = discoverLocaleLayout(config, { cwd });
585
+ namespaces = new Set(layout.sourceFiles.map(f => f.ns).filter(Boolean));
586
+ for (const file of layout.sourceFiles) {
587
+ if (!fs.existsSync(file.path)) continue;
588
+ Object.assign(localeKeys, readLocaleFlat(file));
589
+ }
535
590
  }
536
591
  const localeEntries = Object.entries(localeKeys);
537
592
 
@@ -560,7 +615,14 @@ async function runLint(options = {}) {
560
615
  }
561
616
  }
562
617
 
563
- // 5. Cross-reference
618
+ // 5. Cross-reference. i18next's "ns:key" form names a key inside one
619
+ // namespace file — compare its key part against the merged key set.
620
+ if (namespaces.size > 0) {
621
+ for (const ref of [...allI18nKeys]) {
622
+ const i = ref.indexOf(':');
623
+ if (i > 0 && namespaces.has(ref.slice(0, i))) allI18nKeys.add(ref.slice(i + 1));
624
+ }
625
+ }
564
626
  const deadKeys = localeEntries.length > 0
565
627
  ? findDeadKeys(allI18nKeys, localeKeys)
566
628
  : [];
@@ -0,0 +1,106 @@
1
+ /**
2
+ * local-only-marks.js — the files in a project a data steward has marked
3
+ * local-only: a `<file>.champollion.json` sidecar saying
4
+ * `"transmission": "local-only"` (what `champollion network register-corpus
5
+ * --data … --tier local-only` writes, and what the harness and nmt-forge
6
+ * honour: only a model on this machine may see that file).
7
+ *
8
+ * WHY init reads them: a project holding a local-only test set was set up
9
+ * with a hosted model by default — `init --yes` wrote OpenRouter beside a
10
+ * set its steward had said must never leave the machine (synthetic hospital
11
+ * persona, Rounds 10, 12 and 14). A mark in the project is a statement about
12
+ * where this project's text may go, so init's DEFAULT follows it: the local
13
+ * method. An explicit --method still wins; a hosted method is then the
14
+ * user's own choice, never a default.
15
+ *
16
+ * Read only: the walk opens sidecars (small JSON files) and nothing else;
17
+ * the data files they describe are never read.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ /** The sidecar suffix — mt_eval_harness.corpus_loader.SIDECAR_SUFFIX (keep in step). */
24
+ export const SIDECAR_SUFFIX = '.champollion.json';
25
+
26
+ /** The one transmission value a sidecar sets (corpus_loader.LOCAL_ONLY). */
27
+ export const LOCAL_ONLY = 'local-only';
28
+
29
+ /**
30
+ * Folders never searched: dependency trees, VCS metadata, virtualenvs,
31
+ * build caches and champollion's own cache. A steward's data sits in the
32
+ * project's own folders (data/, private/, test-sets/ …), not in these —
33
+ * and node_modules can be enormous (or, through symlinked packages, cyclic).
34
+ */
35
+ const SKIP_DIRS = new Set([
36
+ 'node_modules', '.git', '.hg', '.svn', '.champollion', '.venv', 'venv',
37
+ '__pycache__', '.next', '.nuxt', '.svelte-kit', '.turbo', '.cache',
38
+ '.pytest_cache', '.mypy_cache', '.tox',
39
+ ]);
40
+
41
+ /** How deep, and how many entries, the walk looks at — bounded, so init stays fast. */
42
+ const MAX_DEPTH = 8;
43
+ const MAX_ENTRIES = 50000;
44
+
45
+ /**
46
+ * Every local-only mark under `root`, nearest first (by depth, then path).
47
+ *
48
+ * A sidecar that cannot be read is reported as a mark (`unreadable: true`):
49
+ * the harness refuses to run a corpus whose sidecar it cannot read, and a
50
+ * steward wrote it to restrict the data — an unreadable restriction is never
51
+ * read as no restriction.
52
+ *
53
+ * @param {string} root - The project folder (init's cwd)
54
+ * @returns {{ marks: Array<{ sidecar: string, dataFile: string, unreadable: boolean }>, truncated: boolean }}
55
+ * absolute paths; `truncated` when the walk hit its bound before finishing
56
+ */
57
+ export function findLocalOnlyMarks(root) {
58
+ const marks = [];
59
+ let seen = 0;
60
+ let truncated = false;
61
+ const queue = [{ dir: root, depth: 0 }];
62
+ while (queue.length > 0) {
63
+ const { dir, depth } = queue.shift();
64
+ let entries;
65
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
66
+ entries.sort((a, b) => a.name.localeCompare(b.name));
67
+ for (const e of entries) {
68
+ if (++seen > MAX_ENTRIES) { truncated = true; break; }
69
+ const full = path.join(dir, e.name);
70
+ // Directory symlinks are not followed (no cycles, no leaving the project).
71
+ if (e.isDirectory()) {
72
+ if (SKIP_DIRS.has(e.name)) continue;
73
+ if (depth + 1 > MAX_DEPTH) { truncated = true; continue; }
74
+ queue.push({ dir: full, depth: depth + 1 });
75
+ continue;
76
+ }
77
+ if (!e.name.endsWith(SIDECAR_SUFFIX) || e.name === SIDECAR_SUFFIX) continue;
78
+ if (!(e.isFile() || e.isSymbolicLink())) continue;
79
+ const mark = readMark(full);
80
+ if (mark) marks.push({ ...mark, depth });
81
+ }
82
+ if (truncated && seen > MAX_ENTRIES) break;
83
+ }
84
+ marks.sort((a, b) => a.depth - b.depth || a.sidecar.localeCompare(b.sidecar));
85
+ return { marks: marks.map(({ depth, ...m }) => m), truncated };
86
+ }
87
+
88
+ /**
89
+ * One sidecar's mark, or null when it marks nothing local-only.
90
+ *
91
+ * @param {string} sidecar - Absolute path of a `<file>.champollion.json`
92
+ * @returns {{ sidecar: string, dataFile: string, unreadable: boolean }|null}
93
+ */
94
+ export function readMark(sidecar) {
95
+ const dataFile = sidecar.slice(0, -SIDECAR_SUFFIX.length);
96
+ let data;
97
+ try {
98
+ data = JSON.parse(fs.readFileSync(sidecar, 'utf-8').replace(/^/, ''));
99
+ } catch {
100
+ return { sidecar, dataFile, unreadable: true };
101
+ }
102
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return { sidecar, dataFile, unreadable: true };
103
+ return String(data.transmission || '').trim().toLowerCase() === LOCAL_ONLY
104
+ ? { sidecar, dataFile, unreadable: false }
105
+ : null;
106
+ }