From 7befec5b177c0451fb4c5e6b473eea4d13ce3362 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Thu, 3 Jul 2025 10:53:56 -0700 Subject: [PATCH] Add unused i18n keys detection to pre-commit hook (#4328) --- .husky/pre-commit | 3 + scripts/check-unused-i18n-keys.ts | 179 ++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100755 scripts/check-unused-i18n-keys.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index a19f49ff7..e591a50d9 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,3 +3,6 @@ if [[ "$OS" == "Windows_NT" ]]; then else npx lint-staged fi + +# Check for unused i18n keys in staged files +npx tsx scripts/check-unused-i18n-keys.ts diff --git a/scripts/check-unused-i18n-keys.ts b/scripts/check-unused-i18n-keys.ts new file mode 100755 index 000000000..2be36a483 --- /dev/null +++ b/scripts/check-unused-i18n-keys.ts @@ -0,0 +1,179 @@ +#!/usr/bin/env tsx +import { execSync } from 'child_process' +import * as fs from 'fs' +import { globSync } from 'glob' + +interface LocaleData { + [key: string]: any +} + +// Configuration +const SOURCE_PATTERNS = ['src/**/*.{js,ts,vue}', '!src/locales/**/*'] +const IGNORE_PATTERNS = [ + // Keys that might be dynamically constructed + /^commands\./, // Command definitions are loaded dynamically + /^settings\..*\.options\./, // Setting options are rendered dynamically + /^nodeDefs\./, // Node definitions are loaded from backend + /^templateWorkflows\./, // Template workflows are loaded dynamically + /^dataTypes\./, // Data types might be referenced dynamically + /^contextMenu\./, // Context menu items might be dynamic + /^color\./ // Color names might be used dynamically +] + +// Get list of staged locale files +function getStagedLocaleFiles(): string[] { + try { + const output = execSync('git diff --cached --name-only --diff-filter=AM', { + encoding: 'utf-8' + }) + return output + .split('\n') + .filter( + (file) => file.startsWith('src/locales/') && file.endsWith('.json') + ) + } catch { + return [] + } +} + +// Extract all keys from a nested object +function extractKeys(obj: any, prefix = ''): string[] { + const keys: string[] = [] + + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + keys.push(...extractKeys(value, fullKey)) + } else { + keys.push(fullKey) + } + } + + return keys +} + +// Get new keys added in staged files +function getNewKeysFromStagedFiles(stagedFiles: string[]): Set { + const newKeys = new Set() + + for (const file of stagedFiles) { + try { + // Get the staged content + const stagedContent = execSync(`git show :${file}`, { encoding: 'utf-8' }) + const stagedData: LocaleData = JSON.parse(stagedContent) + const stagedKeys = new Set(extractKeys(stagedData)) + + // Get the current HEAD content (if file exists) + let headKeys = new Set() + try { + const headContent = execSync(`git show HEAD:${file}`, { + encoding: 'utf-8' + }) + const headData: LocaleData = JSON.parse(headContent) + headKeys = new Set(extractKeys(headData)) + } catch { + // File is new, all keys are new + } + + // Find keys that are in staged but not in HEAD + stagedKeys.forEach((key) => { + if (!headKeys.has(key)) { + newKeys.add(key) + } + }) + } catch (error) { + console.error(`Error processing ${file}:`, error) + } + } + + return newKeys +} + +// Check if a key should be ignored +function shouldIgnoreKey(key: string): boolean { + return IGNORE_PATTERNS.some((pattern) => pattern.test(key)) +} + +// Search for key usage in source files +function isKeyUsed(key: string, sourceFiles: string[]): boolean { + // Common patterns for i18n key usage + const patterns = [ + // Direct usage: $t('key'), t('key'), i18n.t('key') + new RegExp(`[t$]\\s*\\(\\s*['"\`]${key}['"\`]`, 'g'), + // With namespace: $t('g.key'), t('namespace.key') + new RegExp( + `[t$]\\s*\\(\\s*['"\`][^'"]+\\.${key.split('.').pop()}['"\`]`, + 'g' + ), + // Dynamic keys might reference parts of the key + new RegExp(`['"\`]${key}['"\`]`, 'g') + ] + + for (const file of sourceFiles) { + const content = fs.readFileSync(file, 'utf-8') + + for (const pattern of patterns) { + if (pattern.test(content)) { + return true + } + } + } + + return false +} + +// Main function +async function checkNewUnusedKeys() { + const stagedLocaleFiles = getStagedLocaleFiles() + + if (stagedLocaleFiles.length === 0) { + // No locale files staged, nothing to check + process.exit(0) + } + + // Get all new keys from staged files + const newKeys = getNewKeysFromStagedFiles(stagedLocaleFiles) + + if (newKeys.size === 0) { + // Silent success - no output needed + process.exit(0) + } + + // Get all source files + const sourceFiles = globSync(SOURCE_PATTERNS) + + // Check each new key + const unusedNewKeys: string[] = [] + + newKeys.forEach((key) => { + if (!shouldIgnoreKey(key) && !isKeyUsed(key, sourceFiles)) { + unusedNewKeys.push(key) + } + }) + + // Report results + if (unusedNewKeys.length > 0) { + console.log('\n❌ Found unused NEW i18n keys:\n') + + for (const key of unusedNewKeys.sort()) { + console.log(` - ${key}`) + } + + console.log(`\n✨ Total unused new keys: ${unusedNewKeys.length}`) + console.log( + '\nThese keys were added but are not used anywhere in the codebase.' + ) + console.log('Please either use them or remove them before committing.') + + process.exit(1) + } else { + // Silent success - no output needed + } +} + +// Run the check +checkNewUnusedKeys().catch((err) => { + console.error('Error checking unused keys:', err) + process.exit(1) +})