diff --git a/.internationalizer.example.yml b/.internationalizer.example.yml index f455ecd..9efb008 100644 --- a/.internationalizer.example.yml +++ b/.internationalizer.example.yml @@ -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 diff --git a/README.md b/README.md index ac7f5f0..6f7009d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 diff --git a/cmd/internationalizer/validate.go b/cmd/internationalizer/validate.go index 3f0b1c3..b3e9987 100644 --- a/cmd/internationalizer/validate.go +++ b/cmd/internationalizer/validate.go @@ -16,7 +16,11 @@ 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) @@ -24,7 +28,12 @@ func newValidateCmd() *cobra.Command { 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 } @@ -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 } diff --git a/cmd/internationalizer/validate_test.go b/cmd/internationalizer/validate_test.go index 24169d4..0eae899 100644 --- a/cmd/internationalizer/validate_test.go +++ b/cmd/internationalizer/validate_test.go @@ -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"}) @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index f69ec40..d2107e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -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") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7024f1d..039fa66 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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() diff --git a/internal/formats/formats.go b/internal/formats/formats.go index f75ae2e..7c1b9ba 100644 --- a/internal/formats/formats.go +++ b/internal/formats/formats.go @@ -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{}, diff --git a/internal/formats/json.go b/internal/formats/json.go index 0e0bef8..1049f7f 100644 --- a/internal/formats/json.go +++ b/internal/formats/json.go @@ -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) { diff --git a/internal/formats/yaml.go b/internal/formats/yaml.go index f824eaa..9eb7f0d 100644 --- a/internal/formats/yaml.go +++ b/internal/formats/yaml.go @@ -2,6 +2,8 @@ package formats import ( "fmt" + "sort" + "strconv" "strings" "gopkg.in/yaml.v3" @@ -55,13 +57,64 @@ func (f *YAMLFormat) Serialize(entries map[string]string, original []byte) ([]by return serializeYAMLFromScratch(entries) } +func (f *YAMLFormat) RemoveEntries(original []byte, keys map[string]struct{}) ([]byte, error) { + var doc yaml.Node + if err := yaml.Unmarshal(original, &doc); err != nil { + return nil, fmt.Errorf("yaml parse original: %w", err) + } + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { + removeYAMLEntries("", doc.Content[0], keys) + } + return yaml.Marshal(&doc) +} + +func removeYAMLEntries(prefix string, node *yaml.Node, keys map[string]struct{}) { + switch node.Kind { + case yaml.MappingNode: + kept := node.Content[:0] + for index := 0; index+1 < len(node.Content); index += 2 { + keyNode := node.Content[index] + valueNode := node.Content[index+1] + path := keyNode.Value + if prefix != "" { + path = prefix + "." + keyNode.Value + } + if _, remove := keys[path]; remove { + continue + } + removeYAMLEntries(path, valueNode, keys) + kept = append(kept, keyNode, valueNode) + } + node.Content = kept + case yaml.SequenceNode: + for index, child := range node.Content { + path := fmt.Sprintf("%s.%d", prefix, index) + removeYAMLEntries(path, child, keys) + } + } +} + func serializeYAMLPreserving(entries map[string]string, original []byte) ([]byte, error) { var doc yaml.Node if err := yaml.Unmarshal(original, &doc); err != nil { return nil, fmt.Errorf("yaml parse original: %w", err) } + replaced := make(map[string]struct{}, len(entries)) if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { - replaceYAMLLeaves("", doc.Content[0], entries) + root := doc.Content[0] + replaceYAMLLeaves("", root, entries, replaced) + keys := make([]string, 0, len(entries)) + for key := range entries { + if _, ok := replaced[key]; !ok { + keys = append(keys, key) + } + } + sort.Strings(keys) + for _, key := range keys { + if err := setYAMLPath(root, strings.Split(key, "."), entries[key]); err != nil { + return nil, fmt.Errorf("yaml set path %q: %w", key, err) + } + } } out, err := yaml.Marshal(&doc) if err != nil { @@ -70,7 +123,7 @@ func serializeYAMLPreserving(entries map[string]string, original []byte) ([]byte return out, nil } -func replaceYAMLLeaves(prefix string, node *yaml.Node, entries map[string]string) { +func replaceYAMLLeaves(prefix string, node *yaml.Node, entries map[string]string, replaced map[string]struct{}) { switch node.Kind { case yaml.MappingNode: for i := 0; i+1 < len(node.Content); i += 2 { @@ -84,9 +137,10 @@ func replaceYAMLLeaves(prefix string, node *yaml.Node, entries map[string]string if replacement, ok := entries[p]; ok { val.Value = replacement val.Tag = "!!str" + replaced[p] = struct{}{} } } else { - replaceYAMLLeaves(p, val, entries) + replaceYAMLLeaves(p, val, entries, replaced) } } case yaml.SequenceNode: @@ -96,9 +150,10 @@ func replaceYAMLLeaves(prefix string, node *yaml.Node, entries map[string]string if replacement, ok := entries[p]; ok { child.Value = replacement child.Tag = "!!str" + replaced[p] = struct{}{} } } else { - replaceYAMLLeaves(p, child, entries) + replaceYAMLLeaves(p, child, entries, replaced) } } } @@ -108,15 +163,42 @@ func serializeYAMLFromScratch(entries map[string]string) ([]byte, error) { root := &yaml.Node{Kind: yaml.MappingNode} for key, value := range entries { parts := strings.Split(key, ".") - setYAMLPath(root, parts, value) + if err := setYAMLPath(root, parts, value); err != nil { + return nil, fmt.Errorf("yaml set path %q: %w", key, err) + } } doc := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}} return yaml.Marshal(doc) } -func setYAMLPath(node *yaml.Node, parts []string, value string) { +func setYAMLPath(node *yaml.Node, parts []string, value string) error { if len(parts) == 0 { - return + return nil + } + if node.Kind == yaml.SequenceNode { + index, err := strconv.Atoi(parts[0]) + if err != nil || index < 0 { + return fmt.Errorf("sequence requires a non-negative numeric segment, got %q", parts[0]) + } + for len(node.Content) <= index { + node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}) + } + child := node.Content[index] + if len(parts) == 1 { + child.Kind = yaml.ScalarNode + child.Tag = "!!str" + child.Value = value + child.Content = nil + return nil + } + ensureYAMLContainer(child, parts[1]) + return setYAMLPath(child, parts[1:], value) + } + if node.Kind != yaml.MappingNode { + node.Kind = yaml.MappingNode + node.Tag = "!!map" + node.Value = "" + node.Content = nil } // Find or create the key node. var valNode *yaml.Node @@ -129,20 +211,37 @@ func setYAMLPath(node *yaml.Node, parts []string, value string) { if valNode == nil { keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: parts[0]} if len(parts) == 1 { - valNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value} + valNode = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} } else { - valNode = &yaml.Node{Kind: yaml.MappingNode} + valNode = &yaml.Node{} + ensureYAMLContainer(valNode, parts[1]) } node.Content = append(node.Content, keyNode, valNode) } if len(parts) == 1 { valNode.Value = value valNode.Kind = yaml.ScalarNode + valNode.Tag = "!!str" + valNode.Content = nil + return nil + } + ensureYAMLContainer(valNode, parts[1]) + return setYAMLPath(valNode, parts[1:], value) +} + +func ensureYAMLContainer(node *yaml.Node, nextPart string) { + if node.Kind == yaml.MappingNode || node.Kind == yaml.SequenceNode { return } - if valNode.Kind != yaml.MappingNode { - valNode.Kind = yaml.MappingNode - valNode.Content = nil + if _, err := strconv.Atoi(nextPart); err == nil { + node.Kind = yaml.SequenceNode + node.Tag = "!!seq" + node.Value = "" + node.Content = nil + return } - setYAMLPath(valNode, parts[1:], value) + node.Kind = yaml.MappingNode + node.Tag = "!!map" + node.Value = "" + node.Content = nil } diff --git a/internal/formats/yaml_test.go b/internal/formats/yaml_test.go index 213e2b4..24ce6e5 100644 --- a/internal/formats/yaml_test.go +++ b/internal/formats/yaml_test.go @@ -1,6 +1,11 @@ package formats -import "testing" +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) func TestYAMLParseExcludesNonStringLeaves(t *testing.T) { f := &YAMLFormat{} @@ -12,3 +17,80 @@ func TestYAMLParseExcludesNonStringLeaves(t *testing.T) { t.Fatalf("entries = %#v, want only the translatable string", entries) } } + +func TestYAMLSerializePreservingAddsMissingEntries(t *testing.T) { + f := &YAMLFormat{} + output, err := f.Serialize(map[string]string{ + "items_one": "{{count}} article", + "items_many": "{{count}} articles", + }, []byte("# translations\nitems_one: '{{count}} item'\nenabled: true\n")) + if err != nil { + t.Fatal(err) + } + parsed, err := f.Parse(output) + if err != nil { + t.Fatal(err) + } + if parsed["items_one"] != "{{count}} article" || parsed["items_many"] != "{{count}} articles" { + t.Fatalf("serialized entries = %#v\n%s", parsed, output) + } + if !strings.Contains(string(output), "# translations") || !strings.Contains(string(output), "enabled: true") { + t.Fatalf("serialization lost source shape:\n%s", output) + } +} + +func TestYAMLSerializePreservingAddsMissingEntriesInsideSequence(t *testing.T) { + f := &YAMLFormat{} + output, err := f.Serialize(map[string]string{ + "screens.0.items_one": "{{count}} article", + "screens.0.items_many": "{{count}} articles", + }, []byte("screens:\n - items_one: '{{count}} item'\n")) + if err != nil { + t.Fatal(err) + } + parsed, err := f.Parse(output) + if err != nil { + t.Fatal(err) + } + if parsed["screens.0.items_many"] != "{{count}} articles" { + t.Fatalf("serialized entries = %#v\n%s", parsed, output) + } +} + +func TestYAMLSerializePreservingCreatesMissingSequenceBranch(t *testing.T) { + f := &YAMLFormat{} + output, err := f.Serialize(map[string]string{ + "title": "Accueil", + "screens.0.title": "Paramètres", + }, []byte("title: Home\n")) + if err != nil { + t.Fatal(err) + } + var decoded struct { + Screens []map[string]string `yaml:"screens"` + } + if err := yaml.Unmarshal(output, &decoded); err != nil { + t.Fatalf("missing sequence branch changed shape: %v\n%s", err, output) + } + if len(decoded.Screens) != 1 || decoded.Screens[0]["title"] != "Paramètres" { + t.Fatalf("missing sequence branch = %#v\n%s", decoded.Screens, output) + } +} + +func TestYAMLSerializePreservingKeepsNumericMappingKeys(t *testing.T) { + f := &YAMLFormat{} + output, err := f.Serialize(map[string]string{ + "http.200": "OK", + "http.404": "Introuvable", + }, []byte("http:\n '200': OK\n '500': Error\n")) + if err != nil { + t.Fatal(err) + } + parsed, err := f.Parse(output) + if err != nil { + t.Fatal(err) + } + if parsed["http.200"] != "OK" || parsed["http.404"] != "Introuvable" || parsed["http.500"] != "Error" { + t.Fatalf("numeric mapping changed shape: %#v\n%s", parsed, output) + } +} diff --git a/internal/glossary/glossary.go b/internal/glossary/glossary.go index 567e5dd..4658019 100644 --- a/internal/glossary/glossary.go +++ b/internal/glossary/glossary.go @@ -10,12 +10,19 @@ import ( // Term is a glossary entry mapping a source term to its approved translation. type Term struct { - Source string `json:"source"` - Target string `json:"target"` - IgnoreCase bool `json:"ignore_case,omitempty"` - WholeWord bool `json:"whole_word,omitempty"` + Source string `json:"source"` + Target string `json:"target"` + Variants []string `json:"variants,omitempty"` + Enforcement string `json:"enforcement,omitempty"` + IgnoreCase bool `json:"ignore_case,omitempty"` + WholeWord bool `json:"whole_word,omitempty"` } +const ( + EnforcementError = "error" + EnforcementWarning = "warning" +) + // Load reads the glossary file for a locale from the given directory. func Load(dir, locale string) ([]Term, error) { path := filepath.Join(dir, locale+".json") @@ -31,15 +38,21 @@ func Load(dir, locale string) ([]Term, error) { if err := json.Unmarshal(data, &terms); err != nil { return nil, fmt.Errorf("parsing glossary %s: %w", path, err) } + if err := validateTerms(terms); err != nil { + return nil, fmt.Errorf("validating glossary %s: %w", path, err) + } return terms, nil } // Save writes glossary terms to the locale file. func Save(dir, locale string, terms []Term) error { + path := filepath.Join(dir, locale+".json") + if err := validateTerms(terms); err != nil { + return fmt.Errorf("validating glossary %s: %w", path, err) + } if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("creating glossary directory: %w", err) } - path := filepath.Join(dir, locale+".json") data, err := json.MarshalIndent(terms, "", " ") if err != nil { return err @@ -47,6 +60,42 @@ func Save(dir, locale string, terms []Term) error { return os.WriteFile(path, append(data, '\n'), 0o644) } +func validateTerms(terms []Term) error { + for i, term := range terms { + switch term.Enforcement { + case "", EnforcementError, EnforcementWarning: + default: + return fmt.Errorf("term %d (%q) has invalid enforcement %q", i, term.Source, term.Enforcement) + } + } + return nil +} + +// SourceIdenticalExempt reports whether an otherwise source-identical complete +// value is explicitly approved by a glossary term with the same source and +// target. It never treats a term embedded within a larger value as an exemption. +func SourceIdenticalExempt(terms []Term, source, target string) bool { + for _, term := range terms { + equal := func(left, right string) bool { + if term.IgnoreCase { + return strings.EqualFold(left, right) + } + return left == right + } + if equal(source, target) && equal(term.Source, source) && equal(term.Target, target) { + return true + } + } + return false +} + +// ApprovedTargets returns the primary target followed by its approved variants. +func ApprovedTargets(term Term) []string { + targets := make([]string, 1, 1+len(term.Variants)) + targets[0] = term.Target + return append(targets, term.Variants...) +} + // Add appends a term to the glossary for a locale. func Add(dir, locale, source, target string) error { terms, err := Load(dir, locale) diff --git a/internal/glossary/glossary_test.go b/internal/glossary/glossary_test.go index 0e2b512..00a00cf 100644 --- a/internal/glossary/glossary_test.go +++ b/internal/glossary/glossary_test.go @@ -1,6 +1,10 @@ package glossary import ( + "os" + "path/filepath" + "reflect" + "strings" "testing" ) @@ -90,6 +94,107 @@ func TestLoadEmpty(t *testing.T) { } } +func TestLoadOldGlossaryWithoutValidationMetadata(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fr.json") + if err := os.WriteFile(path, []byte(`[{"source":"Dashboard","target":"Tableau de bord","ignore_case":true}]`), 0o644); err != nil { + t.Fatal(err) + } + + terms, err := Load(dir, "fr") + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(terms) != 1 { + t.Fatalf("Load returned %d terms, want 1", len(terms)) + } + if terms[0].Enforcement != "" || terms[0].Variants != nil { + t.Fatalf("old glossary metadata = %#v, want zero values", terms[0]) + } +} + +func TestSaveAndLoadVariantsAndEnforcement(t *testing.T) { + dir := t.TempDir() + want := []Term{{ + Source: "Sign in", + Target: "Connexion", + Variants: []string{"Se connecter"}, + Enforcement: EnforcementWarning, + }} + if err := Save(dir, "fr", want); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := Load(dir, "fr") + if err != nil { + t.Fatalf("Load: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Load = %#v, want %#v", got, want) + } +} + +func TestLoadRejectsInvalidEnforcement(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fr.json") + if err := os.WriteFile(path, []byte(`[{"source":"Save","target":"Enregistrer","enforcement":"block"}]`), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Load(dir, "fr") + if err == nil { + t.Fatal("Load accepted invalid enforcement") + } + if !strings.Contains(err.Error(), `invalid enforcement "block"`) { + t.Fatalf("Load error = %q, want invalid enforcement detail", err) + } +} + +func TestSaveRejectsInvalidEnforcementBeforeWriting(t *testing.T) { + dir := filepath.Join(t.TempDir(), "glossary") + err := Save(dir, "fr", []Term{{Source: "Save", Target: "Enregistrer", Enforcement: "block"}}) + if err == nil { + t.Fatal("Save accepted invalid enforcement") + } + if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) { + t.Fatalf("Save created glossary directory for invalid terms: %v", statErr) + } +} + +func TestApprovedTargetsIncludesPrimaryAndVariants(t *testing.T) { + term := Term{Target: "Connexion", Variants: []string{"Se connecter", "Identification"}} + want := []string{"Connexion", "Se connecter", "Identification"} + if got := ApprovedTargets(term); !reflect.DeepEqual(got, want) { + t.Fatalf("ApprovedTargets = %#v, want %#v", got, want) + } +} + +func TestSourceIdenticalExemptRequiresCompleteExplicitMatch(t *testing.T) { + terms := []Term{ + {Source: "API", Target: "API"}, + {Source: "GitHub", Target: "GitHub", IgnoreCase: true}, + } + tests := []struct { + name string + source string + target string + want bool + }{ + {name: "exact", source: "API", target: "API", want: true}, + {name: "ignore case", source: "github", target: "GITHUB", want: true}, + {name: "partial", source: "API access", target: "API access", want: false}, + {name: "not source identical", source: "API", target: "Apis", want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := SourceIdenticalExempt(terms, test.source, test.target); got != test.want { + t.Fatalf("SourceIdenticalExempt(%q, %q) = %t, want %t", test.source, test.target, got, test.want) + } + }) + } +} + func TestFormatForPrompt(t *testing.T) { terms := []Term{ {Source: "Dashboard", Target: "Tableau de bord", WholeWord: true}, diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 0000000..3606505 --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,46 @@ +// Package policy resolves the effective translation policy for a locale. +package policy + +import ( + "github.com/Tom-R-Main/Internationalizer/internal/config" + "github.com/Tom-R-Main/Internationalizer/internal/glossary" + "github.com/Tom-R-Main/Internationalizer/internal/llm" + "github.com/Tom-R-Main/Internationalizer/internal/state" +) + +const promptPolicyVersion = 1 + +// Resolved is the resolved prompt, provider settings, and stable hash for +// one target locale and source format. +type Resolved struct { + Prompt string + LLM config.LLM + Hash string +} + +// Resolve builds the effective translation policy. Callers provide +// already-loaded style-guide and glossary content so policy resolution remains +// independent of filesystem layout. +func Resolve(cfg *config.Config, targetLocale, format, styleGuide string, terms []glossary.Term) (Resolved, error) { + effectiveLLM := cfg.LLMForLocale(targetLocale) + prompt := llm.BuildSystemPrompt(cfg.SourceLocale, targetLocale, styleGuide, terms) + if format == "markdown" { + prompt = llm.BuildDocumentPrompt(cfg.SourceLocale, targetLocale, styleGuide, terms) + } + + hash, err := state.HashValue(struct { + Version int `json:"version"` + SourceLocale string `json:"source_locale"` + TargetLocale string `json:"target_locale"` + Format string `json:"format"` + Provider string `json:"provider"` + Model string `json:"model"` + Reasoning string `json:"reasoning_effort"` + Prompt string `json:"prompt"` + }{promptPolicyVersion, cfg.SourceLocale, targetLocale, format, effectiveLLM.Provider, effectiveLLM.Model, llm.EffectiveReasoningEffort(effectiveLLM), prompt}) + if err != nil { + return Resolved{}, err + } + + return Resolved{Prompt: prompt, LLM: effectiveLLM, Hash: hash}, nil +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go new file mode 100644 index 0000000..f020c09 --- /dev/null +++ b/internal/policy/policy_test.go @@ -0,0 +1,152 @@ +package policy_test + +import ( + "reflect" + "testing" + + "github.com/Tom-R-Main/Internationalizer/internal/config" + "github.com/Tom-R-Main/Internationalizer/internal/glossary" + "github.com/Tom-R-Main/Internationalizer/internal/policy" +) + +func TestResolveIsDeterministic(t *testing.T) { + cfg := baseConfig() + terms := []glossary.Term{{Source: "workspace", Target: "espace de travail", WholeWord: true}} + + first, err := policy.Resolve(cfg, "fr", "json", "Use formal language.", terms) + if err != nil { + t.Fatal(err) + } + second, err := policy.Resolve(cfg, "fr", "json", "Use formal language.", terms) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(first, second) { + t.Fatalf("resolutions differ: first = %#v, second = %#v", first, second) + } + if want := cfg.LLMForLocale("fr"); !reflect.DeepEqual(first.LLM, want) { + t.Fatalf("effective LLM = %#v, want %#v", first.LLM, want) + } + const wantHash = "fdfbfa87db81fd2fe792922f343342fe89705e51bb8bee61534a6813f17bf3d1" + if first.Hash != wantHash { + t.Fatalf("hash = %q, want legacy-compatible %q", first.Hash, wantHash) + } +} + +func TestResolveHashSensitivity(t *testing.T) { + base, err := policy.Resolve(baseConfig(), "fr", "json", "Use formal language.", nil) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + mutateConfig func(*config.Config) + targetLocale string + format string + styleGuide string + terms []glossary.Term + }{ + { + name: "prompt", + styleGuide: "Use informal language.", + }, + { + name: "glossary prompt", + terms: []glossary.Term{{Source: "workspace", Target: "espace de travail"}}, + }, + { + name: "provider", + mutateConfig: func(cfg *config.Config) { + cfg.LLM.Provider = "anthropic" + }, + }, + { + name: "model", + mutateConfig: func(cfg *config.Config) { + cfg.LLM.Model = "gpt-5.6-terra" + }, + }, + { + name: "reasoning", + mutateConfig: func(cfg *config.Config) { + cfg.LLM.ReasoningEffort = "max" + }, + }, + { + name: "format", + format: "yaml", + }, + { + name: "target locale", + targetLocale: "de", + }, + { + name: "source locale", + mutateConfig: func(cfg *config.Config) { + cfg.SourceLocale = "en-GB" + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := baseConfig() + if test.mutateConfig != nil { + test.mutateConfig(cfg) + } + targetLocale := test.targetLocale + if targetLocale == "" { + targetLocale = "fr" + } + format := test.format + if format == "" { + format = "json" + } + styleGuide := test.styleGuide + if styleGuide == "" { + styleGuide = "Use formal language." + } + + got, err := policy.Resolve(cfg, targetLocale, format, styleGuide, test.terms) + if err != nil { + t.Fatal(err) + } + if got.Hash == base.Hash { + t.Fatalf("hash did not change from %q", base.Hash) + } + }) + } +} + +func TestResolveUsesLocaleLLMOverride(t *testing.T) { + cfg := baseConfig() + cfg.LLM.LocaleOverrides = map[string]config.LLMOverride{ + "ja": { + Provider: "gemini", + Model: "gemini-3.8-flash", + APIKeyEnv: "GOOGLE_AI_STUDIO_API_KEY", + }, + } + + resolved, err := policy.Resolve(cfg, "ja", "json", "", nil) + if err != nil { + t.Fatal(err) + } + want := cfg.LLMForLocale("ja") + if !reflect.DeepEqual(resolved.LLM, want) { + t.Fatalf("effective LLM = %#v, want %#v", resolved.LLM, want) + } +} + +func baseConfig() *config.Config { + return &config.Config{ + SourceLocale: "en", + LLM: config.LLM{ + Provider: "openai", + Model: "gpt-5.6-luna", + ReasoningEffort: "low", + }, + } +} diff --git a/internal/translate/concurrency_test.go b/internal/translate/concurrency_test.go index 105bb5f..3298962 100644 --- a/internal/translate/concurrency_test.go +++ b/internal/translate/concurrency_test.go @@ -92,13 +92,17 @@ func TestRunHonorsCancellationBeforeWritingTargets(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - results, err := Run(ctx, cfg, &fakeProvider{}, Options{}) + provider := &fakeProvider{} + results, err := Run(ctx, cfg, provider, Options{}) if err != context.Canceled { t.Fatalf("Run error = %v, want context.Canceled", err) } if len(results) != 0 { t.Fatalf("canceled run results = %#v, want none", results) } + if provider.calls != 0 { + t.Fatalf("provider calls = %d, want none", provider.calls) + } if _, err := os.Stat(filepath.Join(dir, "fr.json")); !os.IsNotExist(err) { t.Fatalf("canceled run created target: %v", err) } diff --git a/internal/translate/protected.go b/internal/translate/protected.go index 7405d56..345f58e 100644 --- a/internal/translate/protected.go +++ b/internal/translate/protected.go @@ -2,17 +2,11 @@ package translate import ( "fmt" - "regexp" "strings" validation "github.com/Tom-R-Main/Internationalizer/internal/validate" ) -var ( - htmlTagRe = regexp.MustCompile(`(?s)|]*>`) - inlineCodeRe = regexp.MustCompile("`+[^`\\n]*`+") -) - // validateTranslationValue enforces deterministic invariants at both provider // and explicit-adoption boundaries. Instructions in a prompt are not proof // that protected source structure survived translation. @@ -20,152 +14,8 @@ func validateTranslationValue(key, source, target string) error { if strings.TrimSpace(source) != "" && strings.TrimSpace(target) == "" { return fmt.Errorf("blank translation for %q", key) } - if mismatch := validationMismatch(key, source, target); mismatch != nil { - return mismatch - } - if !equalStrings(htmlTagRe.FindAllString(source, -1), htmlTagRe.FindAllString(target, -1)) { - return fmt.Errorf("protected HTML structure mismatch for %q", key) - } - if !equalStrings(extractFencedCode(source), extractFencedCode(target)) { - return fmt.Errorf("fenced code mismatch for %q", key) - } - if !equalStrings(inlineCodeRe.FindAllString(source, -1), inlineCodeRe.FindAllString(target, -1)) { - return fmt.Errorf("inline code mismatch for %q", key) - } - if !equalStrings(extractLinkDestinations(source), extractLinkDestinations(target)) { - return fmt.Errorf("markdown link destination mismatch for %q", key) + if findings := validation.ProtectedFindings(key, source, target); len(findings) > 0 { + return fmt.Errorf("%s for %q", findings[0].Message, key) } return nil } - -func validationMismatch(key, source, target string) error { - mismatch := validation.InterpolationMismatch(key, source, target) - if mismatch == nil { - return nil - } - return fmt.Errorf("interpolation mismatch for %q (source: %v, target: %v)", key, mismatch.SourceVars, mismatch.TargetVars) -} - -func extractFencedCode(input string) []string { - lines := strings.SplitAfter(input, "\n") - var blocks []string - var block strings.Builder - var marker byte - var markerLength int - inFence := false - for _, line := range lines { - trimmed := strings.TrimLeft(strings.TrimSuffix(line, "\n"), " \t") - if !inFence { - candidate, length, ok := fenceMarker(trimmed) - if !ok { - continue - } - inFence = true - marker = candidate - markerLength = length - block.Reset() - block.WriteString(line) - continue - } - - block.WriteString(line) - if isClosingFence(trimmed, marker, markerLength) { - blocks = append(blocks, block.String()) - inFence = false - } - } - if inFence { - blocks = append(blocks, block.String()) - } - return blocks -} - -func fenceMarker(line string) (byte, int, bool) { - if len(line) < 3 || (line[0] != '`' && line[0] != '~') { - return 0, 0, false - } - marker := line[0] - length := 0 - for length < len(line) && line[length] == marker { - length++ - } - return marker, length, length >= 3 -} - -func isClosingFence(line string, marker byte, minimum int) bool { - if len(line) < minimum || line[0] != marker { - return false - } - length := 0 - for length < len(line) && line[length] == marker { - length++ - } - return length >= minimum && strings.TrimSpace(line[length:]) == "" -} - -func extractLinkDestinations(input string) []string { - var destinations []string - for offset := 0; offset+1 < len(input); { - relative := strings.Index(input[offset:], "](") - if relative < 0 { - break - } - start := offset + relative + 2 - depth := 1 - escaped := false - end := start - for ; end < len(input); end++ { - character := input[end] - if escaped { - escaped = false - continue - } - if character == '\\' { - escaped = true - continue - } - switch character { - case '(': - depth++ - case ')': - depth-- - if depth == 0 { - inside := strings.TrimSpace(input[start:end]) - destinations = append(destinations, firstLinkToken(inside)) - } - } - if depth == 0 { - break - } - } - if depth != 0 { - break - } - offset = end + 1 - } - return destinations -} - -func firstLinkToken(inside string) string { - if strings.HasPrefix(inside, "<") { - if end := strings.IndexByte(inside, '>'); end >= 0 { - return inside[:end+1] - } - } - if end := strings.IndexAny(inside, " \t\n"); end >= 0 { - return inside[:end] - } - return inside -} - -func equalStrings(left, right []string) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] != right[index] { - return false - } - } - return true -} diff --git a/internal/translate/translate.go b/internal/translate/translate.go index f398e15..8db397e 100644 --- a/internal/translate/translate.go +++ b/internal/translate/translate.go @@ -13,13 +13,13 @@ import ( "github.com/Tom-R-Main/Internationalizer/internal/formats" "github.com/Tom-R-Main/Internationalizer/internal/glossary" "github.com/Tom-R-Main/Internationalizer/internal/llm" + "github.com/Tom-R-Main/Internationalizer/internal/policy" "github.com/Tom-R-Main/Internationalizer/internal/state" "github.com/Tom-R-Main/Internationalizer/internal/styleguide" "github.com/Tom-R-Main/Internationalizer/internal/tm" + validation "github.com/Tom-R-Main/Internationalizer/internal/validate" ) -const promptPolicyVersion = 1 - // Options configures a translation run. type Options struct { DryRun bool @@ -83,6 +83,10 @@ type jobOutput struct { // Run executes the translation pipeline. func Run(ctx context.Context, cfg *config.Config, provider llm.Provider, opts Options) ([]Result, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + effectiveConfig := *cfg effectiveConfig.ApplyDefaults() cfg = &effectiveConfig @@ -246,8 +250,15 @@ func translateLocale( batchSize int, opts Options, ) jobOutput { - result := Result{Bundle: bundle.bundle.ID, Locale: locale, KeysTotal: len(bundle.sourceKeys)} - localeLLM := cfg.LLMForLocale(locale) + sourceKeys := bundle.sourceKeys + var optionalPluralKeys map[string]struct{} + if cfg.Validation.PluralStyle == "i18next-v4" { + sourceKeys, _, optionalPluralKeys = validation.ExpandI18nextV4Source(bundle.sourceKeys, cfg.SourceLocale, locale) + for key := range optionalPluralKeys { + delete(sourceKeys, key) + } + } + result := Result{Bundle: bundle.bundle.ID, Locale: locale, KeysTotal: len(sourceKeys)} targetPath, err := bundle.bundle.TargetPath(locale) if err != nil { result.Errors = append(result.Errors, err.Error()) @@ -265,24 +276,14 @@ func translateLocale( result.Errors = append(result.Errors, fmt.Sprintf("style guide: %v", err)) return jobOutput{result: result} } - prompt := llm.BuildSystemPrompt(cfg.SourceLocale, locale, guide, terms) - if bundle.format.Name() == "markdown" { - prompt = llm.BuildDocumentPrompt(cfg.SourceLocale, locale, guide, terms) - } - policyHash, err := state.HashValue(struct { - Version int `json:"version"` - SourceLocale string `json:"source_locale"` - TargetLocale string `json:"target_locale"` - Format string `json:"format"` - Provider string `json:"provider"` - Model string `json:"model"` - Reasoning string `json:"reasoning_effort"` - Prompt string `json:"prompt"` - }{promptPolicyVersion, cfg.SourceLocale, locale, bundle.format.Name(), localeLLM.Provider, localeLLM.Model, llm.EffectiveReasoningEffort(localeLLM), prompt}) + translationPolicy, err := policy.Resolve(cfg, locale, bundle.format.Name(), guide, terms) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("hashing translation policy: %v", err)) return jobOutput{result: result} } + localeLLM := translationPolicy.LLM + prompt := translationPolicy.Prompt + policyHash := translationPolicy.Hash targetKeys := make(map[string]string) var targetData []byte @@ -303,10 +304,10 @@ func translateLocale( return jobOutput{result: result} } - keys := sortedKeys(bundle.sourceKeys) + keys := sortedKeys(sourceKeys) plans := make([]plannedEntry, 0, len(keys)) for _, key := range keys { - sourceValue := bundle.sourceKeys[key] + sourceValue := sourceKeys[key] sourceHash := state.SourceHash(bundle.format.Name(), sourceValue) targetValue, exists := targetKeys[key] recorded, recordedOK := manifest.Get(bundle.bundle.ID, key, locale) @@ -421,6 +422,18 @@ func translateLocale( serializationBaseline := targetData if !targetExists { serializationBaseline = bundle.sourceData + if len(optionalPluralKeys) > 0 { + remover, ok := bundle.format.(formats.EntryRemover) + if !ok { + result.Errors = append(result.Errors, fmt.Sprintf("format %q cannot omit source-only plural forms", bundle.format.Name())) + return jobOutput{result: result} + } + serializationBaseline, err = remover.RemoveEntries(serializationBaseline, optionalPluralKeys) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("preparing target structure %s: %v", targetPath, err)) + return jobOutput{result: result} + } + } } output, err := bundle.format.Serialize(staged, serializationBaseline) if err != nil { diff --git a/internal/translate/translate_test.go b/internal/translate/translate_test.go index dcf9fa6..0b02a65 100644 --- a/internal/translate/translate_test.go +++ b/internal/translate/translate_test.go @@ -2,12 +2,14 @@ package translate import ( "context" + "encoding/json" "os" "path/filepath" "testing" "github.com/Tom-R-Main/Internationalizer/internal/config" "github.com/Tom-R-Main/Internationalizer/internal/llm" + validation "github.com/Tom-R-Main/Internationalizer/internal/validate" ) type fakeProvider struct { @@ -52,6 +54,101 @@ func TestRunRejectsIncompleteProviderResponseWithoutWritingTarget(t *testing.T) } } +func TestRunProducesTargetLocalePluralFormsThatPassStrictValidation(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "en.json") + if err := os.WriteFile(sourcePath, []byte(`{"items_one":"{{count}} item","items_other":"{{count}} items"}`), 0o644); err != nil { + t.Fatal(err) + } + cfg := testConfig(dir, sourcePath) + cfg.Validation.PluralStyle = "i18next-v4" + provider := &fakeProvider{response: &llm.TranslateResponse{Translations: map[string]string{ + "items_many": "{{count}} articles", + "items_one": "{{count}} article", + "items_other": "{{count}} articles", + }}} + + if _, err := Run(context.Background(), cfg, provider, Options{}); err != nil { + t.Fatal(err) + } + reports, err := validation.ValidateWithOptions(cfg, validation.Options{RequireState: true}) + if err != nil { + t.Fatal(err) + } + if validation.HasFailures(reports) { + t.Fatalf("translated plural target failed state validation: %#v", reports[0]) + } + reports, err = validation.ValidateWithOptions(cfg, validation.Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + if validation.HasFailures(reports) { + t.Fatalf("translated plural target failed content validation: %#v", reports[0]) + } +} + +func TestRunOmitsSourceOnlyPluralFormsForTargetLocale(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "en.json") + if err := os.WriteFile(sourcePath, []byte(`{"items_one":"{{count}} item","items_other":"{{count}} items"}`), 0o644); err != nil { + t.Fatal(err) + } + cfg := testConfig(dir, sourcePath) + cfg.TargetLocales = []string{"ja"} + cfg.Validation.PluralStyle = "i18next-v4" + provider := &fakeProvider{response: &llm.TranslateResponse{Translations: map[string]string{ + "items_other": "{{count}} 個", + }}} + + results, err := Run(context.Background(), cfg, provider, Options{}) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].KeysTotal != 1 || results[0].KeysTranslated != 1 { + t.Fatalf("Japanese plural result = %#v, want only items_other", results) + } + data, err := os.ReadFile(filepath.Join(dir, "ja.json")) + if err != nil { + t.Fatal(err) + } + var target map[string]string + if err := json.Unmarshal(data, &target); err != nil { + t.Fatal(err) + } + if target["items_other"] != "{{count}} 個" { + t.Fatalf("Japanese target = %#v, want items_other", target) + } + if _, ok := target["items_one"]; ok { + t.Fatalf("Japanese target retained source-only plural: %#v", target) + } +} + +func TestRunProducesTargetLocalePluralFormsForYAML(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "en.yml") + if err := os.WriteFile(sourcePath, []byte("# source\nitems_one: '{{count}} item'\nitems_other: '{{count}} items'\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg := testConfig(dir, sourcePath) + cfg.Validation.PluralStyle = "i18next-v4" + provider := &fakeProvider{response: &llm.TranslateResponse{Translations: map[string]string{ + "items_many": "{{count}} articles", + "items_one": "{{count}} article", + "items_other": "{{count}} articles", + }}} + + if _, err := Run(context.Background(), cfg, provider, Options{}); err != nil { + t.Fatal(err) + } + reports, err := validation.ValidateWithOptions(cfg, validation.Options{Strict: true, RequireState: true}) + if err != nil { + t.Fatal(err) + } + if validation.HasFailures(reports) { + t.Fatalf("translated YAML plural target failed validation: %#v", reports[0]) + } +} + func TestRunRoutesLocaleToConfiguredProvider(t *testing.T) { dir := t.TempDir() sourcePath := filepath.Join(dir, "en.json") diff --git a/internal/validate/findings.go b/internal/validate/findings.go new file mode 100644 index 0000000..1b5d03b --- /dev/null +++ b/internal/validate/findings.go @@ -0,0 +1,50 @@ +package validate + +import "sort" + +// FindingCode is a stable machine-readable validation outcome. +type FindingCode string + +const ( + CodeMissingKey FindingCode = "missing_key" + CodeExtraKey FindingCode = "extra_key" + CodeBlankTranslation FindingCode = "blank_translation" + CodeSourceIdentical FindingCode = "source_identical" + CodeProtectedStructureMismatch FindingCode = "protected_structure_mismatch" + CodeGlossaryViolation FindingCode = "glossary_violation" + CodePluralFormMissing FindingCode = "plural_form_missing" + CodeUntracked FindingCode = "untracked" + CodeSourceStale FindingCode = "source_stale" + CodePolicyStale FindingCode = "policy_stale" + CodeTargetModified FindingCode = "target_modified" +) + +// Severity determines whether a finding fails validation. +type Severity string + +const ( + SeverityWarning Severity = "warning" + SeverityError Severity = "error" +) + +// Finding describes one stable validation outcome for automation and humans. +type Finding struct { + Code FindingCode `json:"code"` + Severity Severity `json:"severity"` + Key string `json:"key,omitempty"` + Message string `json:"message"` + Expected []string `json:"expected,omitempty"` + Actual []string `json:"actual,omitempty"` +} + +func sortFindings(findings []Finding) { + sort.SliceStable(findings, func(i, j int) bool { + if findings[i].Key != findings[j].Key { + return findings[i].Key < findings[j].Key + } + if findings[i].Code != findings[j].Code { + return findings[i].Code < findings[j].Code + } + return findings[i].Message < findings[j].Message + }) +} diff --git a/internal/validate/plurals.go b/internal/validate/plurals.go index bfe0241..4bb9b45 100644 --- a/internal/validate/plurals.go +++ b/internal/validate/plurals.go @@ -1,5 +1,7 @@ package validate +import "strings" + // CLDRPluralForms maps locale codes to their required CLDR plural categories. // Source: Unicode CLDR plural rules for the most common languages. var CLDRPluralForms = map[string][]string{ @@ -32,7 +34,7 @@ var CLDRPluralForms = map[string][]string{ // Semitic "ar": {"zero", "one", "two", "few", "many", "other"}, - "he": {"one", "two", "many", "other"}, + "he": {"one", "two", "other"}, // Indic "hi": {"one", "other"}, @@ -60,8 +62,120 @@ var CLDRPluralForms = map[string][]string{ // PluralFormsFor returns the CLDR plural categories for a locale. // Falls back to ["one", "other"] if the locale is not in the table. func PluralFormsFor(locale string) []string { - if forms, ok := CLDRPluralForms[locale]; ok { + if forms, ok := KnownPluralFormsFor(locale); ok { return forms } return []string{"one", "other"} } + +// KnownPluralFormsFor returns configured CLDR categories without guessing for +// unknown locales. Strict validation uses this form to avoid false failures. +func KnownPluralFormsFor(locale string) ([]string, bool) { + if forms, ok := CLDRPluralForms[locale]; ok { + return forms, true + } + for configuredLocale, forms := range CLDRPluralForms { + if strings.EqualFold(configuredLocale, locale) { + return forms, true + } + } + if separator := strings.IndexAny(locale, "-_"); separator > 0 { + forms, ok := CLDRPluralForms[strings.ToLower(locale[:separator])] + return forms, ok + } + return nil, false +} + +// ExpandI18nextV4Source returns the source key set plus any target-only plural +// categories needed by targetLocale. A missing target category uses the source +// locale's "other" value as its translation template. optional contains source +// plural keys that the target locale does not require. +func ExpandI18nextV4Source(sourceKeys map[string]string, sourceLocale, targetLocale string) (expanded map[string]string, required, optional map[string]struct{}) { + expanded = cloneStrings(sourceKeys) + required = make(map[string]struct{}) + optional = make(map[string]struct{}) + + sourceForms, sourceKnown := KnownPluralFormsFor(sourceLocale) + targetForms, targetKnown := KnownPluralFormsFor(targetLocale) + if !sourceKnown || !targetKnown { + return expanded, required, optional + } + + for base, values := range pluralFamilies(sourceKeys, sourceForms) { + template := values["other"] + if template == "" { + for _, form := range sourceForms { + if value, ok := values[form]; ok { + template = value + break + } + } + } + for _, form := range targetForms { + key := base + "_" + form + required[key] = struct{}{} + if _, ok := expanded[key]; !ok { + expanded[key] = template + } + } + for _, form := range sourceForms { + key := base + "_" + form + if _, targetRequires := required[key]; !targetRequires { + optional[key] = struct{}{} + } + } + } + return expanded, required, optional +} + +func pluralFamilies(sourceKeys map[string]string, sourceForms []string) map[string]map[string]string { + forms := make(map[string]struct{}, len(sourceForms)) + for _, form := range sourceForms { + forms[form] = struct{}{} + } + candidates := make(map[string]map[string]string) + for key, value := range sourceKeys { + base, form, ok := splitI18nextPluralKey(key) + if !ok { + continue + } + if _, sourceForm := forms[form]; !sourceForm { + continue + } + if candidates[base] == nil { + candidates[base] = make(map[string]string) + } + candidates[base][form] = value + } + for base, values := range candidates { + for _, form := range sourceForms { + if _, ok := values[form]; !ok { + delete(candidates, base) + break + } + } + } + return candidates +} + +func cloneStrings(values map[string]string) map[string]string { + cloned := make(map[string]string, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + +func splitI18nextPluralKey(key string) (string, string, bool) { + separator := strings.LastIndexByte(key, '_') + if separator <= 0 || separator == len(key)-1 { + return "", "", false + } + category := key[separator+1:] + for _, known := range []string{"zero", "one", "two", "few", "many", "other"} { + if category == known { + return key[:separator], category, true + } + } + return "", "", false +} diff --git a/internal/validate/plurals_test.go b/internal/validate/plurals_test.go new file mode 100644 index 0000000..fcf4f0e --- /dev/null +++ b/internal/validate/plurals_test.go @@ -0,0 +1,67 @@ +package validate + +import ( + "reflect" + "testing" +) + +func TestKnownPluralFormsForFallsBackToBaseLocaleWithoutGuessing(t *testing.T) { + forms, ok := KnownPluralFormsFor("ru-RU") + if !ok || len(forms) != 4 || forms[1] != "few" { + t.Fatalf("Russian forms = %v, known = %v", forms, ok) + } + if forms, ok := KnownPluralFormsFor("xx-ZZ"); ok || forms != nil { + t.Fatalf("unknown locale forms = %v, known = %v", forms, ok) + } +} + +func TestExpandI18nextV4SourceUsesLocalePluralFamilies(t *testing.T) { + expanded, required, optional := ExpandI18nextV4Source( + map[string]string{"items_other": "{{count}} 個"}, + "ja", + "ar", + ) + wantKeys := []string{"items_few", "items_many", "items_one", "items_other", "items_two", "items_zero"} + var gotKeys []string + for _, key := range wantKeys { + if expanded[key] != "{{count}} 個" { + t.Fatalf("expanded[%q] = %q", key, expanded[key]) + } + if _, ok := required[key]; !ok { + t.Fatalf("required lacks %q: %#v", key, required) + } + gotKeys = append(gotKeys, key) + } + if !reflect.DeepEqual(gotKeys, wantKeys) || len(optional) != 0 || len(expanded) != len(wantKeys) { + t.Fatalf("expanded = %#v, required = %#v, optional = %#v", expanded, required, optional) + } +} + +func TestExpandI18nextV4SourceMarksSourceOnlyFormsOptional(t *testing.T) { + expanded, required, optional := ExpandI18nextV4Source( + map[string]string{"items_one": "{{count}} item", "items_other": "{{count}} items"}, + "en", + "ja", + ) + if len(expanded) != 2 || len(required) != 1 { + t.Fatalf("expanded = %#v, required = %#v", expanded, required) + } + if _, ok := optional["items_one"]; !ok { + t.Fatalf("optional = %#v, want items_one", optional) + } +} + +func TestHebrewPluralFormsMatchI18nextV4CardinalCategories(t *testing.T) { + forms, ok := KnownPluralFormsFor("he") + if !ok || !reflect.DeepEqual(forms, []string{"one", "two", "other"}) { + t.Fatalf("Hebrew forms = %v, known = %t", forms, ok) + } +} + +func TestKnownPluralFormsForAcceptsCaseInsensitiveLocaleTags(t *testing.T) { + for _, locale := range []string{"FR", "PT-br", "ja-JP"} { + if _, ok := KnownPluralFormsFor(locale); !ok { + t.Fatalf("KnownPluralFormsFor(%q) did not normalize locale casing", locale) + } + } +} diff --git a/internal/validate/protected.go b/internal/validate/protected.go new file mode 100644 index 0000000..2feffd9 --- /dev/null +++ b/internal/validate/protected.go @@ -0,0 +1,170 @@ +package validate + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + htmlTagRe = regexp.MustCompile(`(?s)|]*>`) + inlineCodeRe = regexp.MustCompile("`+[^`\\n]*`+") +) + +// ProtectedFindings compares source and target structures that translations +// must preserve exactly. Multiple damaged structures may yield multiple +// findings with the same stable code and distinct messages. +func ProtectedFindings(key, source, target string) []Finding { + var findings []Finding + if mismatch := InterpolationMismatch(key, source, target); mismatch != nil { + findings = append(findings, protectedFinding(key, "interpolation variables", mismatch.SourceVars, mismatch.TargetVars)) + } + if sourceTags, targetTags := htmlTagRe.FindAllString(source, -1), htmlTagRe.FindAllString(target, -1); !equalStrings(sourceTags, targetTags) { + findings = append(findings, protectedFinding(key, "HTML structure", sourceTags, targetTags)) + } + if sourceBlocks, targetBlocks := extractFencedCode(source), extractFencedCode(target); !equalStrings(sourceBlocks, targetBlocks) { + findings = append(findings, protectedFinding(key, "fenced code", sourceBlocks, targetBlocks)) + } + if sourceCode, targetCode := inlineCodeRe.FindAllString(source, -1), inlineCodeRe.FindAllString(target, -1); !equalStrings(sourceCode, targetCode) { + findings = append(findings, protectedFinding(key, "inline code", sourceCode, targetCode)) + } + if sourceLinks, targetLinks := extractLinkDestinations(source), extractLinkDestinations(target); !equalStrings(sourceLinks, targetLinks) { + findings = append(findings, protectedFinding(key, "markdown link destinations", sourceLinks, targetLinks)) + } + return findings +} + +func protectedFinding(key, structure string, expected, actual []string) Finding { + return Finding{ + Code: CodeProtectedStructureMismatch, + Severity: SeverityError, + Key: key, + Message: fmt.Sprintf("protected %s mismatch", structure), + Expected: expected, + Actual: actual, + } +} + +func extractFencedCode(input string) []string { + lines := strings.SplitAfter(input, "\n") + var blocks []string + var block strings.Builder + var marker byte + var markerLength int + inFence := false + for _, line := range lines { + trimmed := strings.TrimLeft(strings.TrimSuffix(line, "\n"), " \t") + if !inFence { + candidate, length, ok := fenceMarker(trimmed) + if !ok { + continue + } + inFence = true + marker = candidate + markerLength = length + block.Reset() + block.WriteString(line) + continue + } + + block.WriteString(line) + if isClosingFence(trimmed, marker, markerLength) { + blocks = append(blocks, block.String()) + inFence = false + } + } + if inFence { + blocks = append(blocks, block.String()) + } + return blocks +} + +func fenceMarker(line string) (byte, int, bool) { + if len(line) < 3 || (line[0] != '`' && line[0] != '~') { + return 0, 0, false + } + marker := line[0] + length := 0 + for length < len(line) && line[length] == marker { + length++ + } + return marker, length, length >= 3 +} + +func isClosingFence(line string, marker byte, minimum int) bool { + if len(line) < minimum || line[0] != marker { + return false + } + length := 0 + for length < len(line) && line[length] == marker { + length++ + } + return length >= minimum && strings.TrimSpace(line[length:]) == "" +} + +func extractLinkDestinations(input string) []string { + var destinations []string + for offset := 0; offset+1 < len(input); { + relative := strings.Index(input[offset:], "](") + if relative < 0 { + break + } + start := offset + relative + 2 + depth := 1 + escaped := false + end := start + for ; end < len(input); end++ { + character := input[end] + if escaped { + escaped = false + continue + } + if character == '\\' { + escaped = true + continue + } + switch character { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + inside := strings.TrimSpace(input[start:end]) + destinations = append(destinations, firstLinkToken(inside)) + } + } + if depth == 0 { + break + } + } + if depth != 0 { + break + } + offset = end + 1 + } + return destinations +} + +func firstLinkToken(inside string) string { + if strings.HasPrefix(inside, "<") { + if end := strings.IndexByte(inside, '>'); end >= 0 { + return inside[:end+1] + } + } + if end := strings.IndexAny(inside, " \t\n"); end >= 0 { + return inside[:end] + } + return inside +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/internal/validate/strict_test.go b/internal/validate/strict_test.go new file mode 100644 index 0000000..cec7633 --- /dev/null +++ b/internal/validate/strict_test.go @@ -0,0 +1,538 @@ +package validate + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + "time" + + "github.com/Tom-R-Main/Internationalizer/internal/config" + "github.com/Tom-R-Main/Internationalizer/internal/glossary" + "github.com/Tom-R-Main/Internationalizer/internal/policy" + "github.com/Tom-R-Main/Internationalizer/internal/state" +) + +func TestStrictValidationSeparatesStructuralAndTranslatedCoverage(t *testing.T) { + cfg := validationConfig(t, map[string]string{"translated": "Save", "seeded": "Cancel"}, map[string]string{"translated": "Enregistrer", "seeded": "Cancel"}) + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + report := reports[0] + if report.Coverage != 100 || report.StructuralCoverage != 100 || report.TranslatedCoverage == nil || *report.TranslatedCoverage != 50 { + t.Fatalf("coverage = legacy %.1f, structural %.1f, translated %v", report.Coverage, report.StructuralCoverage, report.TranslatedCoverage) + } + assertFindingCodes(t, report, CodeSourceIdentical) + if !HasFailures(reports) { + t.Fatal("strict source-identical translation did not fail") + } +} + +func TestStrictValidationAllowsExactSameSourceGlossaryTerm(t *testing.T) { + cfg := validationConfig(t, map[string]string{"brand": "Lens"}, map[string]string{"brand": "Lens"}) + if err := glossary.Save(cfg.GlossaryDir, "fr", []glossary.Term{{Source: "Lens", Target: "Lens", WholeWord: true}}); err != nil { + t.Fatal(err) + } + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + if HasFailures(reports) || reports[0].TranslatedCoverage == nil || *reports[0].TranslatedCoverage != 100 { + t.Fatalf("exact glossary exemption failed: %#v", reports[0]) + } +} + +func TestStrictValidationAllowsNonLinguisticValues(t *testing.T) { + cfg := validationConfig(t, + map[string]string{"count": "123", "separator": "---"}, + map[string]string{"count": "123", "separator": "---"}, + ) + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + if HasFailures(reports) || reports[0].TranslatedCoverage == nil || *reports[0].TranslatedCoverage != 100 { + t.Fatalf("non-linguistic values failed strict validation: %#v", reports[0]) + } +} + +func TestExtraKeyWarnsByDefaultAndFailsStrictOrRequiredState(t *testing.T) { + cfg := validationConfig(t, map[string]string{"save": "Save"}, map[string]string{"save": "Enregistrer", "extra": "Supplémentaire"}) + + legacy, err := Validate(cfg) + if err != nil { + t.Fatal(err) + } + if HasFailures(legacy) { + t.Fatalf("legacy extra key became a failure: %#v", legacy[0]) + } + if findingByCode(legacy[0], CodeExtraKey).Severity != SeverityWarning { + t.Fatalf("legacy extra finding = %#v", findingByCode(legacy[0], CodeExtraKey)) + } + + strict, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + if !HasFailures(strict) || findingByCode(strict[0], CodeExtraKey).Severity != SeverityError { + t.Fatalf("strict extra key did not fail: %#v", strict[0]) + } + + requiredState, err := ValidateWithOptions(cfg, Options{RequireState: true}) + if err != nil { + t.Fatal(err) + } + if !HasFailures(requiredState) || findingByCode(requiredState[0], CodeExtraKey).Severity != SeverityError { + t.Fatalf("required-state extra key did not fail: %#v", requiredState[0]) + } +} + +func TestStrictValidationReportsProtectedAndGlossaryFailures(t *testing.T) { + cfg := validationConfig(t, + map[string]string{ + "html": "Click here", + "placeholder": "Hello, {{name}}", + "term": "Open the Dashboard", + }, + map[string]string{ + "html": "Cliquez ici", + "placeholder": "Bonjour", + "term": "Ouvrez le tableau", + }, + ) + if err := glossary.Save(cfg.GlossaryDir, "fr", []glossary.Term{{Source: "Dashboard", Target: "Tableau de bord", WholeWord: true}}); err != nil { + t.Fatal(err) + } + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + assertFindingCodes(t, reports[0], CodeGlossaryViolation, CodeProtectedStructureMismatch) + if len(reports[0].Mismatches) != 1 || reports[0].Mismatches[0].Key != "placeholder" { + t.Fatalf("legacy mismatch field changed: %#v", reports[0].Mismatches) + } +} + +func TestWarningGlossaryFindingDoesNotFailOtherwiseValidStrictReport(t *testing.T) { + cfg := validationConfig(t, map[string]string{"term": "Open Dashboard"}, map[string]string{"term": "Ouvrir tableau"}) + if err := glossary.Save(cfg.GlossaryDir, "fr", []glossary.Term{{Source: "Dashboard", Target: "Tableau de bord", Enforcement: glossary.EnforcementWarning}}); err != nil { + t.Fatal(err) + } + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + finding := findingByCode(reports[0], CodeGlossaryViolation) + if finding.Severity != SeverityWarning || HasFailures(reports) { + t.Fatalf("warning glossary result = %#v, report = %#v", finding, reports[0]) + } +} + +func TestGlossaryIgnoreCaseUsesUnicodeCaseFolding(t *testing.T) { + cfg := validationConfig(t, map[string]string{"term": "ΟΣ"}, map[string]string{"term": "ος"}) + if err := glossary.Save(cfg.GlossaryDir, "fr", []glossary.Term{{Source: "οσ", Target: "οσ", IgnoreCase: true, WholeWord: true}}); err != nil { + t.Fatal(err) + } + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + if findingByCode(reports[0], CodeGlossaryViolation).Code != "" { + t.Fatalf("Unicode case-folded glossary term failed: %#v", reports[0]) + } +} + +func TestStrictValidationRejectsBlankTranslation(t *testing.T) { + cfg := validationConfig(t, map[string]string{"save": "Save"}, map[string]string{"save": ""}) + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + assertFindingCodes(t, reports[0], CodeBlankTranslation) + if !HasFailures(reports) || reports[0].TranslatedCoverage == nil || *reports[0].TranslatedCoverage != 0 { + t.Fatalf("blank translation passed strict validation: %#v", reports[0]) + } +} + +func TestI18nextPluralValidationAllowsRequiredTargetForms(t *testing.T) { + cfg := validationConfig(t, + map[string]string{"items_one": "{{count}} item", "items_other": "{{count}} items"}, + map[string]string{"items_one": "{{count}} article", "items_many": "{{count}} articles", "items_other": "{{count}} articles"}, + ) + cfg.Validation.PluralStyle = "i18next-v4" + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + if HasFailures(reports) || len(reports[0].Extra) != 0 { + t.Fatalf("valid French plural forms failed: %#v", reports[0]) + } + + writeJSON(t, targetPath(cfg), map[string]string{"items_one": "{{count}} article", "items_other": "{{count}} articles"}) + reports, err = ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + assertFindingCodes(t, reports[0], CodePluralFormMissing) + if findingByCode(reports[0], CodePluralFormMissing).Key != "items_many" { + t.Fatalf("plural finding = %#v", findingByCode(reports[0], CodePluralFormMissing)) + } +} + +func TestTargetOnlyPluralFormsReceiveContentAndStateValidation(t *testing.T) { + cfg := validationConfig(t, + map[string]string{"items_one": "{{count}} item", "items_other": "{{count}} items"}, + map[string]string{"items_one": "{{count}} article", "items_many": "", "items_other": "{{count}} articles"}, + ) + cfg.Validation.PluralStyle = "i18next-v4" + + reports, err := ValidateWithOptions(cfg, Options{Strict: true, RequireState: true}) + if err != nil { + t.Fatal(err) + } + var pluralFindings []FindingCode + for _, finding := range reports[0].Findings { + if finding.Key == "items_many" { + pluralFindings = append(pluralFindings, finding.Code) + } + } + for _, want := range []FindingCode{CodeBlankTranslation, CodeProtectedStructureMismatch, CodeUntracked} { + found := false + for _, got := range pluralFindings { + if got == want { + found = true + break + } + } + if !found { + t.Fatalf("items_many findings = %v, missing %q", pluralFindings, want) + } + } +} + +func TestUnknownLocaleDoesNotGuessPluralRequirements(t *testing.T) { + cfg := validationConfig(t, + map[string]string{"items_one": "{{count}} item", "items_other": "{{count}} items"}, + map[string]string{"items_one": "{{count}} x", "items_other": "{{count}} xs"}, + ) + oldTarget := targetPath(cfg) + cfg.TargetLocales = []string{"xx-ZZ"} + newTarget := filepath.Join(filepath.Dir(oldTarget), "xx-ZZ.json") + if err := os.Rename(oldTarget, newTarget); err != nil { + t.Fatal(err) + } + cfg.Validation.PluralStyle = "i18next-v4" + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + if findingByCode(reports[0], CodePluralFormMissing).Code != "" { + t.Fatalf("unknown locale received guessed plural finding: %#v", reports[0]) + } + + writeJSON(t, newTarget, map[string]string{"items_other": "{{count}} xs"}) + reports, err = ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + assertFindingCodes(t, reports[0], CodeMissingKey) +} + +func TestI18nextPluralCoverageUsesTargetLocaleRequirements(t *testing.T) { + cfg := validationConfig(t, + map[string]string{"items_one": "{{count}} item", "items_other": "{{count}} items"}, + map[string]string{"items_other": "{{count}} 個"}, + ) + oldTarget := targetPath(cfg) + cfg.TargetLocales = []string{"ja"} + newTarget := filepath.Join(filepath.Dir(oldTarget), "ja.json") + if err := os.Rename(oldTarget, newTarget); err != nil { + t.Fatal(err) + } + cfg.Validation.PluralStyle = "i18next-v4" + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + if HasFailures(reports) || reports[0].StructuralCoverage != 100 || reports[0].TranslatedCoverage == nil || *reports[0].TranslatedCoverage != 100 { + t.Fatalf("valid Japanese plural coverage = %#v", reports[0]) + } +} + +func TestRequireStateReportsAndClearsBoundProvenanceFindings(t *testing.T) { + cfg := validationConfig(t, map[string]string{"save": "Save"}, map[string]string{"save": "Enregistrer"}) + reports, err := ValidateWithOptions(cfg, Options{RequireState: true}) + if err != nil { + t.Fatal(err) + } + assertFindingCodes(t, reports[0], CodeUntracked) + + resolved, err := policy.Resolve(cfg, "fr", "json", "", nil) + if err != nil { + t.Fatal(err) + } + manifest := state.New() + manifest.Set(state.Entry{ + Bundle: "default", + Key: "save", + Locale: "fr", + SourceHash: state.SourceHash("json", "Save"), + PolicyHash: resolved.Hash, + TargetHash: state.TargetHash("Enregistrer"), + UpdatedAt: time.Now().UTC(), + }) + if err := manifest.Save(cfg.ManifestPath); err != nil { + t.Fatal(err) + } + + reports, err = ValidateWithOptions(cfg, Options{RequireState: true}) + if err != nil { + t.Fatal(err) + } + if HasFailures(reports) { + t.Fatalf("matching state failed: %#v", reports[0]) + } + + writeJSON(t, cfg.SourcePath, map[string]string{"save": "Save now"}) + writeJSON(t, targetPath(cfg), map[string]string{"save": "Sauvegarder"}) + cfg.LLM.Model = "test-v2" + reports, err = ValidateWithOptions(cfg, Options{RequireState: true}) + if err != nil { + t.Fatal(err) + } + assertFindingCodes(t, reports[0], CodePolicyStale, CodeSourceStale, CodeTargetModified) +} + +func TestRequireStateRejectsMalformedManifest(t *testing.T) { + cfg := validationConfig(t, map[string]string{"save": "Save"}, map[string]string{"save": "Enregistrer"}) + if err := os.WriteFile(cfg.ManifestPath, []byte("{"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ValidateWithOptions(cfg, Options{RequireState: true}); err == nil { + t.Fatal("malformed manifest was accepted") + } +} + +func TestProtectedFindingsUseStableCodeForEveryStructure(t *testing.T) { + tests := map[string][2]string{ + "interpolation": {"Hello {{name}}", "Bonjour"}, + "html": {"Save", "Enregistrer"}, + "inline code": {"Run `go test`", "Lancez `go test ./...`"}, + "fenced code": {"```go\nfmt.Println(1)\n```\n", "```go\nfmt.Println(2)\n```\n"}, + "link": {"[Guide](https://example.com/a)", "[Guide](https://example.com/b)"}, + } + for name, values := range tests { + t.Run(name, func(t *testing.T) { + findings := ProtectedFindings("key", values[0], values[1]) + if len(findings) == 0 || findings[0].Code != CodeProtectedStructureMismatch { + t.Fatalf("findings = %#v", findings) + } + }) + } +} + +func TestEvaluationCorpusSchemaAndIDsAreStable(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "test", "evaluation", "v1", "cases.json")) + if err != nil { + t.Fatal(err) + } + var corpus struct { + SchemaVersion int `json:"schema_version"` + Cases []struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &corpus); err != nil { + t.Fatal(err) + } + if corpus.SchemaVersion != 1 || len(corpus.Cases) < 10 { + t.Fatalf("corpus metadata = %#v", corpus) + } + seen := make(map[string]struct{}, len(corpus.Cases)) + for _, testCase := range corpus.Cases { + if testCase.ID == "" || testCase.Kind == "" { + t.Fatalf("invalid case metadata: %#v", testCase) + } + if _, exists := seen[testCase.ID]; exists { + t.Fatalf("duplicate evaluation case id %q", testCase.ID) + } + seen[testCase.ID] = struct{}{} + } +} + +func TestStrictEvaluationProjectExposesBaselineBlindSpots(t *testing.T) { + repositoryRoot := filepath.Clean(filepath.Join("..", "..")) + t.Chdir(repositoryRoot) + cfg, err := config.Load(filepath.Join("test", "evaluation", "v1", "project", "config.yml")) + if err != nil { + t.Fatal(err) + } + + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + if len(reports) != 1 { + t.Fatalf("reports = %#v", reports) + } + report := reports[0] + if report.StructuralCoverage < 72.7 || report.StructuralCoverage > 72.8 || report.TranslatedCoverage == nil || *report.TranslatedCoverage < 63.6 || *report.TranslatedCoverage > 63.7 { + t.Fatalf("evaluation coverage = structural %.1f, translated %v", report.StructuralCoverage, report.TranslatedCoverage) + } + for _, code := range []FindingCode{ + CodeMissingKey, + CodeExtraKey, + CodeSourceIdentical, + CodeProtectedStructureMismatch, + CodeGlossaryViolation, + CodePluralFormMissing, + } { + if findingByCode(report, code).Code == "" { + t.Fatalf("evaluation findings = %#v, missing %q", report.Findings, code) + } + } +} + +func TestEvaluationValidationCasesMatchStrictFindings(t *testing.T) { + repositoryRoot := filepath.Clean(filepath.Join("..", "..")) + t.Chdir(repositoryRoot) + data, err := os.ReadFile(filepath.Join("test", "evaluation", "v1", "cases.json")) + if err != nil { + t.Fatal(err) + } + var corpus struct { + Cases []struct { + ID string `json:"id"` + Kind string `json:"kind"` + ExpectedFindingCodes []FindingCode `json:"expected_finding_codes"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &corpus); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(filepath.Join("test", "evaluation", "v1", "project", "config.yml")) + if err != nil { + t.Fatal(err) + } + reports, err := ValidateWithOptions(cfg, Options{Strict: true}) + if err != nil { + t.Fatal(err) + } + keyByCase := map[string]string{ + "missing-key": "missing", + "unexpected-target-key": "extra", + "english-seed": "seeded", + "allowed-product-name": "brand", + "placeholder-damage": "placeholder", + "html-damage": "markup", + "glossary-drift": "term", + "target-plural-gap": "items_many", + } + for _, testCase := range corpus.Cases { + if testCase.Kind != "validation" { + continue + } + key, ok := keyByCase[testCase.ID] + if !ok { + t.Fatalf("validation case %q has no executable project mapping", testCase.ID) + } + var got []FindingCode + for _, finding := range reports[0].Findings { + if finding.Key == key { + got = append(got, finding.Code) + } + } + sort.Slice(got, func(i, j int) bool { return got[i] < got[j] }) + want := append([]FindingCode(nil), testCase.ExpectedFindingCodes...) + sort.Slice(want, func(i, j int) bool { return want[i] < want[j] }) + if !reflect.DeepEqual(got, want) { + t.Fatalf("case %q findings = %v, want %v", testCase.ID, got, want) + } + } +} + +func validationConfig(t *testing.T, source, target map[string]string) *config.Config { + t.Helper() + dir := t.TempDir() + sourcePath := filepath.Join(dir, "en.json") + writeJSON(t, sourcePath, source) + writeJSON(t, filepath.Join(dir, "fr.json"), target) + return &config.Config{ + SourceLocale: "en", + TargetLocales: []string{"fr"}, + SourcePath: sourcePath, + GlossaryDir: filepath.Join(dir, "glossary"), + StyleGuidesDir: filepath.Join(dir, "style-guides"), + ManifestPath: filepath.Join(dir, "manifest.json"), + LLM: config.LLM{Provider: "test", Model: "test-v1"}, + } +} + +func targetPath(cfg *config.Config) string { + path, _ := cfg.EffectiveBundles()[0].TargetPath(cfg.TargetLocales[0]) + return path +} + +func writeJSON(t *testing.T, path string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + +func findingByCode(report Report, code FindingCode) Finding { + for _, finding := range report.Findings { + if finding.Code == code { + return finding + } + } + return Finding{} +} + +func assertFindingCodes(t *testing.T, report Report, expected ...FindingCode) { + t.Helper() + gotSet := make(map[FindingCode]struct{}) + for _, finding := range report.Findings { + gotSet[finding.Code] = struct{}{} + } + for _, code := range expected { + if _, ok := gotSet[code]; !ok { + t.Fatalf("findings = %#v, missing code %q", report.Findings, code) + } + } +} + +func TestFindingsSortDeterministicallyByKeyThenCode(t *testing.T) { + findings := []Finding{ + {Key: "z", Code: CodeSourceIdentical, Message: "z"}, + {Key: "a", Code: CodeSourceIdentical, Message: "b"}, + {Key: "a", Code: CodeExtraKey, Message: "a"}, + } + sortFindings(findings) + want := []Finding{ + {Key: "a", Code: CodeExtraKey, Message: "a"}, + {Key: "a", Code: CodeSourceIdentical, Message: "b"}, + {Key: "z", Code: CodeSourceIdentical, Message: "z"}, + } + if !reflect.DeepEqual(findings, want) { + t.Fatalf("findings = %#v, want %#v", findings, want) + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 0c9c84b..ccdc35f 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -6,21 +6,37 @@ import ( "regexp" "sort" "strings" + "unicode" "github.com/Tom-R-Main/Internationalizer/internal/config" "github.com/Tom-R-Main/Internationalizer/internal/formats" + "github.com/Tom-R-Main/Internationalizer/internal/glossary" + "github.com/Tom-R-Main/Internationalizer/internal/policy" + "github.com/Tom-R-Main/Internationalizer/internal/state" + "github.com/Tom-R-Main/Internationalizer/internal/styleguide" ) -// Report holds validation results for a single locale. +// Options enables validation contracts that are intentionally opt-in during +// their compatibility period. +type Options struct { + Strict bool + RequireState bool +} + +// Report holds validation results for a single locale. Coverage remains a +// compatibility alias for StructuralCoverage. type Report struct { - Bundle string `json:"bundle"` - Locale string `json:"locale"` - TargetPath string `json:"target_path"` - Missing []string `json:"missing"` - Extra []string `json:"extra"` - Mismatches []Mismatch `json:"mismatches,omitempty"` - Errors []string `json:"errors,omitempty"` - Coverage float64 `json:"coverage"` + Bundle string `json:"bundle"` + Locale string `json:"locale"` + TargetPath string `json:"target_path"` + Missing []string `json:"missing"` + Extra []string `json:"extra"` + Mismatches []Mismatch `json:"mismatches,omitempty"` + Errors []string `json:"errors,omitempty"` + Coverage float64 `json:"coverage"` + StructuralCoverage float64 `json:"structural_coverage"` + TranslatedCoverage *float64 `json:"translated_coverage,omitempty"` + Findings []Finding `json:"findings,omitempty"` } // Mismatch indicates an interpolation variable difference between source and target. @@ -30,14 +46,31 @@ type Mismatch struct { TargetVars []string `json:"target_vars"` } -// interpolation patterns: {{var}}, {var}, %{var} var interpolationRe = regexp.MustCompile(`(?:\{\{(\w+)\}\}|\{(\w+)\}|%\{(\w+)\})`) -// Validate checks all target locales against the source locale. +// Validate preserves the original validation behavior. func Validate(cfg *config.Config) ([]Report, error) { + return ValidateWithOptions(cfg, Options{}) +} + +// ValidateWithOptions checks all target locales against the source locale. +func ValidateWithOptions(cfg *config.Config, opts Options) ([]Report, error) { + effectiveConfig := *cfg + effectiveConfig.ApplyDefaults() + cfg = &effectiveConfig if err := cfg.ValidateProject(); err != nil { return nil, err } + + var manifest *state.Manifest + if opts.RequireState { + var err error + manifest, err = state.Load(cfg.ManifestPath) + if err != nil { + return nil, err + } + } + var reports []Report for _, bundle := range cfg.EffectiveBundles() { format, err := formatForBundle(bundle) @@ -57,7 +90,29 @@ func Validate(cfg *config.Config) ([]Report, error) { if err != nil { return nil, err } - report := validateLocale(bundle.ID, locale, sourceKeys, targetPath, format) + + var terms []glossary.Term + if opts.Strict || opts.RequireState { + terms, err = glossary.Load(cfg.GlossaryDir, locale) + if err != nil { + return nil, err + } + } + + policyHash := "" + if opts.RequireState { + guide, loadErr := styleguide.Load(cfg.StyleGuidesDir, locale) + if loadErr != nil { + return nil, loadErr + } + resolved, resolveErr := policy.Resolve(cfg, locale, format.Name(), guide, terms) + if resolveErr != nil { + return nil, fmt.Errorf("hashing translation policy: %w", resolveErr) + } + policyHash = resolved.Hash + } + + report := validateLocale(bundle.ID, cfg.SourceLocale, locale, sourceKeys, targetPath, format, terms, manifest, policyHash, cfg.Validation.PluralStyle, opts) reports = append(reports, report) } } @@ -71,61 +126,209 @@ func formatForBundle(bundle config.Bundle) (formats.Format, error) { return formats.FormatForFile(bundle.Source) } -func validateLocale(bundle, locale string, sourceKeys map[string]string, targetPath string, format formats.Format) Report { +func validateLocale(bundle, sourceLocale, locale string, sourceKeys map[string]string, targetPath string, format formats.Format, terms []glossary.Term, manifest *state.Manifest, policyHash, pluralStyle string, opts Options) Report { report := Report{Bundle: bundle, Locale: locale, TargetPath: targetPath} + if opts.Strict { + translated := 0.0 + report.TranslatedCoverage = &translated + } + + validationKeys := sourceKeys + requiredPluralKeys := make(map[string]struct{}) + optionalPluralKeys := make(map[string]struct{}) + if (opts.Strict || opts.RequireState) && pluralStyle == "i18next-v4" { + validationKeys, requiredPluralKeys, optionalPluralKeys = ExpandI18nextV4Source(sourceKeys, sourceLocale, locale) + } targetData, err := os.ReadFile(targetPath) if err != nil { - // Target file doesn't exist — all keys are missing. - for key := range sourceKeys { + for _, key := range allKeys(validationKeys) { + if _, optional := optionalPluralKeys[key]; optional { + continue + } report.Missing = append(report.Missing, key) + report.Findings = append(report.Findings, missingFinding(key, requiredPluralKeys)) } - sort.Strings(report.Missing) - report.Coverage = 0 return report } targetKeys, err := format.Parse(targetData) if err != nil { - report.Missing = allKeys(sourceKeys) + report.Missing = allKeys(validationKeys) report.Errors = append(report.Errors, fmt.Sprintf("parsing target: %v", err)) - report.Coverage = 0 return report } - // Find missing and extra keys. - for key := range sourceKeys { - if _, ok := targetKeys[key]; !ok { + present := 0 + translated := 0 + for key, sourceValue := range validationKeys { + _, coverageKey := optionalPluralKeys[key] + coverageKey = !coverageKey + targetValue, ok := targetKeys[key] + if !ok { + if _, optional := optionalPluralKeys[key]; optional { + if coverageKey { + present++ + translated++ + } + continue + } report.Missing = append(report.Missing, key) + report.Findings = append(report.Findings, missingFinding(key, requiredPluralKeys)) + continue } - } - for key := range targetKeys { - if _, ok := sourceKeys[key]; !ok { - report.Extra = append(report.Extra, key) + if coverageKey { + present++ + } + + blank := strings.TrimSpace(sourceValue) != "" && strings.TrimSpace(targetValue) == "" + identical := sourceValue == targetValue && !isNonLinguistic(sourceValue) + exempt := identical && glossary.SourceIdenticalExempt(terms, sourceValue, targetValue) + if coverageKey && !blank && (!identical || exempt) { + translated++ + } + if opts.Strict && blank { + report.Findings = append(report.Findings, Finding{Code: CodeBlankTranslation, Severity: SeverityError, Key: key, Message: "non-empty source has a blank target"}) + } + if opts.Strict && identical && !exempt { + report.Findings = append(report.Findings, Finding{Code: CodeSourceIdentical, Severity: SeverityError, Key: key, Message: "target is identical to the source without an exact glossary exemption"}) + } + + if mismatch := InterpolationMismatch(key, sourceValue, targetValue); mismatch != nil { + report.Mismatches = append(report.Mismatches, *mismatch) + } + if opts.Strict { + report.Findings = append(report.Findings, ProtectedFindings(key, sourceValue, targetValue)...) + report.Findings = append(report.Findings, glossaryFindings(key, sourceValue, targetValue, terms)...) + } + if opts.RequireState { + report.Findings = append(report.Findings, provenanceFindings(bundle, key, locale, format.Name(), sourceValue, targetValue, policyHash, manifest)...) } } - // Check interpolation mismatches on shared keys. - for key, sourceVal := range sourceKeys { - targetVal, ok := targetKeys[key] - if !ok { + for key := range targetKeys { + if _, ok := validationKeys[key]; ok { continue } - if mismatch := InterpolationMismatch(key, sourceVal, targetVal); mismatch != nil { - report.Mismatches = append(report.Mismatches, *mismatch) + report.Extra = append(report.Extra, key) + severity := SeverityWarning + if opts.Strict || opts.RequireState { + severity = SeverityError } + report.Findings = append(report.Findings, Finding{Code: CodeExtraKey, Severity: severity, Key: key, Message: "target contains a key absent from the source"}) } sort.Strings(report.Missing) sort.Strings(report.Extra) + sort.Slice(report.Mismatches, func(i, j int) bool { return report.Mismatches[i].Key < report.Mismatches[j].Key }) + sortFindings(report.Findings) - total := len(sourceKeys) + total := len(validationKeys) - len(optionalPluralKeys) if total > 0 { - report.Coverage = float64(total-len(report.Missing)) / float64(total) * 100 + report.StructuralCoverage = float64(present) / float64(total) * 100 + report.Coverage = report.StructuralCoverage + if report.TranslatedCoverage != nil { + value := float64(translated) / float64(total) * 100 + report.TranslatedCoverage = &value + } } return report } +func missingFinding(key string, requiredPluralKeys map[string]struct{}) Finding { + if _, required := requiredPluralKeys[key]; required { + return Finding{Code: CodePluralFormMissing, Severity: SeverityError, Key: key, Message: "target locale requires this plural form"} + } + return Finding{Code: CodeMissingKey, Severity: SeverityError, Key: key, Message: "target key is missing"} +} + +func provenanceFindings(bundle, key, locale, format, source, target, policyHash string, manifest *state.Manifest) []Finding { + recorded, ok := manifest.Get(bundle, key, locale) + if !ok { + return []Finding{{Code: CodeUntracked, Severity: SeverityError, Key: key, Message: "target has no manifest provenance"}} + } + var findings []Finding + if recorded.SourceHash != state.SourceHash(format, source) { + findings = append(findings, Finding{Code: CodeSourceStale, Severity: SeverityError, Key: key, Message: "source changed after the recorded translation"}) + } + if recorded.PolicyHash != policyHash { + findings = append(findings, Finding{Code: CodePolicyStale, Severity: SeverityError, Key: key, Message: "translation policy changed after the recorded translation"}) + } + if recorded.TargetHash != state.TargetHash(target) { + findings = append(findings, Finding{Code: CodeTargetModified, Severity: SeverityError, Key: key, Message: "target changed after the recorded translation"}) + } + return findings +} + +func glossaryFindings(key, source, target string, terms []glossary.Term) []Finding { + var findings []Finding + for _, term := range terms { + if !containsGlossaryValue(source, term.Source, term.IgnoreCase, term.WholeWord) { + continue + } + approved := glossary.ApprovedTargets(term) + matched := false + for _, candidate := range approved { + if containsGlossaryValue(target, candidate, term.IgnoreCase, term.WholeWord) { + matched = true + break + } + } + if matched { + continue + } + severity := SeverityError + if term.Enforcement == glossary.EnforcementWarning { + severity = SeverityWarning + } + findings = append(findings, Finding{Code: CodeGlossaryViolation, Severity: severity, Key: key, Message: fmt.Sprintf("source term %q is missing an approved target form", term.Source), Expected: approved}) + } + return findings +} + +func containsGlossaryValue(value, term string, ignoreCase, wholeWord bool) bool { + if term == "" { + return false + } + if ignoreCase { + matcher := regexp.MustCompile(`(?i:` + regexp.QuoteMeta(term) + `)`) + for _, location := range matcher.FindAllStringIndex(value, -1) { + if !wholeWord || (wordBoundary(value, location[0]-1) && wordBoundary(value, location[1])) { + return true + } + } + return false + } + for offset := 0; ; { + index := strings.Index(value[offset:], term) + if index < 0 { + return false + } + start := offset + index + end := start + len(term) + if !wholeWord || (wordBoundary(value, start-1) && wordBoundary(value, end)) { + return true + } + offset = end + if offset >= len(value) { + return false + } + } +} + +func wordBoundary(value string, byteIndex int) bool { + if byteIndex < 0 || byteIndex >= len(value) { + return true + } + for byteIndex > 0 && value[byteIndex]&0xC0 == 0x80 { + byteIndex-- + } + for _, r := range value[byteIndex:] { + return !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '_' + } + return true +} + // InterpolationMismatch compares the placeholder multiset in two values. func InterpolationMismatch(key, source, target string) *Mismatch { sourceVars := extractVars(source) @@ -142,6 +345,11 @@ func HasFailures(reports []Report) bool { if len(report.Missing) > 0 || len(report.Mismatches) > 0 || len(report.Errors) > 0 { return true } + for _, finding := range report.Findings { + if finding.Severity == SeverityError { + return true + } + } } return false } @@ -181,51 +389,72 @@ func allKeys(m map[string]string) []string { return keys } +func isNonLinguistic(value string) bool { + for _, r := range value { + if unicode.IsLetter(r) { + return false + } + } + return true +} + // FormatHuman returns a human-readable summary of validation reports. func FormatHuman(reports []Report) string { var b strings.Builder hasErrors := false - - for _, r := range reports { + for _, report := range reports { status := "OK" - if len(r.Missing) > 0 || len(r.Mismatches) > 0 || len(r.Errors) > 0 { + if HasFailures([]Report{report}) { status = "FAIL" hasErrors = true } - - _, _ = fmt.Fprintf(&b, "[%s/%s] %s — %.1f%% coverage", r.Bundle, r.Locale, status, r.Coverage) - - if len(r.Missing) > 0 { - _, _ = fmt.Fprintf(&b, ", %d missing", len(r.Missing)) + if report.TranslatedCoverage == nil { + _, _ = fmt.Fprintf(&b, "[%s/%s] %s — %.1f%% coverage", report.Bundle, report.Locale, status, report.Coverage) + } else { + _, _ = fmt.Fprintf(&b, "[%s/%s] %s — %.1f%% structural coverage", report.Bundle, report.Locale, status, report.StructuralCoverage) + _, _ = fmt.Fprintf(&b, ", %.1f%% translated coverage", *report.TranslatedCoverage) } - if len(r.Extra) > 0 { - _, _ = fmt.Fprintf(&b, ", %d extra", len(r.Extra)) + if len(report.Missing) > 0 { + _, _ = fmt.Fprintf(&b, ", %d missing", len(report.Missing)) } - if len(r.Mismatches) > 0 { - _, _ = fmt.Fprintf(&b, ", %d interpolation mismatches", len(r.Mismatches)) + if len(report.Extra) > 0 { + _, _ = fmt.Fprintf(&b, ", %d extra", len(report.Extra)) } - if len(r.Errors) > 0 { - _, _ = fmt.Fprintf(&b, ", %d errors", len(r.Errors)) + if len(report.Mismatches) > 0 { + _, _ = fmt.Fprintf(&b, ", %d interpolation mismatches", len(report.Mismatches)) } - b.WriteString("\n") - - // Show details for failures. - if len(r.Missing) > 0 && len(r.Missing) <= 20 { - for _, key := range r.Missing { - _, _ = fmt.Fprintf(&b, " - missing: %s\n", key) + if len(report.Errors) > 0 { + _, _ = fmt.Fprintf(&b, ", %d errors", len(report.Errors)) + } + b.WriteByte('\n') + for _, finding := range report.Findings { + _, _ = fmt.Fprintf(&b, " - %s: %s", finding.Code, finding.Message) + if finding.Key != "" { + _, _ = fmt.Fprintf(&b, " (%s)", finding.Key) } + b.WriteByte('\n') } - for _, m := range r.Mismatches { - _, _ = fmt.Fprintf(&b, " - mismatch: %s (source: %v, target: %v)\n", - m.Key, m.SourceVars, m.TargetVars) + for _, mismatch := range report.Mismatches { + if hasProtectedInterpolationFinding(report.Findings, mismatch.Key) { + continue + } + _, _ = fmt.Fprintf(&b, " - mismatch: %s (source: %v, target: %v)\n", mismatch.Key, mismatch.SourceVars, mismatch.TargetVars) } - for _, reportErr := range r.Errors { + for _, reportErr := range report.Errors { _, _ = fmt.Fprintf(&b, " - error: %s\n", reportErr) } } - if !hasErrors { b.WriteString("\nAll locales valid.\n") } return b.String() } + +func hasProtectedInterpolationFinding(findings []Finding, key string) bool { + for _, finding := range findings { + if finding.Code == CodeProtectedStructureMismatch && finding.Key == key && finding.Message == "protected interpolation variables mismatch" { + return true + } + } + return false +} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index a3270fa..2b5cbad 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -3,6 +3,7 @@ package validate import ( "os" "path/filepath" + "strings" "testing" "github.com/Tom-R-Main/Internationalizer/internal/config" @@ -65,3 +66,20 @@ func TestValidateReportsMalformedTargetAsFailure(t *testing.T) { t.Fatalf("reports = %#v, want malformed target failure", reports) } } + +func TestFormatHumanPreservesInterpolationMismatchDetails(t *testing.T) { + reports := []Report{{ + Bundle: "app", + Locale: "fr", + Mismatches: []Mismatch{{ + Key: "welcome", + SourceVars: []string{"name"}, + TargetVars: nil, + }}, + }} + + got := FormatHuman(reports) + if !strings.Contains(got, "mismatch: welcome (source: [name], target: [])") { + t.Fatalf("FormatHuman omitted mismatch details: %q", got) + } +} diff --git a/test/acceptance/acceptance_test.go b/test/acceptance/acceptance_test.go index ac15571..b2a0be1 100644 --- a/test/acceptance/acceptance_test.go +++ b/test/acceptance/acceptance_test.go @@ -168,6 +168,40 @@ func TestAdoptionLifecycleIsExplicitIdempotentAndReadOnlyWhenPlanned(t *testing. } } +func TestStrictValidationDistinguishesSeededContentFromCurrentState(t *testing.T) { + projectDir := t.TempDir() + mustWriteFile(t, filepath.Join(projectDir, "locales", "en.json"), "{\n \"brand\": \"Lens\",\n \"seeded\": \"Save\"\n}\n") + mustWriteFile(t, filepath.Join(projectDir, "locales", "fr.json"), "{\n \"brand\": \"Lens\",\n \"seeded\": \"Save\"\n}\n") + mustWriteFile(t, filepath.Join(projectDir, "glossary", "fr.json"), "[{\"source\":\"Lens\",\"target\":\"Lens\",\"whole_word\":true}]\n") + mustWriteFile(t, filepath.Join(projectDir, ".internationalizer.yml"), `source_locale: en +target_locales: [fr] +source_path: locales/en.json +glossary_dir: glossary +manifest_path: .internationalizer.lock +`) + + legacy := runCLI(t, projectDir, nil, "validate", "--json") + legacy.requireSuccess(t) + + strict := runCLI(t, projectDir, nil, "validate", "--strict", "--json") + if strict.exitCode != 1 { + t.Fatalf("strict exit code = %d, want 1; stderr=%s", strict.exitCode, strict.stderr) + } + for _, want := range []string{`"structural_coverage": 100`, `"translated_coverage": 50`, `"code": "source_identical"`} { + if !strings.Contains(strict.stdout, want) { + t.Fatalf("strict report does not contain %q:\n%s", want, strict.stdout) + } + } + + adoption := runCLI(t, projectDir, nil, "translate", "--adopt-existing") + adoption.requireSuccess(t) + requireState := runCLI(t, projectDir, nil, "validate", "--require-state", "--json") + requireState.requireSuccess(t) + if strings.Contains(requireState.stdout, `"code": "untracked"`) { + t.Fatalf("adopted state remained untracked:\n%s", requireState.stdout) + } +} + func TestOpenAITranslationExercisesResponsesContractAndPreservesSourceShape(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/test/evaluation/v1/README.md b/test/evaluation/v1/README.md new file mode 100644 index 0000000..12a255e --- /dev/null +++ b/test/evaluation/v1/README.md @@ -0,0 +1,14 @@ +# Internationalizer evaluation fixtures v1 + +This directory freezes the shared evidence contract used by deterministic +validation and future translation-memory experiments. + +`cases.json` is an append-only corpus within schema version 1. Every case has a +stable `id`, a `kind`, its relevant inputs, and expected stable finding or +eligibility codes. New case kinds may add fields, but existing meanings must not +change without a schema-version bump. + +The fixture project is intentionally invalid. `baseline.json` records how the +CLI at commit `c95cfbdda62f569e353bac59f4a8dbd9b7d5834e` observed it before strict +validation existed. Provider fields are null because this baseline is fully +deterministic and makes no model calls. diff --git a/test/evaluation/v1/baseline.json b/test/evaluation/v1/baseline.json new file mode 100644 index 0000000..2a1dee9 --- /dev/null +++ b/test/evaluation/v1/baseline.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "captured_at": "2026-09-03", + "commit": "c95cfbdda62f569e353bac59f4a8dbd9b7d5834e", + "go_version": "go1.27.0", + "platform": "darwin/arm64", + "command": "GOTOOLCHAIN=local GOPROXY=off go run ./cmd/internationalizer validate --config test/evaluation/v1/project/config.yml --json", + "provider": null, + "model": null, + "exit_code": 1, + "observed_report": { + "bundle": "evaluation", + "locale": "fr", + "missing": ["items_other", "missing"], + "extra": ["extra"], + "interpolation_mismatches": ["placeholder"], + "coverage": 80 + }, + "not_observed": [ + "source_identical", + "protected_structure_mismatch_for_html", + "glossary_violation", + "plural_form_missing", + "translated_coverage" + ] +} diff --git a/test/evaluation/v1/cases.json b/test/evaluation/v1/cases.json new file mode 100644 index 0000000..912f251 --- /dev/null +++ b/test/evaluation/v1/cases.json @@ -0,0 +1,98 @@ +{ + "schema_version": 1, + "cases": [ + { + "id": "missing-key", + "kind": "validation", + "source": "Missing", + "target_present": false, + "expected_finding_codes": ["missing_key"] + }, + { + "id": "unexpected-target-key", + "kind": "validation", + "target": "Supplémentaire", + "source_present": false, + "expected_finding_codes": ["extra_key"] + }, + { + "id": "english-seed", + "kind": "validation", + "source": "Save", + "target": "Save", + "expected_finding_codes": ["source_identical"] + }, + { + "id": "allowed-product-name", + "kind": "validation", + "source": "Lens", + "target": "Lens", + "glossary": [{"source": "Lens", "target": "Lens", "whole_word": true}], + "expected_finding_codes": [] + }, + { + "id": "placeholder-damage", + "kind": "validation", + "source": "Hello, {{name}}", + "target": "Bonjour", + "expected_finding_codes": ["protected_structure_mismatch"] + }, + { + "id": "html-damage", + "kind": "validation", + "source": "Click here", + "target": "Cliquez ici", + "expected_finding_codes": ["protected_structure_mismatch"] + }, + { + "id": "glossary-drift", + "kind": "validation", + "source": "Open the Dashboard", + "target": "Ouvrez le tableau", + "glossary": [{"source": "Dashboard", "target": "Tableau de bord", "whole_word": true}], + "expected_finding_codes": ["glossary_violation"] + }, + { + "id": "target-plural-gap", + "kind": "validation", + "source_locale": "en", + "target_locale": "fr", + "plural_style": "i18next-v4", + "source_keys": ["items_one", "items_other"], + "target_keys": ["items_one"], + "expected_finding_codes": ["plural_form_missing"] + }, + { + "id": "collapsed-sibling-states", + "kind": "audit", + "sources": {"state.open": "Open", "state.closed": "Closed"}, + "targets": {"state.open": "Ouvert", "state.closed": "Ouvert"}, + "expected_finding_codes": ["sibling_translation_collapsed"] + }, + { + "id": "unsafe-number-change", + "kind": "candidate", + "previous_source": "Retry in 5 minutes", + "current_source": "Retry in 30 minutes", + "expected_eligibility": "reject", + "expected_reason": "semantic_anchor_changed" + }, + { + "id": "unsafe-negation-change", + "kind": "candidate", + "previous_source": "Notifications are enabled", + "current_source": "Notifications are not enabled", + "expected_eligibility": "reject", + "expected_reason": "semantic_anchor_changed" + }, + { + "id": "safe-copy-edit", + "kind": "repair", + "previous_source": "Save item", + "previous_target": "Enregistrer l’élément", + "current_source": "Save this item", + "reviewed_reference": "Enregistrer cet élément", + "expected_eligibility": "eligible" + } + ] +} diff --git a/test/evaluation/v1/project/config.yml b/test/evaluation/v1/project/config.yml new file mode 100644 index 0000000..cf01557 --- /dev/null +++ b/test/evaluation/v1/project/config.yml @@ -0,0 +1,11 @@ +source_locale: en +target_locales: [fr] +bundles: + - id: evaluation + source: test/evaluation/v1/project/locales/en.json + target: test/evaluation/v1/project/locales/{locale}.json + format: json +glossary_dir: test/evaluation/v1/project/glossary +manifest_path: test/evaluation/v1/project/manifest.json +validation: + plural_style: i18next-v4 diff --git a/test/evaluation/v1/project/glossary/fr.json b/test/evaluation/v1/project/glossary/fr.json new file mode 100644 index 0000000..f7d4cb9 --- /dev/null +++ b/test/evaluation/v1/project/glossary/fr.json @@ -0,0 +1,12 @@ +[ + { + "source": "Lens", + "target": "Lens", + "whole_word": true + }, + { + "source": "Dashboard", + "target": "Tableau de bord", + "whole_word": true + } +] diff --git a/test/evaluation/v1/project/locales/en.json b/test/evaluation/v1/project/locales/en.json new file mode 100644 index 0000000..588733b --- /dev/null +++ b/test/evaluation/v1/project/locales/en.json @@ -0,0 +1,14 @@ +{ + "missing": "Missing", + "seeded": "Save", + "brand": "Lens", + "placeholder": "Hello, {{name}}", + "markup": "Click here", + "term": "Open the Dashboard", + "items_one": "{{count}} item", + "items_other": "{{count}} items", + "state": { + "open": "Open", + "closed": "Closed" + } +} diff --git a/test/evaluation/v1/project/locales/fr.json b/test/evaluation/v1/project/locales/fr.json new file mode 100644 index 0000000..7266d45 --- /dev/null +++ b/test/evaluation/v1/project/locales/fr.json @@ -0,0 +1,13 @@ +{ + "seeded": "Save", + "brand": "Lens", + "placeholder": "Bonjour", + "markup": "Cliquez ici", + "term": "Ouvrez le tableau", + "items_one": "{{count}} article", + "state": { + "open": "Ouvert", + "closed": "Ouvert" + }, + "extra": "Supplémentaire" +}