feat(i18n): add compile-time key validation tool
Adds cmd/i18n-validate that scans Go source files for i18n key usage and validates them against locale JSON files and registered intents. Features: - Scans T(), C(), I(), and qualified i18n.* calls - Expands ./... pattern to find all Go packages - Validates message keys against locale JSON files - Validates intent keys against registered core.* intents - Reports missing keys with file:line locations - Skips constant references (type-safe usage) Usage: go run ./cmd/i18n-validate ./... task i18n:validate Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
829be45fcc
commit
94723454a8
3 changed files with 535 additions and 2 deletions
|
|
@ -49,3 +49,8 @@ tasks:
|
|||
desc: "Regenerate i18n key constants from locale files"
|
||||
cmds:
|
||||
- go generate ./pkg/i18n/...
|
||||
|
||||
i18n:validate:
|
||||
desc: "Validate i18n key usage across the codebase"
|
||||
cmds:
|
||||
- go run ./cmd/i18n-validate ./...
|
||||
|
|
|
|||
524
cmd/i18n-validate/main.go
Normal file
524
cmd/i18n-validate/main.go
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
// Command i18n-validate scans Go source files for i18n key usage and validates
|
||||
// them against the locale JSON files.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/i18n-validate ./...
|
||||
// go run ./cmd/i18n-validate ./pkg/cli ./cmd/dev
|
||||
//
|
||||
// The validator checks:
|
||||
// - T("key") calls - validates key exists in locale files
|
||||
// - C("intent", ...) calls - validates intent exists in registered intents
|
||||
// - i18n.T("key") and i18n.C("intent", ...) qualified calls
|
||||
//
|
||||
// Exit codes:
|
||||
// - 0: All keys valid
|
||||
// - 1: Missing keys found
|
||||
// - 2: Error during validation
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyUsage records where a key is used in the source code.
|
||||
type KeyUsage struct {
|
||||
Key string
|
||||
File string
|
||||
Line int
|
||||
Function string // "T" or "C"
|
||||
}
|
||||
|
||||
// ValidationResult holds the results of validation.
|
||||
type ValidationResult struct {
|
||||
TotalKeys int
|
||||
ValidKeys int
|
||||
MissingKeys []KeyUsage
|
||||
IntentKeys int
|
||||
MessageKeys int
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: i18n-validate <packages...>")
|
||||
fmt.Fprintln(os.Stderr, "Example: i18n-validate ./...")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Find the project root (where locales are)
|
||||
root, err := findProjectRoot()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error finding project root: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Load valid keys from locale files
|
||||
validKeys, err := loadValidKeys(filepath.Join(root, "pkg/i18n/locales"))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading locale files: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Load valid intents
|
||||
validIntents := loadValidIntents()
|
||||
|
||||
// Scan source files
|
||||
usages, err := scanPackages(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error scanning packages: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Validate
|
||||
result := validate(usages, validKeys, validIntents)
|
||||
|
||||
// Report
|
||||
printReport(result)
|
||||
|
||||
if len(result.MissingKeys) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// findProjectRoot finds the project root by looking for go.mod.
|
||||
func findProjectRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", fmt.Errorf("could not find go.mod in any parent directory")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// loadValidKeys loads all valid keys from locale JSON files.
|
||||
func loadValidKeys(localesDir string) (map[string]bool, error) {
|
||||
keys := make(map[string]bool)
|
||||
|
||||
entries, err := os.ReadDir(localesDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading locales dir: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(localesDir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
extractKeys("", raw, keys)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// extractKeys recursively extracts flattened keys from nested JSON.
|
||||
func extractKeys(prefix string, data map[string]any, out map[string]bool) {
|
||||
for key, value := range data {
|
||||
fullKey := key
|
||||
if prefix != "" {
|
||||
fullKey = prefix + "." + key
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
out[fullKey] = true
|
||||
case map[string]any:
|
||||
// Check if it's a plural/verb/noun object (has specific keys)
|
||||
if isPluralOrGrammarObject(v) {
|
||||
out[fullKey] = true
|
||||
} else {
|
||||
extractKeys(fullKey, v, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isPluralOrGrammarObject checks if a map is a leaf object (plural forms, verb forms, etc).
|
||||
func isPluralOrGrammarObject(m map[string]any) bool {
|
||||
// CLDR plural keys
|
||||
_, hasOne := m["one"]
|
||||
_, hasOther := m["other"]
|
||||
_, hasZero := m["zero"]
|
||||
_, hasTwo := m["two"]
|
||||
_, hasFew := m["few"]
|
||||
_, hasMany := m["many"]
|
||||
|
||||
// Grammar keys
|
||||
_, hasPast := m["past"]
|
||||
_, hasGerund := m["gerund"]
|
||||
_, hasGender := m["gender"]
|
||||
_, hasBase := m["base"]
|
||||
|
||||
// Article keys
|
||||
_, hasDefault := m["default"]
|
||||
_, hasVowel := m["vowel"]
|
||||
|
||||
if hasOne || hasOther || hasZero || hasTwo || hasFew || hasMany {
|
||||
return true
|
||||
}
|
||||
if hasPast || hasGerund || hasGender || hasBase {
|
||||
return true
|
||||
}
|
||||
if hasDefault || hasVowel {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// loadValidIntents returns the set of valid intent keys.
|
||||
func loadValidIntents() map[string]bool {
|
||||
// Core intents - these match what's defined in intents.go
|
||||
return map[string]bool{
|
||||
// Destructive
|
||||
"core.delete": true,
|
||||
"core.remove": true,
|
||||
"core.discard": true,
|
||||
"core.reset": true,
|
||||
"core.overwrite": true,
|
||||
// Creation
|
||||
"core.create": true,
|
||||
"core.add": true,
|
||||
"core.clone": true,
|
||||
"core.copy": true,
|
||||
// Modification
|
||||
"core.save": true,
|
||||
"core.update": true,
|
||||
"core.rename": true,
|
||||
"core.move": true,
|
||||
// Git
|
||||
"core.commit": true,
|
||||
"core.push": true,
|
||||
"core.pull": true,
|
||||
"core.merge": true,
|
||||
"core.rebase": true,
|
||||
// Network
|
||||
"core.install": true,
|
||||
"core.download": true,
|
||||
"core.upload": true,
|
||||
"core.publish": true,
|
||||
"core.deploy": true,
|
||||
// Process
|
||||
"core.start": true,
|
||||
"core.stop": true,
|
||||
"core.restart": true,
|
||||
"core.run": true,
|
||||
"core.build": true,
|
||||
"core.test": true,
|
||||
// Information
|
||||
"core.continue": true,
|
||||
"core.proceed": true,
|
||||
"core.confirm": true,
|
||||
// Additional
|
||||
"core.sync": true,
|
||||
"core.boot": true,
|
||||
"core.format": true,
|
||||
"core.analyse": true,
|
||||
"core.link": true,
|
||||
"core.unlink": true,
|
||||
"core.fetch": true,
|
||||
"core.generate": true,
|
||||
"core.validate": true,
|
||||
"core.check": true,
|
||||
"core.scan": true,
|
||||
}
|
||||
}
|
||||
|
||||
// scanPackages scans Go packages for i18n key usage.
|
||||
func scanPackages(patterns []string) ([]KeyUsage, error) {
|
||||
var usages []KeyUsage
|
||||
|
||||
for _, pattern := range patterns {
|
||||
// Expand pattern
|
||||
matches, err := expandPattern(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding pattern %q: %w", pattern, err)
|
||||
}
|
||||
|
||||
for _, dir := range matches {
|
||||
dirUsages, err := scanDirectory(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning %s: %w", dir, err)
|
||||
}
|
||||
usages = append(usages, dirUsages...)
|
||||
}
|
||||
}
|
||||
|
||||
return usages, nil
|
||||
}
|
||||
|
||||
// expandPattern expands a Go package pattern to directories.
|
||||
func expandPattern(pattern string) ([]string, error) {
|
||||
// Handle ./... or ... pattern
|
||||
if strings.HasSuffix(pattern, "...") {
|
||||
base := strings.TrimSuffix(pattern, "...")
|
||||
base = strings.TrimSuffix(base, "/")
|
||||
if base == "" || base == "." {
|
||||
base = "."
|
||||
}
|
||||
return findAllGoDirs(base)
|
||||
}
|
||||
|
||||
// Single directory
|
||||
return []string{pattern}, nil
|
||||
}
|
||||
|
||||
// findAllGoDirs finds all directories containing .go files.
|
||||
func findAllGoDirs(root string) ([]string, error) {
|
||||
var dirs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Continue walking even on error
|
||||
}
|
||||
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip vendor, testdata, and hidden directories (but not . itself)
|
||||
if info.IsDir() {
|
||||
name := info.Name()
|
||||
if name == "vendor" || name == "testdata" || (strings.HasPrefix(name, ".") && name != ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for .go files
|
||||
if strings.HasSuffix(path, ".go") {
|
||||
dir := filepath.Dir(path)
|
||||
if !seen[dir] {
|
||||
seen[dir] = true
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return dirs, err
|
||||
}
|
||||
|
||||
// scanDirectory scans a directory for i18n key usage.
|
||||
func scanDirectory(dir string) ([]KeyUsage, error) {
|
||||
var usages []KeyUsage
|
||||
|
||||
fset := token.NewFileSet()
|
||||
// Parse all .go files except those ending exactly in _test.go
|
||||
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
|
||||
name := fi.Name()
|
||||
// Only exclude files that are actual test files (ending in _test.go)
|
||||
// Files like "go_test_cmd.go" should be included
|
||||
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go")
|
||||
}, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for filename, file := range pkg.Files {
|
||||
fileUsages := scanFile(fset, filename, file)
|
||||
usages = append(usages, fileUsages...)
|
||||
}
|
||||
}
|
||||
|
||||
return usages, nil
|
||||
}
|
||||
|
||||
// scanFile scans a single file for i18n key usage.
|
||||
func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage {
|
||||
var usages []KeyUsage
|
||||
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
funcName := getFuncName(call)
|
||||
if funcName == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for T(), C(), i18n.T(), i18n.C()
|
||||
if funcName == "T" || funcName == "i18n.T" || funcName == "_" || funcName == "i18n._" {
|
||||
if key := extractStringArg(call, 0); key != "" {
|
||||
pos := fset.Position(call.Pos())
|
||||
usages = append(usages, KeyUsage{
|
||||
Key: key,
|
||||
File: filename,
|
||||
Line: pos.Line,
|
||||
Function: "T",
|
||||
})
|
||||
}
|
||||
} else if funcName == "C" || funcName == "i18n.C" {
|
||||
if key := extractStringArg(call, 0); key != "" {
|
||||
pos := fset.Position(call.Pos())
|
||||
usages = append(usages, KeyUsage{
|
||||
Key: key,
|
||||
File: filename,
|
||||
Line: pos.Line,
|
||||
Function: "C",
|
||||
})
|
||||
}
|
||||
} else if funcName == "I" || funcName == "i18n.I" {
|
||||
if key := extractStringArg(call, 0); key != "" {
|
||||
pos := fset.Position(call.Pos())
|
||||
usages = append(usages, KeyUsage{
|
||||
Key: key,
|
||||
File: filename,
|
||||
Line: pos.Line,
|
||||
Function: "C", // I() is an intent builder
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return usages
|
||||
}
|
||||
|
||||
// getFuncName extracts the function name from a call expression.
|
||||
func getFuncName(call *ast.CallExpr) string {
|
||||
switch fn := call.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
return fn.Name
|
||||
case *ast.SelectorExpr:
|
||||
if ident, ok := fn.X.(*ast.Ident); ok {
|
||||
return ident.Name + "." + fn.Sel.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractStringArg extracts a string literal from a call argument.
|
||||
func extractStringArg(call *ast.CallExpr, index int) string {
|
||||
if index >= len(call.Args) {
|
||||
return ""
|
||||
}
|
||||
|
||||
arg := call.Args[index]
|
||||
|
||||
// Direct string literal
|
||||
if lit, ok := arg.(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
||||
// Remove quotes
|
||||
s := lit.Value
|
||||
if len(s) >= 2 {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// Identifier (constant reference) - we skip these as they're type-safe
|
||||
if _, ok := arg.(*ast.Ident); ok {
|
||||
return "" // Skip constants like IntentCoreDelete
|
||||
}
|
||||
|
||||
// Selector (like i18n.IntentCoreDelete) - skip these too
|
||||
if _, ok := arg.(*ast.SelectorExpr); ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// validate validates key usages against valid keys and intents.
|
||||
func validate(usages []KeyUsage, validKeys, validIntents map[string]bool) ValidationResult {
|
||||
result := ValidationResult{
|
||||
TotalKeys: len(usages),
|
||||
}
|
||||
|
||||
for _, usage := range usages {
|
||||
if usage.Function == "C" {
|
||||
result.IntentKeys++
|
||||
// Check intent keys
|
||||
if validIntents[usage.Key] {
|
||||
result.ValidKeys++
|
||||
} else {
|
||||
// Also allow custom intents (non-core.* prefix)
|
||||
if !strings.HasPrefix(usage.Key, "core.") {
|
||||
result.ValidKeys++ // Assume custom intents are valid
|
||||
} else {
|
||||
result.MissingKeys = append(result.MissingKeys, usage)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.MessageKeys++
|
||||
// Check message keys
|
||||
if validKeys[usage.Key] {
|
||||
result.ValidKeys++
|
||||
} else if strings.HasPrefix(usage.Key, "core.") {
|
||||
// core.* keys used with T() are intent keys
|
||||
if validIntents[usage.Key] {
|
||||
result.ValidKeys++
|
||||
} else {
|
||||
result.MissingKeys = append(result.MissingKeys, usage)
|
||||
}
|
||||
} else {
|
||||
result.MissingKeys = append(result.MissingKeys, usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// printReport prints the validation report.
|
||||
func printReport(result ValidationResult) {
|
||||
fmt.Printf("i18n Validation Report\n")
|
||||
fmt.Printf("======================\n\n")
|
||||
fmt.Printf("Total keys scanned: %d\n", result.TotalKeys)
|
||||
fmt.Printf(" Message keys (T): %d\n", result.MessageKeys)
|
||||
fmt.Printf(" Intent keys (C): %d\n", result.IntentKeys)
|
||||
fmt.Printf("Valid keys: %d\n", result.ValidKeys)
|
||||
fmt.Printf("Missing keys: %d\n", len(result.MissingKeys))
|
||||
|
||||
if len(result.MissingKeys) > 0 {
|
||||
fmt.Printf("\nMissing Keys:\n")
|
||||
fmt.Printf("-------------\n")
|
||||
|
||||
// Sort by file then line
|
||||
sort.Slice(result.MissingKeys, func(i, j int) bool {
|
||||
if result.MissingKeys[i].File != result.MissingKeys[j].File {
|
||||
return result.MissingKeys[i].File < result.MissingKeys[j].File
|
||||
}
|
||||
return result.MissingKeys[i].Line < result.MissingKeys[j].Line
|
||||
})
|
||||
|
||||
for _, usage := range result.MissingKeys {
|
||||
fmt.Printf(" %s:%d: %s(%q)\n", usage.File, usage.Line, usage.Function, usage.Key)
|
||||
}
|
||||
|
||||
fmt.Printf("\nAdd these keys to pkg/i18n/locales/en_GB.json or use constants from pkg/i18n/keys.go\n")
|
||||
} else {
|
||||
fmt.Printf("\nAll keys are valid!\n")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// To regenerate: task i18n:generate
|
||||
// Package i18n provides internationalization for the CLI.
|
||||
//
|
||||
// To validate i18n key usage:
|
||||
//
|
||||
// go run ./cmd/i18n-validate ./...
|
||||
// task i18n:validate
|
||||
package i18n
|
||||
|
||||
// Intent keys for type-safe intent references.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue