Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .internationalizer.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ style_guides_dir: style-guides # Markdown style guides per locale
glossary_dir: glossary # JSON glossary files per locale
tm_path: .internationalizer/tm.jsonl # translation memory cache
manifest_path: .internationalizer.lock # versioned, reviewable translation state

validation:
plural_style: i18next-v4 # optional; generates and validates target-locale plural forms
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,44 @@ baseline.

### `validate`

Check all locale files for missing keys, extra keys, and interpolation mismatches.
Check all locale files against their source bundles. Default validation checks
structural coverage (the percentage of required target keys present), reports extra keys
as warnings, and fails for missing keys or interpolation mismatches.

```bash
internationalizer validate # human-readable output
internationalizer validate --json # machine-readable JSON
internationalizer validate -q # exit code only
internationalizer validate --strict # enforce translation quality rules
internationalizer validate --require-state # require current manifest provenance
```

`--strict` also reports translated coverage. A linguistic value identical to
its source is untranslated unless the glossary explicitly contains an exact
same-source, same-target entry for the complete value; `ignore_case` is honored,
but a glossary term embedded in a longer value is not an exemption. Strict mode
fails on extra keys, source-identical values, changed interpolation/HTML/code/
Markdown-link structure, glossary violations, and configured plural forms.

`--require-state` verifies each target against `.internationalizer.lock`. It
fails when a key is untracked, or when its recorded source, translation policy,
or target hash is stale. It can be combined with `--strict`.

Human and JSON reports use stable finding codes:

| Code | Meaning |
| --- | --- |
| `missing_key` / `extra_key` | Source and target key sets differ |
| `blank_translation` | A non-empty source has an empty strict-mode target |
| `source_identical` | A strict-mode linguistic value remains untranslated |
| `protected_structure_mismatch` | Interpolation, HTML, code, or link structure changed |
| `glossary_violation` | No approved target term or variant was found |
| `plural_form_missing` | A configured locale plural form is absent |
| `untracked` | No manifest record exists for the target |
| `source_stale` | Source content changed after the recorded translation |
| `policy_stale` | The generated prompt or model settings changed |
| `target_modified` | Target content differs from the manifest record |

### `detect`

Auto-detect the i18n framework and suggest a configuration.
Expand Down Expand Up @@ -263,8 +293,18 @@ tm_path: .internationalizer/tm.jsonl
# Versioned source, policy, target, and provenance state
# (default: .internationalizer.lock; commit this file)
manifest_path: .internationalizer.lock

# Optional translation and strict-validation rules
validation:
plural_style: i18next-v4 # generate and validate target-locale plural forms
```

With `i18next-v4`, recognized source plural families are expanded during
translation to the target locale's CLDR categories. A target-only category uses
the source family's `_other` value as its translation template. Strict
validation requires those target categories; source-only categories are
optional for target locales that do not use them.

## Style Guides

Style guides are Markdown files that get injected into the LLM translation prompt. They control tone, formality, typography, and other language-specific conventions.
Expand Down Expand Up @@ -296,13 +336,20 @@ Glossary files are JSON arrays stored in `{glossary_dir}/{locale}.json`:
{
"source": "Dashboard",
"target": "Tableau de bord",
"variants": ["Panneau de contrôle"],
"enforcement": "error",
"ignore_case": false,
"whole_word": true
}
]
```

Terms are injected into the LLM prompt as a terminology table, ensuring consistent translation of key terms across your application.
`variants` lists other approved target forms. `enforcement` may be `error`,
`warning`, or omitted for the default error behavior. Terms are injected into
the LLM prompt as a terminology table, ensuring consistent translation across
your application. An exact entry such as `{"source":"API","target":"API"}`
also exempts that complete source-identical value from strict untranslated-value
findings; it does not exempt a longer value merely containing `API`.

## Translation Memory

