feat(magefile): detect indirect api translation key references

The api translation scanner only looked at literal arguments to i18n.T /
i18n.TP, so keys passed via a variable (e.g. the time.since_* keys stored
in a struct slice in pkg/utils/humanize_duration.go and looked up via
chunk.key) were invisible and had to be hard-coded in an allowlist of
dynamic prefixes.

Mirror the frontend scanner: collect every dotted string literal in the
Go source as a "usage hint" and treat any literal that matches a known
translation key as used. This automatically picks up the time.since_*
case and removes the need for the apiDynamicKeyPrefixes allowlist.
This commit is contained in:
kolaente 2026-04-23 12:51:13 +02:00
parent 1d637a4ac6
commit d67c586c9b
1 changed files with 41 additions and 24 deletions

View File

@ -665,17 +665,6 @@ func (Check) GotSwag(ctx context.Context) error {
return nil return nil
} }
// apiDynamicKeyPrefixes lists prefixes that are used dynamically (with a
// non-literal key argument) in the api. Any translation key starting with
// one of these prefixes is considered "used" so the dead-key detection doesn't
// false-positive on them. Add a prefix here only after verifying the dynamic
// call site actually produces the expected keys.
var apiDynamicKeyPrefixes = []string{
// pkg/utils/humanize_duration.go uses i18n.TP(lang, chunk.key, ...) where
// chunk.key comes from a struct containing the time.since_* keys.
"time.since_",
}
// Translations checks that all translation keys used in the code exist in // Translations checks that all translation keys used in the code exist in
// their respective English translation files, and conversely that no unused // their respective English translation files, and conversely that no unused
// keys exist in those files. Both the api (Go) and the frontend (Vue/TS) are // keys exist in those files. Both the api (Go) and the frontend (Vue/TS) are
@ -707,14 +696,25 @@ func checkAPITranslations() error {
fmt.Printf("Loaded %d translation keys from %s\n", len(translations), translationFile) fmt.Printf("Loaded %d translation keys from %s\n", len(translations), translationFile)
keys, err := walkAPIForTranslationKeys(".") keys, literals, err := walkAPIForTranslationKeys(".")
if err != nil { if err != nil {
return fmt.Errorf("error walking api codebase: %w", err) return fmt.Errorf("error walking api codebase: %w", err)
} }
fmt.Printf("Found %d translation key references in the api codebase\n", len(keys)) fmt.Printf("Found %d translation key references in the api codebase\n", len(keys))
return reportTranslationResults("api", translations, keys, apiDynamicKeyPrefixes) // Some api keys are referenced indirectly e.g. the time.since_* keys are
// stored as string literals in a struct slice in pkg/utils/humanize_duration.go
// and looked up via i18n.TP(lang, chunk.key, ...). Any literal that matches a
// known translation key is treated as a usage hint, so those keys aren't
// flagged as dead.
for lit := range literals {
if translations[lit] {
keys = append(keys, TranslationKey{Key: lit, FilePath: "<api literal hint>", Line: 0})
}
}
return reportTranslationResults("api", translations, keys, nil)
} }
func checkFrontendTranslations() error { func checkFrontendTranslations() error {
@ -865,11 +865,12 @@ func flattenTranslations(prefix string, src map[string]any, dest map[string]bool
} }
// walkAPIForTranslationKeys walks the Go api code and extracts translation // walkAPIForTranslationKeys walks the Go api code and extracts translation
// keys referenced via string-literal arguments to i18n.T / i18n.TP. Dynamic // keys referenced via string-literal arguments to i18n.T / i18n.TP, plus a
// (non-literal) references are not detected here add them to // set of dotted string literals as "usage hints" for indirect references
// apiDynamicKeyPrefixes instead. // (e.g. keys stored in a struct slice and passed to i18n.TP via a variable).
func walkAPIForTranslationKeys(rootDir string) ([]TranslationKey, error) { func walkAPIForTranslationKeys(rootDir string) ([]TranslationKey, map[string]bool, error) {
var allKeys []TranslationKey var allKeys []TranslationKey
allLiterals := make(map[string]bool)
pkgDir := filepath.Join(rootDir, "pkg") pkgDir := filepath.Join(rootDir, "pkg")
@ -883,30 +884,40 @@ func walkAPIForTranslationKeys(rootDir string) ([]TranslationKey, error) {
} }
if !info.IsDir() && strings.HasSuffix(path, ".go") { if !info.IsDir() && strings.HasSuffix(path, ".go") {
keys, err := extractAPITranslationKeysFromFile(path) keys, literals, err := extractAPITranslationKeysFromFile(path)
if err != nil { if err != nil {
fmt.Printf("Warning: %v\n", err) fmt.Printf("Warning: %v\n", err)
return nil return nil
} }
allKeys = append(allKeys, keys...) allKeys = append(allKeys, keys...)
for l := range literals {
allLiterals[l] = true
}
} }
return nil return nil
}) })
return allKeys, err return allKeys, allLiterals, err
} }
// apiStringLiteralRe matches double-quoted strings in Go source that look like
// dotted translation keys (e.g. "time.since_years"). Used to surface keys that
// are referenced indirectly for example, stored in a struct field and later
// passed to i18n.TP via a variable.
var apiStringLiteralRe = regexp.MustCompile(`"([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)+)"`)
// extractAPITranslationKeysFromFile extracts all i18n.T/i18n.TP calls with // extractAPITranslationKeysFromFile extracts all i18n.T/i18n.TP calls with
// a string-literal key. Non-literal (dynamic) keys are not flagged; any // a string-literal key, plus all dotted string literals in the file (returned
// dynamic-key reference must be allowlisted via apiDynamicKeyPrefixes. // as the second value) which are used as usage hints for indirect references.
func extractAPITranslationKeysFromFile(filePath string) ([]TranslationKey, error) { func extractAPITranslationKeysFromFile(filePath string) ([]TranslationKey, map[string]bool, error) {
content, err := os.ReadFile(filePath) content, err := os.ReadFile(filePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("error reading file %s: %w", filePath, err) return nil, nil, fmt.Errorf("error reading file %s: %w", filePath, err)
} }
var keys []TranslationKey var keys []TranslationKey
literals := make(map[string]bool)
// Match i18n.T(ctx, "key") and i18n.TP(ctx, "key", count) // Match i18n.T(ctx, "key") and i18n.TP(ctx, "key", count)
re := regexp.MustCompile(`i18n\.(T|TP)\([^,]+,\s*"([^"]+)"`) re := regexp.MustCompile(`i18n\.(T|TP)\([^,]+,\s*"([^"]+)"`)
@ -928,7 +939,13 @@ func extractAPITranslationKeysFromFile(filePath string) ([]TranslationKey, error
} }
} }
return keys, nil for _, match := range apiStringLiteralRe.FindAllSubmatchIndex(content, -1) {
if len(match) >= 4 {
literals[string(content[match[2]:match[3]])] = true
}
}
return keys, literals, nil
} }
// frontendI18nCallReSingle matches translation calls using single-quoted keys: // frontendI18nCallReSingle matches translation calls using single-quoted keys: