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.
- package/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- 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
|
+
}
|