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)|?[A-Za-z][^>]*>`)
- 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)|?[A-Za-z][^>]*>`)
+ 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"
+}