Skip to content

Add unused i18n keys detection to pre-commit hook #4328

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks on windows; please mimic above (ridiculous) if/else workaround.

Copy link
Contributor Author

@christian-byrne christian-byrne Jul 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it, making PR now!

179 changes: 179 additions & 0 deletions scripts/check-unused-i18n-keys.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const newKeys = new Set<string>()

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<string>()
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)
})