Expand Down
15 changes: 13 additions & 2 deletions cmd/internationalizer/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,24 @@ func newValidateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "validate",
Short: "Validate locale files against the source locale",
Long: "Check all target locales for missing keys, extra keys, and interpolation mismatches.",
Long: `Check target locale structure and interpolation against the source locale.

Use --strict to require translated values and enforce extra-key, protected
structure, glossary, and configured plural rules. Use --require-state to verify
that source, policy, and target content still match the translation manifest.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfgPath, _ := cmd.Flags().GetString("config")
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}

reports, err := validate.Validate(cfg)
strict, _ := cmd.Flags().GetBool("strict")
requireState, _ := cmd.Flags().GetBool("require-state")
reports, err := validate.ValidateWithOptions(cfg, validate.Options{
Strict: strict,
RequireState: requireState,
})
if err != nil {
return err
}
Expand Down Expand Up @@ -54,6 +63,8 @@ func newValidateCmd() *cobra.Command {
cmd.Flags().StringP("config", "c", "", "path to config file (default: .internationalizer.yml)")
cmd.Flags().Bool("json", false, "output report as JSON")
cmd.Flags().BoolP("quiet", "q", false, "exit code only, no output")
cmd.Flags().Bool("strict", false, "fail on untranslated values and strict policy findings")
cmd.Flags().Bool("require-state", false, "fail when translation manifest state is missing or stale")

return cmd
}
87 changes: 87 additions & 0 deletions cmd/internationalizer/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ func TestValidateJSONReturnsFailureAfterWritingReport(t *testing.T) {
}

cmd := newValidateCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"--config", configPath, "--json"})
Expand All @@ -33,3 +35,88 @@ func TestValidateJSONReturnsFailureAfterWritingReport(t *testing.T) {
t.Fatalf("JSON report was not written before failure: %q", stdout.String())
}
}

func TestValidateDefaultAllowsExtraKey(t *testing.T) {
configPath := writeValidateProject(t, `{"a":"A"}`, `{"a":"Un A","extra":"Supplémentaire"}`, "")

cmd := newValidateCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"--config", configPath})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute returned error for default extra key: %v", err)
}
}

func TestValidateStrictRejectsExtraKey(t *testing.T) {
configPath := writeValidateProject(t, `{"a":"A"}`, `{"a":"Un A","extra":"Supplémentaire"}`, "")

cmd := newValidateCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"--config", configPath, "--strict"})
if err := cmd.Execute(); !errors.Is(err, errValidationFailed) {
t.Fatalf("Execute error = %v, want %v", err, errValidationFailed)
}
}

func TestValidateRequireStateRejectsMissingManifest(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, "missing.lock")
configPath := writeValidateProject(t, `{"a":"A"}`, `{"a":"Un A"}`, "manifest_path: "+manifestPath+"\n")

cmd := newValidateCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"--config", configPath, "--require-state"})
if err := cmd.Execute(); !errors.Is(err, errValidationFailed) {
t.Fatalf("Execute error = %v, want %v", err, errValidationFailed)
}
if !strings.Contains(stdout.String(), "untracked") {
t.Fatalf("require-state report lacks untracked finding: %q", stdout.String())
}
}

func TestValidateStrictQuietEmitsNothing(t *testing.T) {
configPath := writeValidateProject(t, `{"a":"A"}`, `{"a":"Un A","extra":"Supplémentaire"}`, "")

cmd := newValidateCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--config", configPath, "--strict", "--quiet"})
if err := cmd.Execute(); !errors.Is(err, errValidationFailed) {
t.Fatalf("Execute error = %v, want %v", err, errValidationFailed)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("quiet output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}

func writeValidateProject(t *testing.T, source, target, extraConfig string) string {
t.Helper()
dir := t.TempDir()
sourcePath := filepath.Join(dir, "en.json")
targetPath := filepath.Join(dir, "fr.json")
if err := os.WriteFile(sourcePath, []byte(source), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(targetPath, []byte(target), 0o644); err != nil {
t.Fatal(err)
}
configPath := filepath.Join(dir, ".internationalizer.yml")
configData := "target_locales: [fr]\nsource_path: " + sourcePath + "\n" + extraConfig
if err := os.WriteFile(configPath, []byte(configData), 0o644); err != nil {
t.Fatal(err)
}
return configPath
}
33 changes: 21 additions & 12 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,24 @@ const (
)

type Config struct {
SourceLocale string `yaml:"source_locale"`
TargetLocales []string `yaml:"target_locales"`
SourcePath string `yaml:"source_path"`
Bundles []Bundle `yaml:"bundles"`
LLM LLM `yaml:"llm"`
BatchSize int `yaml:"batch_size"`
Concurrency int `yaml:"concurrency"`
StyleGuidesDir string `yaml:"style_guides_dir"`
GlossaryDir string `yaml:"glossary_dir"`
TMPath string `yaml:"tm_path"`
ManifestPath string `yaml:"manifest_path"`
Formats []string `yaml:"formats"`
SourceLocale string `yaml:"source_locale"`
TargetLocales []string `yaml:"target_locales"`
SourcePath string `yaml:"source_path"`
Bundles []Bundle `yaml:"bundles"`
LLM LLM `yaml:"llm"`
BatchSize int `yaml:"batch_size"`
Concurrency int `yaml:"concurrency"`
StyleGuidesDir string `yaml:"style_guides_dir"`
GlossaryDir string `yaml:"glossary_dir"`
TMPath string `yaml:"tm_path"`
ManifestPath string `yaml:"manifest_path"`
Formats []string `yaml:"formats"`
Validation Validation `yaml:"validation"`
}

// Validation configures optional project-specific validation rules.
type Validation struct {
PluralStyle string `yaml:"plural_style"`
}

// Bundle maps one source file to a locale-specific target path.
Expand Down Expand Up @@ -168,6 +174,9 @@ func (c *Config) Validate() error {

// ValidateProject checks configuration that is required even for dry runs.
func (c *Config) ValidateProject() error {
if c.Validation.PluralStyle != "" && c.Validation.PluralStyle != "i18next-v4" {
return fmt.Errorf("unsupported validation.plural_style %q", c.Validation.PluralStyle)
}
if len(c.TargetLocales) == 0 {
return fmt.Errorf("target_locales must not be empty")
}
Expand Down
52 changes: 52 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,58 @@ llm:
}
}

func TestLoadResolvesValidationFromYAML(t *testing.T) {
path := filepath.Join(t.TempDir(), ".internationalizer.yml")
data := []byte(`source_locale: en
target_locales: [fr]
source_path: locales/en.json
validation:
plural_style: i18next-v4
`)
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if got, want := cfg.Validation.PluralStyle, "i18next-v4"; got != want {
t.Fatalf("validation plural style = %q, want %q", got, want)
}
}

func TestValidateProjectAcceptsSupportedPluralStyles(t *testing.T) {
for _, pluralStyle := range []string{"", "i18next-v4"} {
t.Run(pluralStyle, func(t *testing.T) {
cfg := &Config{
TargetLocales: []string{"fr"},
SourcePath: filepath.Join("locales", "en.json"),
Validation: Validation{PluralStyle: pluralStyle},
}
if err := cfg.ValidateProject(); err != nil {
t.Fatalf("ValidateProject rejected plural style %q: %v", pluralStyle, err)
}
})
}
}

func TestValidateProjectRejectsUnknownPluralStyle(t *testing.T) {
cfg := &Config{
TargetLocales: []string{"fr"},
SourcePath: filepath.Join("locales", "en.json"),
Validation: Validation{PluralStyle: "gettext"},
}

err := cfg.ValidateProject()
if err == nil {
t.Fatal("ValidateProject accepted an unknown plural style")
}
if got, want := err.Error(), `unsupported validation.plural_style "gettext"`; got != want {
t.Fatalf("ValidateProject error = %q, want %q", got, want)
}
}

func TestEffectiveBundlesPreservesLegacySourcePathContract(t *testing.T) {
cfg := &Config{SourcePath: filepath.Join("locales", "en.json")}
bundles := cfg.EffectiveBundles()
Expand Down
6 changes: 6 additions & 0 deletions internal/formats/formats.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ type Format interface {
Serialize(entries map[string]string, original []byte) ([]byte, error)
}

// EntryRemover is implemented by structured formats that can remove selected
// string leaves while preserving the rest of the original document shape.
type EntryRemover interface {
RemoveEntries(original []byte, keys map[string]struct{}) ([]byte, error)
}

var registry = []Format{
&JSONFormat{},
&YAMLFormat{},
Expand Down
40 changes: 40 additions & 0 deletions internal/formats/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,46 @@ func (f *JSONFormat) Serialize(entries map[string]string, original []byte) ([]by
return serializeFromScratch(entries)
}

func (f *JSONFormat) RemoveEntries(original []byte, keys map[string]struct{}) ([]byte, error) {
var raw interface{}
dec := json.NewDecoder(bytes.NewReader(original))
dec.UseNumber()
if err := dec.Decode(&raw); err != nil {
return nil, fmt.Errorf("json parse original: %w", err)
}
removeJSONEntries("", raw, keys)
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(raw); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}

func removeJSONEntries(prefix string, value interface{}, keys map[string]struct{}) {
switch node := value.(type) {
case map[string]interface{}:
for key, child := range node {
path := key
if prefix != "" {
path = prefix + "." + key
}
if _, remove := keys[path]; remove {
delete(node, key)
continue
}
removeJSONEntries(path, child, keys)
}
case []interface{}:
for index, child := range node {
path := fmt.Sprintf("%s.%d", prefix, index)
removeJSONEntries(path, child, keys)
}
}
}

// serializePreservingOrder walks the original JSON structure and replaces
// leaf values from the entries map, preserving key ordering.
func serializePreservingOrder(entries map[string]string, original []byte) ([]byte, error) {
Expand Down
Loading