refactor: add i18n-validate tool, remove bugseti plan docs
Move i18n-validate tool from core/cli internal/tools/ into pkg/i18n/internal/validate/. Remove bugseti plan docs (now in core/bugseti repo). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
adaa4131f9
commit
ac2e83b88d
3 changed files with 525 additions and 1770 deletions
|
|
@ -1,150 +0,0 @@
|
||||||
# BugSETI HubService Design
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A thin HTTP client service in the BugSETI desktop app that coordinates with the agentic portal's `/api/bugseti/*` endpoints. Prevents duplicate work across the 11 community testers, aggregates stats for leaderboard, and registers client instances.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
| Decision | Choice | Rationale |
|
|
||||||
|----------|--------|-----------|
|
|
||||||
| Target | Direct to portal API | Endpoints built for this purpose |
|
|
||||||
| Auth | Auto-register via forge token | No manual key management for users |
|
|
||||||
| Sync strategy | Lazy/manual | User-triggered claims, manual stats sync |
|
|
||||||
| Offline mode | Offline-first | Queue failed writes, retry on reconnect |
|
|
||||||
| Approach | Thin HTTP client (net/http) | Matches existing patterns, no deps |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
**File:** `internal/bugseti/hub.go` + `hub_test.go`
|
|
||||||
|
|
||||||
```
|
|
||||||
HubService
|
|
||||||
├── HTTP client (net/http, 10s timeout)
|
|
||||||
├── Auth: auto-register via forge token → cached ak_ token
|
|
||||||
├── Config: HubURL, HubToken, ClientID in ConfigService
|
|
||||||
├── Offline-first: queue failed writes, drain on next success
|
|
||||||
└── Lazy sync: user-triggered, no background goroutines
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dependencies:** ConfigService only.
|
|
||||||
|
|
||||||
**Integration:**
|
|
||||||
- QueueService calls `hub.ClaimIssue()` when user picks an issue
|
|
||||||
- SubmitService calls `hub.UpdateStatus("completed")` after PR
|
|
||||||
- TrayService calls `hub.GetLeaderboard()` from UI
|
|
||||||
- main.go calls `hub.Register()` on startup
|
|
||||||
|
|
||||||
## Data Types
|
|
||||||
|
|
||||||
```go
|
|
||||||
type HubClient struct {
|
|
||||||
ClientID string // UUID, generated once, persisted in config
|
|
||||||
Name string // e.g. "Snider's MacBook"
|
|
||||||
Version string // bugseti.GetVersion()
|
|
||||||
OS string // runtime.GOOS
|
|
||||||
Arch string // runtime.GOARCH
|
|
||||||
}
|
|
||||||
|
|
||||||
type HubClaim struct {
|
|
||||||
IssueID string // "owner/repo#123"
|
|
||||||
Repo string
|
|
||||||
IssueNumber int
|
|
||||||
Title string
|
|
||||||
URL string
|
|
||||||
Status string // claimed|in_progress|completed|skipped
|
|
||||||
ClaimedAt time.Time
|
|
||||||
PRUrl string
|
|
||||||
PRNumber int
|
|
||||||
}
|
|
||||||
|
|
||||||
type LeaderboardEntry struct {
|
|
||||||
Rank int
|
|
||||||
ClientName string
|
|
||||||
IssuesCompleted int
|
|
||||||
PRsSubmitted int
|
|
||||||
PRsMerged int
|
|
||||||
CurrentStreak int
|
|
||||||
}
|
|
||||||
|
|
||||||
type GlobalStats struct {
|
|
||||||
TotalParticipants int
|
|
||||||
ActiveParticipants int
|
|
||||||
TotalIssuesCompleted int
|
|
||||||
TotalPRsMerged int
|
|
||||||
ActiveClaims int
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Mapping
|
|
||||||
|
|
||||||
| Method | HTTP | Endpoint | Trigger |
|
|
||||||
|--------|------|----------|---------|
|
|
||||||
| `Register()` | POST /register | App startup |
|
|
||||||
| `Heartbeat()` | POST /heartbeat | Manual / periodic if enabled |
|
|
||||||
| `ClaimIssue(issue)` | POST /issues/claim | User picks issue |
|
|
||||||
| `UpdateStatus(id, status)` | PATCH /issues/{id}/status | PR submitted, skip |
|
|
||||||
| `ReleaseClaim(id)` | DELETE /issues/{id}/claim | User abandons |
|
|
||||||
| `IsIssueClaimed(id)` | GET /issues/{id} | Before showing issue |
|
|
||||||
| `ListClaims(filters)` | GET /issues/claimed | UI active claims view |
|
|
||||||
| `SyncStats(stats)` | POST /stats/sync | Manual from UI |
|
|
||||||
| `GetLeaderboard(limit)` | GET /leaderboard | UI leaderboard view |
|
|
||||||
| `GetGlobalStats()` | GET /stats | UI stats dashboard |
|
|
||||||
|
|
||||||
## Auto-Register Flow
|
|
||||||
|
|
||||||
New endpoint on portal:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/bugseti/auth/forge
|
|
||||||
Body: { "forge_url": "https://forge.lthn.io", "forge_token": "..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
Portal validates token against Forgejo API (`/api/v1/user`), creates an AgentApiKey with `bugseti.read` + `bugseti.write` scopes, returns `{ "api_key": "ak_..." }`.
|
|
||||||
|
|
||||||
HubService caches the `ak_` token in config.json. On 401, clears cached token and re-registers.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
| Error | Behaviour |
|
|
||||||
|-------|-----------|
|
|
||||||
| Network unreachable | Log, queue write ops, return cached reads |
|
|
||||||
| 401 Unauthorised | Clear token, re-register via forge |
|
|
||||||
| 409 Conflict (claim) | Return "already claimed" — not an error |
|
|
||||||
| 404 (claim not found) | Return nil |
|
|
||||||
| 429 Rate limited | Back off, queue the op |
|
|
||||||
| 5xx Server error | Log, queue write ops |
|
|
||||||
|
|
||||||
**Pending operations queue:**
|
|
||||||
- Failed writes stored in `[]PendingOp`, persisted to `$DataDir/hub_pending.json`
|
|
||||||
- Drained on next successful user-triggered call (no background goroutine)
|
|
||||||
- Each op has: method, path, body, created_at
|
|
||||||
|
|
||||||
## Config Changes
|
|
||||||
|
|
||||||
New fields in `Config` struct:
|
|
||||||
|
|
||||||
```go
|
|
||||||
HubURL string `json:"hubUrl,omitempty"` // portal API base URL
|
|
||||||
HubToken string `json:"hubToken,omitempty"` // cached ak_ token
|
|
||||||
ClientID string `json:"clientId,omitempty"` // UUID, generated once
|
|
||||||
ClientName string `json:"clientName,omitempty"` // display name
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
| File | Action |
|
|
||||||
|------|--------|
|
|
||||||
| `internal/bugseti/hub.go` | New — HubService |
|
|
||||||
| `internal/bugseti/hub_test.go` | New — httptest-based tests |
|
|
||||||
| `internal/bugseti/config.go` | Edit — add Hub* + ClientID fields |
|
|
||||||
| `cmd/bugseti/main.go` | Edit — create + register HubService |
|
|
||||||
| `cmd/bugseti/tray.go` | Edit — leaderboard/stats menu items |
|
|
||||||
| Laravel: auth controller | New — `/api/bugseti/auth/forge` |
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `httptest.NewServer` mocks for all endpoints
|
|
||||||
- Test success, network error, 409 conflict, 401 re-auth flows
|
|
||||||
- Test pending ops queue: add when offline, drain on reconnect
|
|
||||||
- `_Good`, `_Bad`, `_Ugly` naming convention
|
|
||||||
File diff suppressed because it is too large
Load diff
525
pkg/i18n/internal/validate/main.go
Normal file
525
pkg/i18n/internal/validate/main.go
Normal file
|
|
@ -0,0 +1,525 @@
|
||||||
|
// 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()
|
||||||
|
switch funcName {
|
||||||
|
case "T", "i18n.T", "_", "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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "C", "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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "I", "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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue