From 94723454a8b82b6db4f794f9626c950474de83fd Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 13:18:58 +0000 Subject: [PATCH] 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 --- Taskfile.yml | 5 + cmd/i18n-validate/main.go | 524 ++++++++++++++++++++++++++++++++++++++ pkg/i18n/keys.go | 8 +- 3 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 cmd/i18n-validate/main.go diff --git a/Taskfile.yml b/Taskfile.yml index dedc29d0..6c557edc 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 ./... diff --git a/cmd/i18n-validate/main.go b/cmd/i18n-validate/main.go new file mode 100644 index 00000000..817759ef --- /dev/null +++ b/cmd/i18n-validate/main.go @@ -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 ") + 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") + } +} diff --git a/pkg/i18n/keys.go b/pkg/i18n/keys.go index 08b2bbdb..2bc8ec13 100644 --- a/pkg/i18n/keys.go +++ b/pkg/i18n/keys.go @@ -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.