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:
Claude 2026-02-16 14:56:00 +00:00
parent dfd370cff6
commit 3b77adaaa3
No known key found for this signature in database
GPG key ID: AF404715446AEB41
3 changed files with 525 additions and 1770 deletions

View file

@ -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

View 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")
}
}