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
package/lib/lint.js ADDED
@@ -0,0 +1,629 @@
1
+ /**
2
+ * i18n Coverage Linter — detects hardcoded strings that bypass i18n.
3
+ * Zero external dependencies. All regex-based.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { resolveConfig } from './config.js';
9
+ import { flattenKeys } from './flatten.js';
10
+ import { readLocaleFile, detectFormatFromDir, getExtension } from './format.js';
11
+ import { isNonTranslatableString, isNonTranslatableLintExtended } from './string-classify.js';
12
+ import { output } from './output.js';
13
+
14
+ // -----------------------------------------------------------------
15
+ // Framework definitions
16
+ // -----------------------------------------------------------------
17
+
18
+ const FRAMEWORKS = {
19
+ 'next-intl': {
20
+ name: 'next-intl',
21
+ srcDirs: ['src', 'app', 'pages', 'components'],
22
+ extensions: ['.tsx', '.jsx', '.ts', '.js'],
23
+ i18nCallPatterns: [
24
+ /\bt\(\s*['"`]([^'"`]+)['"`]\s*\)/g,
25
+ /useTranslations\(\s*['"`]([^'"`]+)['"`]\s*\)/g,
26
+ ],
27
+ frameworkImport: /from\s+['"]next-intl['"]/,
28
+ translatableAttrs: ['placeholder', 'aria-label', 'alt', 'title', 'content', 'label'],
29
+ },
30
+ 'react-i18next': {
31
+ name: 'react-i18next',
32
+ srcDirs: ['src', 'app', 'pages', 'components'],
33
+ extensions: ['.tsx', '.jsx', '.ts', '.js'],
34
+ i18nCallPatterns: [
35
+ /\bt\(\s*['"`]([^'"`]+)['"`]\s*\)/g,
36
+ /i18nKey\s*=\s*['"`]([^'"`]+)['"`]/g,
37
+ ],
38
+ frameworkImport: /from\s+['"]react-i18next['"]/,
39
+ translatableAttrs: ['placeholder', 'aria-label', 'alt', 'title', 'content', 'label'],
40
+ },
41
+ 'vue-i18n': {
42
+ name: 'vue-i18n',
43
+ srcDirs: ['src', 'app', 'pages', 'components'],
44
+ extensions: ['.vue', '.js', '.ts'],
45
+ i18nCallPatterns: [
46
+ /\$t\(\s*['"`]([^'"`]+)['"`]\s*\)/g,
47
+ /\bt\(\s*['"`]([^'"`]+)['"`]\s*\)/g,
48
+ /keypath\s*=\s*['"`]([^'"`]+)['"`]/g,
49
+ ],
50
+ frameworkImport: /from\s+['"]vue-i18n['"]/,
51
+ translatableAttrs: ['placeholder', 'aria-label', 'alt', 'title', 'content', 'label'],
52
+ },
53
+ hugo: {
54
+ name: 'Hugo',
55
+ srcDirs: ['layouts', 'themes'],
56
+ extensions: ['.html'],
57
+ i18nCallPatterns: [
58
+ /\{\{\s*(?:i18n|T)\s+['"`]([^'"`]+)['"`]/g,
59
+ ],
60
+ frameworkImport: null,
61
+ translatableAttrs: ['alt', 'title', 'placeholder', 'aria-label', 'content'],
62
+ },
63
+ };
64
+
65
+ // Default generic framework for unknown projects
66
+ const GENERIC_FRAMEWORK = {
67
+ name: 'generic',
68
+ srcDirs: ['src', 'app', 'pages', 'components'],
69
+ extensions: ['.tsx', '.jsx', '.ts', '.js', '.vue', '.html'],
70
+ i18nCallPatterns: [/\bt\(\s*['"`]([^'"`]+)['"`]\s*\)/g],
71
+ frameworkImport: null,
72
+ translatableAttrs: ['placeholder', 'aria-label', 'alt', 'title', 'content', 'label'],
73
+ };
74
+
75
+ // -----------------------------------------------------------------
76
+ // Framework detection
77
+ // -----------------------------------------------------------------
78
+
79
+ /**
80
+ * Detect the i18n framework used in the project.
81
+ *
82
+ * Checks for Hugo config files, then falls back to package.json dependencies.
83
+ *
84
+ * @param {string} cwd - Project root directory
85
+ * @returns {{ name: string, extensions: string[], srcDirs: string[], i18nCallPatterns: RegExp[], frameworkImport: RegExp|null }} Framework detection result
86
+ */
87
+ function detectFramework(cwd) {
88
+ // Check for Hugo config files first
89
+ const hugoConfigs = ['hugo.toml', 'hugo.yaml', 'hugo.yml', 'config.toml', 'config.yaml'];
90
+ for (const cfg of hugoConfigs) {
91
+ if (fs.existsSync(path.join(cwd, cfg))) return FRAMEWORKS.hugo;
92
+ }
93
+
94
+ // Check package.json dependencies
95
+ const pkgPath = path.join(cwd, 'package.json');
96
+ if (fs.existsSync(pkgPath)) {
97
+ try {
98
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
99
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
100
+ if (allDeps['next-intl']) return FRAMEWORKS['next-intl'];
101
+ if (allDeps['react-i18next'] || allDeps['i18next']) return FRAMEWORKS['react-i18next'];
102
+ if (allDeps['vue-i18n']) return FRAMEWORKS['vue-i18n'];
103
+ } catch (_) { /* ignore parse errors */ }
104
+ }
105
+
106
+ return GENERIC_FRAMEWORK;
107
+ }
108
+
109
+ // -----------------------------------------------------------------
110
+ // File walking
111
+ // -----------------------------------------------------------------
112
+
113
+ /**
114
+ * Recursively walk a directory and collect files matching extensions.
115
+ *
116
+ * @param {string} dir - Absolute path to directory
117
+ * @param {string[]} extensions - File extensions to include (e.g., ['.tsx', '.jsx'])
118
+ * @param {string[]} ignore - Directory/file names to skip
119
+ * @returns {string[]} Array of absolute file paths
120
+ */
121
+ function walkDir(dir, extensions, ignore) {
122
+ const results = [];
123
+ if (!fs.existsSync(dir)) return results;
124
+
125
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
126
+ for (const entry of entries) {
127
+ const fullPath = path.join(dir, entry.name);
128
+ if (ignore.some(ig => entry.name === ig)) continue;
129
+
130
+ if (entry.isDirectory()) {
131
+ results.push(...walkDir(fullPath, extensions, ignore));
132
+ } else if (extensions.some(ext => entry.name.endsWith(ext))) {
133
+ results.push(fullPath);
134
+ }
135
+ }
136
+ return results;
137
+ }
138
+
139
+ // -----------------------------------------------------------------
140
+ // String analysis utilities
141
+ // -----------------------------------------------------------------
142
+
143
+ /**
144
+ * Determine if a string should be flagged as hardcoded user-facing content.
145
+ * Returns false for strings that look like code, URLs, identifiers, etc.
146
+ *
147
+ * @param {string} text - The string to evaluate
148
+ * @param {number} minLength - Minimum length threshold
149
+ * @returns {boolean} true if the string should be flagged for i18n wrapping
150
+ */
151
+ function shouldFlagString(text, minLength) {
152
+ // Shared core: checks common to both lint and autofix
153
+ // (identifiers, numbers, URLs, hex colors, template expressions, etc.)
154
+ if (isNonTranslatableString(text, minLength)) return false;
155
+
156
+ // Lint-specific extensions: file paths, emails, HTML entities,
157
+ // short HTML tags, and TypeScript type signatures.
158
+ // These appear in source code scanning but not in autofix's JSX context.
159
+ if (isNonTranslatableLintExtended(text)) return false;
160
+
161
+ return true;
162
+ }
163
+
164
+ // -----------------------------------------------------------------
165
+ // Source file scanning
166
+ // -----------------------------------------------------------------
167
+
168
+ /**
169
+ * Extract all i18n key references from file content.
170
+ * Returns a Set of key strings found via t('key'), {{ i18n "key" }}, etc.
171
+ *
172
+ * @param {string} content - File content to scan
173
+ * @param {{ i18nCallPatterns: RegExp[] }} framework - Framework with i18n call patterns
174
+ * @returns {Set<string>} Set of referenced i18n key strings
175
+ */
176
+ function extractI18nCalls(content, framework) {
177
+ const keys = new Set();
178
+ for (const pattern of framework.i18nCallPatterns) {
179
+ // Reset lastIndex for each pattern (they have /g flag)
180
+ const regex = new RegExp(pattern.source, pattern.flags);
181
+ let match;
182
+ while ((match = regex.exec(content)) !== null) {
183
+ keys.add(match[1]);
184
+ }
185
+ }
186
+ return keys;
187
+ }
188
+
189
+ /**
190
+ * Extract hardcoded user-facing strings from a JSX/TSX/HTML file.
191
+ *
192
+ * @param {string} content - File content to scan
193
+ * @param {string} filePath - File path (for context in results)
194
+ * @param {{ name: string, i18nCallPatterns: RegExp[] }} framework - Detected framework
195
+ * @param {number} minLength - Minimum string length to flag
196
+ * @returns {Array<{ line: number, text: string, context: string }>} Array of hardcoded string hits
197
+ */
198
+ function extractHardcodedStrings(content, filePath, framework, minLength) {
199
+ const results = [];
200
+ const lines = content.split('\n');
201
+ let inBlockComment = false;
202
+
203
+ for (let i = 0; i < lines.length; i++) {
204
+ const line = lines[i];
205
+ const trimmed = line.trim();
206
+
207
+ // Track block comments
208
+ if (inBlockComment) {
209
+ if (trimmed.includes('*/')) inBlockComment = false;
210
+ continue;
211
+ }
212
+ if (trimmed.startsWith('/*')) {
213
+ if (!trimmed.includes('*/')) inBlockComment = true;
214
+ continue;
215
+ }
216
+
217
+ // Skip non-content lines
218
+ if (trimmed.startsWith('//')) continue;
219
+ if (/^\s*import\s/.test(trimmed)) continue;
220
+ if (/^\s*(?:export\s+)?(?:type|interface|enum)\s/.test(trimmed)) continue;
221
+ if (/^\s*console\.\w+\(/.test(trimmed)) continue;
222
+ if (/^\s*(?:const|let|var)\s+\w+\s*=\s*['"`]/.test(trimmed)) continue;
223
+
224
+ // Skip TypeScript-heavy lines (type annotations, function signatures, generics)
225
+ // Lines that are purely function/type signatures: "): Promise<Response> {" etc.
226
+ if (/^\s*[})\]]\s*[:,]\s*\w/.test(trimmed)) continue;
227
+ // Lines with type assertions or generic declarations
228
+ if (/^\s*(?:export\s+)?(?:function|async\s+function|const)\s+\w+.*:\s*\w+/.test(trimmed) && !trimmed.includes('>') && !trimmed.includes('"')) continue;
229
+
230
+ // Skip lines that are purely JSX expressions or logic
231
+ if (/^\s*\{.*\}\s*$/.test(trimmed) && !trimmed.includes('>')) continue;
232
+ if (/^\s*(?:return|if|else|switch|case|for|while)\b/.test(trimmed) && !trimmed.includes('>')) continue;
233
+
234
+ // --- Extract JSX text content: >text here< ---
235
+ const textPattern = />([^<>{]+)</g;
236
+ let match;
237
+ while ((match = textPattern.exec(line)) !== null) {
238
+ const text = match[1].trim();
239
+ if (shouldFlagString(text, minLength)) {
240
+ results.push({ line: i + 1, text, context: 'jsx-text' });
241
+ }
242
+ }
243
+
244
+ // --- Extract translatable attribute values: attr="value" ---
245
+ // Only flag string literals, not JSX expressions (attr={...})
246
+ for (const attr of framework.translatableAttrs) {
247
+ // Double-quoted
248
+ const dq = new RegExp(`${attr}\\s*=\\s*"([^"]*)"`, 'g');
249
+ while ((match = dq.exec(line)) !== null) {
250
+ const text = match[1].trim();
251
+ if (shouldFlagString(text, minLength)) {
252
+ results.push({ line: i + 1, text, context: `attr:${attr}` });
253
+ }
254
+ }
255
+ // Single-quoted
256
+ const sq = new RegExp(`${attr}\\s*=\\s*'([^']*)'`, 'g');
257
+ while ((match = sq.exec(line)) !== null) {
258
+ const text = match[1].trim();
259
+ if (shouldFlagString(text, minLength)) {
260
+ results.push({ line: i + 1, text, context: `attr:${attr}` });
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ return results;
267
+ }
268
+
269
+ /**
270
+ * Check if files that should have i18n imports are missing them.
271
+ *
272
+ * @param {string[]} files - Array of absolute file paths to check
273
+ * @param {{ frameworkImport: RegExp|null, extensions: string[] }} framework - Detected framework
274
+ * @param {number} minLength - Minimum string length threshold
275
+ * @returns {string[]} Array of file paths with user-facing content but no framework import
276
+ */
277
+ function checkFrameworkWiring(files, framework, minLength) {
278
+ if (!framework.frameworkImport) return [];
279
+
280
+ const unwired = [];
281
+ for (const filePath of files) {
282
+ const content = fs.readFileSync(filePath, 'utf-8');
283
+ // Does the file have any user-facing content?
284
+ const hardcoded = extractHardcodedStrings(content, filePath, framework, minLength);
285
+ if (hardcoded.length === 0) continue;
286
+ // Does it import the i18n framework?
287
+ if (!framework.frameworkImport.test(content)) {
288
+ unwired.push(filePath);
289
+ }
290
+ }
291
+ return unwired;
292
+ }
293
+
294
+ // -----------------------------------------------------------------
295
+ // Cross-referencing
296
+ // -----------------------------------------------------------------
297
+
298
+ /**
299
+ * Find locale keys that are never referenced in source code.
300
+ *
301
+ * @param {Set<string>} allI18nKeys - All i18n key references found in source files
302
+ * @param {Object<string, string>} localeKeys - Flattened locale key-value map
303
+ * @returns {string[]} Array of unreferenced key names
304
+ */
305
+ function findDeadKeys(allI18nKeys, localeKeys) {
306
+ return Object.keys(localeKeys).filter(k => {
307
+ if (allI18nKeys.has(k)) return false;
308
+ // Check namespace.key pattern: t('title') matches 'hero.title'
309
+ for (const ref of allI18nKeys) {
310
+ if (k.endsWith('.' + ref)) return false;
311
+ }
312
+ return true;
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Try to find a matching locale key for a hardcoded string value.
318
+ * Compares normalized strings against locale values.
319
+ *
320
+ * @param {string} text - The hardcoded string to match
321
+ * @param {Array<[string, unknown]>} localeEntries - Entries from flattened locale object
322
+ * @returns {{ key: string, confidence: 'exact'|'partial' }|null} Match result, or null if no match
323
+ */
324
+ function findFuzzyMatch(text, localeEntries) {
325
+ const norm = text.toLowerCase().replace(/[^\w\s]/g, '').trim();
326
+ if (norm.length < 3) return null;
327
+
328
+ for (const [key, value] of localeEntries) {
329
+ if (typeof value !== 'string') continue;
330
+ const normVal = value.toLowerCase().replace(/[^\w\s]/g, '').trim();
331
+ if (norm === normVal) return { key, confidence: 'exact' };
332
+ }
333
+
334
+ // Partial match for longer strings
335
+ for (const [key, value] of localeEntries) {
336
+ if (typeof value !== 'string') continue;
337
+ const normVal = value.toLowerCase().replace(/[^\w\s]/g, '').trim();
338
+ if (norm.length > 10 && normVal.includes(norm)) return { key, confidence: 'partial' };
339
+ }
340
+
341
+ return null;
342
+ }
343
+
344
+ // -----------------------------------------------------------------
345
+ // Report generation
346
+ // -----------------------------------------------------------------
347
+
348
+ /**
349
+ * Generate a human-readable lint coverage report and print to stdout.
350
+ *
351
+ * @param {{ framework: object, hardcoded: Array, deadKeys: string[], unwiredFiles: string[], localeKeyCount: number, i18nCallCount: number, fileCount: number, coverage: number }} results - Aggregated lint results
352
+ */
353
+ function generateReport(results) {
354
+ const { framework, hardcoded, deadKeys, unwiredFiles, localeKeyCount,
355
+ i18nCallCount, fileCount, coverage } = results;
356
+
357
+ const lines = [];
358
+ lines.push('');
359
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
360
+ lines.push(' champollion lint — Coverage Report');
361
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
362
+ lines.push('');
363
+ lines.push(` Framework: ${framework.name} (auto-detected)`);
364
+ lines.push(` Source locale: ${localeKeyCount} keys`);
365
+ lines.push(` Files scanned: ${fileCount}`);
366
+ lines.push('');
367
+
368
+ // Framework wiring warnings
369
+ if (unwiredFiles.length > 0) {
370
+ lines.push(' [WARN] FRAMEWORK SETUP');
371
+ lines.push(` └── ${framework.name} detected but ${unwiredFiles.length} file(s) have content without i18n import:`);
372
+ for (const f of unwiredFiles.slice(0, 10)) {
373
+ lines.push(` ${f}`);
374
+ }
375
+ if (unwiredFiles.length > 10) lines.push(` ... and ${unwiredFiles.length - 10} more`);
376
+ lines.push('');
377
+ }
378
+
379
+ // Hardcoded strings
380
+ if (hardcoded.length > 0) {
381
+ lines.push(` HARDCODED STRINGS (${hardcoded.length} found)`);
382
+ lines.push('');
383
+
384
+ // Group by file
385
+ const byFile = {};
386
+ for (const item of hardcoded) {
387
+ const key = item.file;
388
+ if (!byFile[key]) byFile[key] = [];
389
+ byFile[key].push(item);
390
+ }
391
+
392
+ for (const [file, items] of Object.entries(byFile)) {
393
+ lines.push(` ${file}`);
394
+ for (const item of items.slice(0, 15)) {
395
+ const truncated = item.text.length > 60
396
+ ? item.text.slice(0, 57) + '...'
397
+ : item.text;
398
+ lines.push(` ├── L${item.line}: "${truncated}"`);
399
+ if (item.suggestedKey) {
400
+ lines.push(` │ Match: ${item.suggestedKey}`);
401
+ }
402
+ }
403
+ if (items.length > 15) {
404
+ lines.push(` └── ... and ${items.length - 15} more in this file`);
405
+ }
406
+ lines.push('');
407
+ }
408
+ }
409
+
410
+ // Dead keys
411
+ if (deadKeys.length > 0) {
412
+ lines.push(` [WARN] DEAD KEYS (${deadKeys.length} unreferenced)`);
413
+ lines.push(' └── Keys in source locale not used by any source file:');
414
+ for (const key of deadKeys.slice(0, 15)) {
415
+ lines.push(` ${key}`);
416
+ }
417
+ if (deadKeys.length > 15) lines.push(` ... and ${deadKeys.length - 15} more`);
418
+ lines.push('');
419
+ }
420
+
421
+ // Coverage
422
+ const total = i18nCallCount + hardcoded.length;
423
+ lines.push(' COVERAGE');
424
+ lines.push(` ├── i18n calls found: ${i18nCallCount}`);
425
+ lines.push(` ├── Hardcoded strings: ${hardcoded.length}`);
426
+ lines.push(` ├── Coverage: ${coverage}% (${i18nCallCount} / ${total})`);
427
+ lines.push(` └── Dead keys: ${deadKeys.length} / ${localeKeyCount}`);
428
+ lines.push('');
429
+
430
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
431
+ if (hardcoded.length > 0) {
432
+ lines.push(` Result: FAIL — ${hardcoded.length} hardcoded string(s) detected`);
433
+ lines.push(' Run with --warn-only to suppress exit code');
434
+ } else {
435
+ lines.push(' Result: PASS — all user-facing strings use i18n');
436
+ }
437
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
438
+ lines.push('');
439
+
440
+ output.raw(lines.join('\n'));
441
+ }
442
+
443
+ // -----------------------------------------------------------------
444
+ // Main entry point
445
+ // -----------------------------------------------------------------
446
+
447
+ /**
448
+ * Load ignore patterns from .champollionignore file.
449
+ * Format: one pattern per line, # comments, blank lines ignored.
450
+ * Patterns are directory/file basenames or globs matched against paths.
451
+ *
452
+ * @param {string} cwd - Project root directory
453
+ * @returns {string[]} Array of ignore patterns
454
+ */
455
+ function loadIgnorePatterns(cwd) {
456
+ const ignorePath = path.join(cwd, '.champollionignore');
457
+ if (!fs.existsSync(ignorePath)) return [];
458
+
459
+ return fs.readFileSync(ignorePath, 'utf-8')
460
+ .split('\n')
461
+ .map(line => line.trim())
462
+ .filter(line => line && !line.startsWith('#'));
463
+ }
464
+
465
+ /**
466
+ * Run the lint pipeline: detect framework, scan files, cross-reference, report.
467
+ *
468
+ * @param {{ cwd?: string, cliArgs?: import('./types.js').CLIArgs, warnOnly?: boolean }} [options] - Lint options
469
+ * @returns {Promise<number>} Exit code (0 = clean, 1 = issues found)
470
+ */
471
+ async function runLint(options = {}) {
472
+ const { cwd = process.cwd(), cliArgs = {}, warnOnly = false } = options;
473
+ const config = resolveConfig(cliArgs, cwd);
474
+ const lintConfig = config.lint || {};
475
+
476
+ const minLength = parseInt(cliArgs['min-length'] || lintConfig.minLength || 2, 10);
477
+ const defaultIgnore = [
478
+ 'node_modules', '.next', 'dist', 'build', '.git', 'public', '.vercel',
479
+ '__tests__', 'test', 'tests', 'coverage', '.turbo',
480
+ ];
481
+
482
+ // Merge: config ignore + .champollionignore file + CLI --ignore
483
+ const fileIgnore = loadIgnorePatterns(cwd);
484
+ const configIgnore = lintConfig.ignore || [];
485
+ const cliIgnore = cliArgs.ignore ? cliArgs.ignore.split(',').map(s => s.trim()) : [];
486
+ const ignorePatterns = [...new Set([...defaultIgnore, ...configIgnore, ...fileIgnore, ...cliIgnore])];
487
+
488
+ // 1. Detect framework
489
+ const framework = detectFramework(cwd);
490
+ output.info(`Detected framework: ${framework.name}`);
491
+
492
+ // 2. Find source files
493
+ const srcDir = cliArgs.src || lintConfig.srcDir || null;
494
+ let sourceFiles = [];
495
+
496
+ if (srcDir) {
497
+ // Explicit src dir
498
+ sourceFiles = walkDir(path.resolve(cwd, srcDir), framework.extensions, ignorePatterns);
499
+ } else {
500
+ // Auto-detect from framework src dirs
501
+ for (const dir of framework.srcDirs) {
502
+ const resolved = path.resolve(cwd, dir);
503
+ sourceFiles.push(...walkDir(resolved, framework.extensions, ignorePatterns));
504
+ }
505
+ }
506
+
507
+ if (sourceFiles.length === 0) {
508
+ if (cliArgs.json) {
509
+ console.log(JSON.stringify({
510
+ command: 'lint',
511
+ framework: framework.name,
512
+ filesScanned: 0,
513
+ findings: [],
514
+ count: 0,
515
+ }, null, 2));
516
+ return 0;
517
+ }
518
+ output.info('No source files found to lint.');
519
+ return 0;
520
+ }
521
+
522
+ output.info(`Scanning ${sourceFiles.length} file(s)...`);
523
+
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
+
531
+ let localeKeys = {};
532
+ if (fs.existsSync(sourcePath)) {
533
+ const raw = readLocaleFile(sourcePath, format);
534
+ localeKeys = format === 'json' ? flattenKeys(raw) : raw;
535
+ }
536
+ const localeEntries = Object.entries(localeKeys);
537
+
538
+ // 4. Scan all source files
539
+ const allI18nKeys = new Set();
540
+ const allHardcoded = [];
541
+ let totalI18nCalls = 0;
542
+
543
+ for (const filePath of sourceFiles) {
544
+ const content = fs.readFileSync(filePath, 'utf-8');
545
+ const relPath = path.relative(cwd, filePath);
546
+
547
+ // Extract i18n calls
548
+ const i18nKeys = extractI18nCalls(content, framework);
549
+ for (const key of i18nKeys) allI18nKeys.add(key);
550
+ totalI18nCalls += i18nKeys.size;
551
+
552
+ // Extract hardcoded strings
553
+ const hardcoded = extractHardcodedStrings(content, filePath, framework, minLength);
554
+ for (const item of hardcoded) {
555
+ item.file = relPath;
556
+ // Try to find a matching locale key
557
+ const match = findFuzzyMatch(item.text, localeEntries);
558
+ if (match) item.suggestedKey = match.key;
559
+ allHardcoded.push(item);
560
+ }
561
+ }
562
+
563
+ // 5. Cross-reference
564
+ const deadKeys = localeEntries.length > 0
565
+ ? findDeadKeys(allI18nKeys, localeKeys)
566
+ : [];
567
+
568
+ // 6. Check framework wiring
569
+ const unwiredFiles = checkFrameworkWiring(sourceFiles, framework, minLength)
570
+ .map(f => path.relative(cwd, f));
571
+
572
+ // 7. Calculate coverage
573
+ const total = totalI18nCalls + allHardcoded.length;
574
+ const coverage = total > 0 ? Math.round((totalI18nCalls / total) * 100) : 100;
575
+
576
+ // 8. Generate report — single JSON document in --json mode (the human
577
+ // report stays the default), collected instead of print-only.
578
+ if (cliArgs.json) {
579
+ console.log(JSON.stringify({
580
+ command: 'lint',
581
+ framework: framework.name,
582
+ filesScanned: sourceFiles.length,
583
+ localeKeys: Object.keys(localeKeys).length,
584
+ i18nCalls: totalI18nCalls,
585
+ coverage,
586
+ findings: allHardcoded.map(item => ({
587
+ file: item.file,
588
+ line: item.line,
589
+ text: item.text,
590
+ context: item.context,
591
+ suggestedKey: item.suggestedKey || null,
592
+ })),
593
+ count: allHardcoded.length,
594
+ deadKeys,
595
+ unwiredFiles,
596
+ warnOnly,
597
+ }, null, 2));
598
+ } else {
599
+ generateReport({
600
+ framework,
601
+ hardcoded: allHardcoded,
602
+ deadKeys,
603
+ unwiredFiles,
604
+ localeKeyCount: Object.keys(localeKeys).length,
605
+ i18nCallCount: totalI18nCalls,
606
+ fileCount: sourceFiles.length,
607
+ coverage,
608
+ });
609
+ }
610
+
611
+ // 9. Return exit code
612
+ if (allHardcoded.length > 0 && !warnOnly) return 1;
613
+ return 0;
614
+ }
615
+
616
+ export {
617
+ runLint,
618
+ detectFramework,
619
+ extractI18nCalls,
620
+ extractHardcodedStrings,
621
+ shouldFlagString,
622
+ findDeadKeys,
623
+ findFuzzyMatch,
624
+ walkDir,
625
+ checkFrameworkWiring,
626
+ loadIgnorePatterns,
627
+ FRAMEWORKS,
628
+ GENERIC_FRAMEWORK,
629
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Loader for the shared method/provider registry SSOT (shared/method-registry.json).
3
+ *
4
+ * Single source of truth, shared with the Python arena harness
5
+ * (arena/mt_eval_harness/method_manifest.py), for which translation METHODS
6
+ * (MT engines) and LLM PROVIDERS exist and their declarative metadata.
7
+ *
8
+ * Mirrors lib/models.js's loader: prefer the package-bundled copy (cli/shared/,
9
+ * shipped in the npm package), fall back to the monorepo-root SSOT for in-repo
10
+ * dev. Returns null if neither is found, so the parity test skips rather than
11
+ * failing in a context without the manifest.
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ let _cache; // undefined = not loaded, null = not found, object = loaded
19
+
20
+ /** @returns {object|null} parsed manifest, or null if not found */
21
+ export function loadMethodManifest() {
22
+ if (_cache !== undefined) return _cache;
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const candidates = [
25
+ path.resolve(__dirname, '..', 'shared', 'method-registry.json'),
26
+ path.resolve(__dirname, '..', '..', 'shared', 'method-registry.json'),
27
+ ];
28
+ for (const p of candidates) {
29
+ try {
30
+ _cache = JSON.parse(fs.readFileSync(p, 'utf-8'));
31
+ return _cache;
32
+ } catch { /* try next */ }
33
+ }
34
+ _cache = null;
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Return manifest entries, optionally filtered to one kind.
40
+ * @param {string|null} kind
41
+ * @returns {Object<string, object>}
42
+ */
43
+ export function manifestEntries(kind = null) {
44
+ const m = loadMethodManifest();
45
+ if (!m || !m.entries) return {};
46
+ const out = {};
47
+ for (const [name, entry] of Object.entries(m.entries)) {
48
+ if (kind === null || entry.kind === kind) out[name] = entry;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * The CLI METHOD_REGISTRY key for a manifest entry: the cli_name override if
55
+ * present (e.g. the canonical "openrouter" provider is "llm" in the CLI), else
56
+ * the canonical name.
57
+ */
58
+ export function cliNameFor(name, entry) {
59
+ return (entry && entry.cli_name) || name;
60
+ }