Skip to content

Commit 55ac540

Browse files
committed
feat(ci): Add locale key ordering linter to CI workflow
Adds a new CI step to verify that all locale files maintain the same key ordering as their corresponding English locale files. This helps ensure consistency across all committed translations and prevents accidental reordering going forward The new `lint-locale-key-ordering.js` script checks both backend (`src/i18n/locales`) and frontend (`webview-ui/src/i18n/locales`) translation files. It identifies missing keys, extra keys, and keys that are out of order compared to the English reference. This linter will run as part of the `code-qa` workflow.
1 parent db1d139 commit 55ac540

File tree

2 files changed

+301
-0
lines changed

2 files changed

+301
-0
lines changed

.github/workflows/code-qa.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ jobs:
1818
uses: ./.github/actions/setup-node-pnpm
1919
- name: Verify all translations are complete
2020
run: node scripts/find-missing-translations.js
21+
- name: Verify all translations are ordered properly
22+
run: node scripts/lint-locale-key-ordering.js
2123

2224
knip:
2325
runs-on: ubuntu-latest
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Script to lint locale file key ordering consistency
5+
*
6+
* This script ensures that all locale files maintain the same key ordering
7+
* as their corresponding English locale files. This helps maintain consistency
8+
* across all committed translations.
9+
*
10+
* Usage:
11+
* node scripts/lint-locale-key-ordering.js [options]
12+
* tsx scripts/lint-locale-key-ordering.js [options]
13+
*
14+
* Options:
15+
* --locale=<locale> Only check a specific locale (e.g. --locale=fr)
16+
* --file=<file> Only check a specific file (e.g. --file=chat.json)
17+
* --area=<area> Only check a specific area (core, webview, or both)
18+
* --help Show this help message
19+
*/
20+
21+
const fs = require("fs")
22+
const path = require("path")
23+
24+
// Process command line arguments
25+
const args = process.argv.slice(2).reduce(
26+
(acc, arg) => {
27+
if (arg === "--help") {
28+
acc.help = true
29+
} else if (arg.startsWith("--locale=")) {
30+
acc.locale = arg.split("=")[1]
31+
} else if (arg.startsWith("--file=")) {
32+
acc.file = arg.split("=")[1]
33+
} else if (arg.startsWith("--area=")) {
34+
acc.area = arg.split("=")[1]
35+
// Validate area value
36+
if (!["core", "webview", "both"].includes(acc.area)) {
37+
console.error(`Error: Invalid area '${acc.area}'. Must be 'core', 'webview', or 'both'.`)
38+
process.exit(1)
39+
}
40+
}
41+
return acc
42+
},
43+
{ area: "both" },
44+
)
45+
46+
// Show help if requested
47+
if (args.help) {
48+
console.log(`
49+
Locale Key Ordering Linter
50+
51+
A utility script to ensure consistent key ordering across all locale files.
52+
Compares the key ordering in non-English locale files to the English reference
53+
to identify any ordering mismatches.
54+
55+
Usage:
56+
node scripts/lint-locale-key-ordering.js [options]
57+
tsx scripts/lint-locale-key-ordering.js [options]
58+
59+
Options:
60+
--locale=<locale> Only check a specific locale (e.g. --locale=fr)
61+
--file=<file> Only check a specific file (e.g. --file=chat.json)
62+
--area=<area> Only check a specific area (core, webview, or both)
63+
'core' = Backend (src/i18n/locales)
64+
'webview' = Frontend UI (webview-ui/src/i18n/locales)
65+
'both' = Check both areas (default)
66+
--help Show this help message
67+
68+
Exit Codes:
69+
0 = All key ordering is consistent
70+
1 = Key ordering inconsistencies found
71+
`)
72+
process.exit(0)
73+
}
74+
75+
// Paths to the locales directories
76+
const LOCALES_DIRS = {
77+
core: path.join(__dirname, "../src/i18n/locales"),
78+
webview: path.join(__dirname, "../webview-ui/src/i18n/locales"),
79+
}
80+
81+
// Determine which areas to check based on args
82+
const areasToCheck = args.area === "both" ? ["core", "webview"] : [args.area]
83+
84+
/**
85+
* Extract keys from a JSON object in the order they appear
86+
* @param {Object} obj - The JSON object
87+
* @param {string} prefix - The current key prefix for nested objects
88+
* @returns {string[]} Array of dot-notation keys in order
89+
*/
90+
function extractKeysInOrder(obj, prefix = "") {
91+
const keys = []
92+
93+
for (const [key, value] of Object.entries(obj)) {
94+
const fullKey = prefix ? `${prefix}.${key}` : key
95+
96+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
97+
// For nested objects, add the parent key first, then recursively add child keys
98+
keys.push(fullKey)
99+
keys.push(...extractKeysInOrder(value, fullKey))
100+
} else {
101+
// For primitive values, just add the key
102+
keys.push(fullKey)
103+
}
104+
}
105+
106+
return keys
107+
}
108+
109+
/**
110+
* Compare two arrays of keys and find ordering differences
111+
* @param {string[]} englishKeys - Keys from English locale
112+
* @param {string[]} localeKeys - Keys from target locale
113+
* @returns {Object} Object containing ordering issues
114+
*/
115+
function compareKeyOrdering(englishKeys, localeKeys) {
116+
const issues = {
117+
missing: [],
118+
extra: [],
119+
outOfOrder: [],
120+
}
121+
122+
// Find missing and extra keys
123+
const englishSet = new Set(englishKeys)
124+
const localeSet = new Set(localeKeys)
125+
126+
issues.missing = englishKeys.filter((key) => !localeSet.has(key))
127+
issues.extra = localeKeys.filter((key) => !englishSet.has(key))
128+
129+
// Check ordering for common keys
130+
const commonKeys = englishKeys.filter((key) => localeSet.has(key))
131+
const localeCommonKeys = localeKeys.filter((key) => englishSet.has(key))
132+
133+
for (let i = 0; i < commonKeys.length; i++) {
134+
if (commonKeys[i] !== localeCommonKeys[i]) {
135+
issues.outOfOrder.push({
136+
expected: commonKeys[i],
137+
actual: localeCommonKeys[i],
138+
position: i,
139+
})
140+
}
141+
}
142+
143+
return issues
144+
}
145+
146+
/**
147+
* Check key ordering for a specific area
148+
* @param {string} area - Area to check ('core' or 'webview')
149+
* @returns {boolean} True if there are ordering issues
150+
*/
151+
function checkAreaKeyOrdering(area) {
152+
const LOCALES_DIR = LOCALES_DIRS[area]
153+
154+
// Get all locale directories (excluding English)
155+
const allLocales = fs.readdirSync(LOCALES_DIR).filter((item) => {
156+
const stats = fs.statSync(path.join(LOCALES_DIR, item))
157+
return stats.isDirectory() && item !== "en"
158+
})
159+
160+
// Filter to the specified locale if provided
161+
const locales = args.locale ? allLocales.filter((locale) => locale === args.locale) : allLocales
162+
163+
if (args.locale && locales.length === 0) {
164+
console.error(`Error: Locale '${args.locale}' not found in ${LOCALES_DIR}`)
165+
process.exit(1)
166+
}
167+
168+
console.log(
169+
`\n${area === "core" ? "BACKEND" : "FRONTEND"} - Checking key ordering for ${locales.length} locale(s): ${locales.join(", ")}`,
170+
)
171+
172+
// Get all English JSON files
173+
const englishDir = path.join(LOCALES_DIR, "en")
174+
let englishFiles = fs.readdirSync(englishDir).filter((file) => file.endsWith(".json") && !file.startsWith("."))
175+
176+
// Filter to the specified file if provided
177+
if (args.file) {
178+
if (!englishFiles.includes(args.file)) {
179+
console.error(`Error: File '${args.file}' not found in ${englishDir}`)
180+
process.exit(1)
181+
}
182+
englishFiles = englishFiles.filter((file) => file === args.file)
183+
}
184+
185+
console.log(`Checking ${englishFiles.length} file(s): ${englishFiles.join(", ")}`)
186+
187+
let hasOrderingIssues = false
188+
189+
// Check each locale
190+
for (const locale of locales) {
191+
let localeHasIssues = false
192+
const localeIssues = []
193+
194+
for (const fileName of englishFiles) {
195+
const englishFilePath = path.join(englishDir, fileName)
196+
const localeFilePath = path.join(LOCALES_DIR, locale, fileName)
197+
198+
// Check if the locale file exists
199+
if (!fs.existsSync(localeFilePath)) {
200+
localeHasIssues = true
201+
localeIssues.push(` ⚠️ ${fileName}: File missing in ${locale}`)
202+
continue
203+
}
204+
205+
// Load and parse both files
206+
let englishContent, localeContent
207+
208+
try {
209+
englishContent = JSON.parse(fs.readFileSync(englishFilePath, "utf8"))
210+
localeContent = JSON.parse(fs.readFileSync(localeFilePath, "utf8"))
211+
} catch (e) {
212+
localeHasIssues = true
213+
localeIssues.push(` ❌ ${fileName}: JSON parsing error - ${e.message}`)
214+
continue
215+
}
216+
217+
// Extract keys in order
218+
const englishKeys = extractKeysInOrder(englishContent)
219+
const localeKeys = extractKeysInOrder(localeContent)
220+
221+
// Compare ordering
222+
const issues = compareKeyOrdering(englishKeys, localeKeys)
223+
224+
if (issues.missing.length > 0 || issues.extra.length > 0 || issues.outOfOrder.length > 0) {
225+
localeHasIssues = true
226+
localeIssues.push(` ❌ ${fileName}: Key ordering issues found`)
227+
228+
if (issues.missing.length > 0) {
229+
localeIssues.push(
230+
` Missing keys: ${issues.missing.slice(0, 3).join(", ")}${issues.missing.length > 3 ? ` (+${issues.missing.length - 3} more)` : ""}`,
231+
)
232+
}
233+
234+
if (issues.extra.length > 0) {
235+
localeIssues.push(
236+
` Extra keys: ${issues.extra.slice(0, 3).join(", ")}${issues.extra.length > 3 ? ` (+${issues.extra.length - 3} more)` : ""}`,
237+
)
238+
}
239+
240+
if (issues.outOfOrder.length > 0) {
241+
const firstMismatches = issues.outOfOrder
242+
.slice(0, 2)
243+
.map((issue) => `expected '${issue.expected}' but found '${issue.actual}'`)
244+
.join(", ")
245+
localeIssues.push(
246+
` Order mismatches: ${firstMismatches}${issues.outOfOrder.length > 2 ? ` (+${issues.outOfOrder.length - 2} more)` : ""}`,
247+
)
248+
}
249+
}
250+
}
251+
252+
// Only print issues
253+
if (localeHasIssues) {
254+
console.log(`\n 📋 Checking locale: ${locale}`)
255+
localeIssues.forEach((issue) => console.log(issue))
256+
hasOrderingIssues = true
257+
}
258+
}
259+
260+
return hasOrderingIssues
261+
}
262+
263+
/**
264+
* Main function to check locale key ordering
265+
*/
266+
function lintLocaleKeyOrdering() {
267+
try {
268+
console.log("🔍 Starting locale key ordering check...")
269+
270+
let anyAreaHasIssues = false
271+
272+
// Check each requested area
273+
for (const area of areasToCheck) {
274+
const hasIssues = checkAreaKeyOrdering(area)
275+
anyAreaHasIssues = anyAreaHasIssues || hasIssues
276+
}
277+
278+
// Summary
279+
if (!anyAreaHasIssues) {
280+
console.log("✅ All locale files have consistent key ordering!")
281+
process.exit(0)
282+
} else {
283+
console.log("\n❌ Key ordering inconsistencies detected!")
284+
console.log("\n💡 To fix ordering issues:")
285+
console.log("1. Review the files with ordering mismatches")
286+
console.log("2. Reorder keys to match the English locale files")
287+
console.log("3. Use MCP sort_i18n_keys tool to fix ordering")
288+
console.log("4. Run this linter again to verify fixes")
289+
process.exit(1)
290+
}
291+
} catch (error) {
292+
console.error("Error:", error.message)
293+
console.error(error.stack)
294+
process.exit(1)
295+
}
296+
}
297+
298+
// Run the main function
299+
lintLocaleKeyOrdering()

0 commit comments

Comments
 (0)