diff --git a/README.md b/README.md index 96d1c4f..3bba438 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Internationalizer is different. It's a **CLI pipeline** that combines LLM transl - **Per-language style guides** — control tone, formality, pluralization, and typography - **Translation memory** — skip unchanged strings, save money on API calls - **Deterministic validation** — catch missing or extra keys, protected-structure drift, glossary issues, and plural or ICU errors before they ship +- **Explicit approval** — keep provider or adopted provenance separate from human review +- **Fluent and pseudolocales** — preserve translator context and exercise accented or bidirectional layouts without an API call ## Installation @@ -105,10 +107,13 @@ internationalizer translate --dry-run internationalizer translate ``` -5. Validate all locales: +5. Validate and approve the exact generated artifacts: ```bash internationalizer validate +internationalizer review list --status needs_review +internationalizer review approve --locale fr --all +internationalizer validate --require-approved ``` ## Commands @@ -131,9 +136,39 @@ Translation state independently reports missing, source-stale, policy-stale, current, and manually edited conditions, so a manual edit cannot conceal a source or policy change. Policy-stale values are reported but only retranslated with `--refresh-policy`. Manually edited values are never overwritten -automatically. Use `--adopt-existing` when introducing the manifest to reviewed -translations or when explicitly accepting a reviewed manual edit as the new -baseline. +automatically. Use `--adopt-existing` when introducing the manifest to existing +translations or when explicitly accepting a manual edit as the new provenance +baseline. Adoption does not imply human approval; use `review approve` for that +separate decision. + +### `pseudo` + +Generate deterministic test locales without a provider or translation-memory +lookup. Accented output defaults to `en-XA`; bidirectional output defaults to +`ar-XB`. ICU and Fluent runtime syntax, code, links, and markup are preserved. + +```bash +internationalizer pseudo # accented en-XA +internationalizer pseudo --strategy bidi # bidi ar-XB +internationalizer pseudo --dry-run # show planned artifacts only +internationalizer pseudo --locale qps-ploc # choose another valid locale tag +``` + +The generator refreshes only artifacts it previously recorded as pseudo +output. Use `--force` to replace any other existing target deliberately. + +### `review` + +Inspect and approve the exact target content currently bound to its source and +translation policy. Generated, cached, and adopted values all begin in +`needs_review`; approval is invalidated by later source, policy, or target +changes. Pseudolocales are tracked separately as test artifacts. + +```bash +internationalizer review list --status needs_review +internationalizer review approve --locale fr --bundle app --key common.save +internationalizer review approve --locale fr --all +``` ### `validate` @@ -148,6 +183,7 @@ 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 +internationalizer validate --require-approved # also require explicit approval ``` `--strict` also reports translated coverage. A linguistic value identical to @@ -159,7 +195,9 @@ 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`. +or target hash is stale. `--require-approved` implies `--require-state` and also +fails if the exact current artifact has not been approved. Both can be combined +with `--strict`. Human and JSON reports use stable finding codes: @@ -178,6 +216,7 @@ Human and JSON reports use stable finding codes: | `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 | +| `needs_review` | Current provenance exists, but the exact target is not approved | ### `detect` @@ -231,6 +270,10 @@ bundles: source: README.md target: docs/i18n/{locale}.md format: markdown + - id: browser + source: browser/locales/en-US/browser.ftl + target: browser/locales/{locale}/browser.ftl + format: fluent # Backward compatibility: source_path still maps targets to sibling files # such as locales/fr.json. Prefer bundles for new projects. @@ -313,6 +356,14 @@ branch identity, and target-locale CLDR plural categories. Provider output that breaks these invariants is rejected before a locale file or translation-memory record is written. +Fluent (`.ftl`) resources are handled as semantic source documents rather than +flattened maps. Message values, terms, and attributes become independent units; +comments are passed to the provider as developer context and included in source +provenance. Serialization preserves resource comments and ordering. Validation +protects variables, references, functions, selector defaults and branches, and +`data-l10n-name` markup slots while allowing target-locale selector variants and +natural reordering of named rich-text elements. + 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 @@ -386,6 +437,8 @@ from the cache without calling the LLM. The default path is under the ignored `.internationalizer/` directory, so it remains a local cache. Set `tm_path` to a tracked location if your project intentionally shares translation memory. The reviewable `.internationalizer.lock` manifest is versioned separately. +Manifest schema v2 records provenance origin and review status independently, +so “generated successfully” and “approved by a person” cannot be conflated. ## Supported Formats @@ -394,6 +447,7 @@ reviewable `.internationalizer.lock` manifest is versioned separately. | JSON | `.json` | Key-value (nested, dot-notation flattened) | | YAML | `.yml`, `.yaml` | Key-value (preserves comments and ordering) | | Markdown | `.md`, `.mdx` | Preamble and H2-level sections | +| Fluent | `.ftl` | Semantic messages, terms, attributes, comments, and selectors | Markdown targets contain invisible `internationalizer:unit` comments before H2 sections. These stable markers let Internationalizer add, move, or edit one @@ -415,7 +469,8 @@ cmd/internationalizer/ CLI entry point and command definitions internal/ config/ YAML config loading with defaults detect/ Project type auto-detection - formats/ Format parsers (JSON, YAML, Markdown) + fluentpattern/ Fluent pattern validation and safe text transforms + formats/ Format adapters (JSON, YAML, Markdown, Fluent) glossary/ Per-locale glossary management llm/ LLM provider interface + implementations anthropic.go Anthropic Claude backend @@ -425,6 +480,8 @@ internal/ locale/ BCP 47 identity and CLDR plural categories message/ ICU MessageFormat parser and structural comparison policy/ Stable translation-policy hashing + pseudo/ Provider-free accented and bidi test locales + review/ Explicit artifact approval workflow state/ Versioned translation manifest styleguide/ Style guide loader tm/ JSONL translation memory diff --git a/cmd/internationalizer/main.go b/cmd/internationalizer/main.go index 21ab076..b9ac8da 100644 --- a/cmd/internationalizer/main.go +++ b/cmd/internationalizer/main.go @@ -26,6 +26,8 @@ func main() { newGlossaryCmd(), newTmCmd(), newValidateCmd(), + newReviewCmd(), + newPseudoCmd(), ) if err := rootCmd.Execute(); err != nil { diff --git a/cmd/internationalizer/pseudo.go b/cmd/internationalizer/pseudo.go new file mode 100644 index 0000000..0a6c847 --- /dev/null +++ b/cmd/internationalizer/pseudo.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + + "github.com/Tom-R-Main/Internationalizer/internal/config" + "github.com/Tom-R-Main/Internationalizer/internal/pseudo" + "github.com/spf13/cobra" +) + +func newPseudoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pseudo", + Short: "Generate deterministic accented or bidi pseudolocales", + RunE: func(cmd *cobra.Command, args []string) error { + cfgPath, _ := cmd.Flags().GetString("config") + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + strategyValue, _ := cmd.Flags().GetString("strategy") + locale, _ := cmd.Flags().GetString("locale") + force, _ := cmd.Flags().GetBool("force") + dryRun, _ := cmd.Flags().GetBool("dry-run") + results, err := pseudo.Generate(cfg, pseudo.GenerateOptions{ + Strategy: pseudo.Strategy(strategyValue), + Locale: locale, + Force: force, + DryRun: dryRun, + }) + if err != nil { + return err + } + for _, result := range results { + action := "generated" + if dryRun { + action = "would generate" + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s/%s: %d units -> %s\n", action, result.Bundle, result.Locale, result.Units, result.TargetPath); err != nil { + return err + } + } + return nil + }, + } + cmd.Flags().StringP("config", "c", "", "path to config file (default: .internationalizer.yml)") + cmd.Flags().String("strategy", string(pseudo.Accented), "pseudo strategy (accented or bidi)") + cmd.Flags().StringP("locale", "l", "", "output locale (defaults to en-XA or ar-XB)") + cmd.Flags().Bool("force", false, "overwrite an existing artifact not owned by the pseudo generator") + cmd.Flags().Bool("dry-run", false, "show outputs without writing files or manifest state") + return cmd +} diff --git a/cmd/internationalizer/review.go b/cmd/internationalizer/review.go new file mode 100644 index 0000000..013db03 --- /dev/null +++ b/cmd/internationalizer/review.go @@ -0,0 +1,100 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/Tom-R-Main/Internationalizer/internal/config" + "github.com/Tom-R-Main/Internationalizer/internal/review" + "github.com/Tom-R-Main/Internationalizer/internal/state" + "github.com/spf13/cobra" +) + +func newReviewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "review", + Short: "Inspect and approve exact translation artifacts", + } + cmd.AddCommand(newReviewListCmd(), newReviewApproveCmd()) + return cmd +} + +func newReviewListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List tracked translations and review status", + RunE: func(cmd *cobra.Command, args []string) error { + cfgPath, _ := cmd.Flags().GetString("config") + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + manifest, err := state.Load(cfg.ManifestPath) + if err != nil { + return err + } + locale, _ := cmd.Flags().GetString("locale") + bundle, _ := cmd.Flags().GetString("bundle") + statusValue, _ := cmd.Flags().GetString("status") + entries, err := review.List(manifest, review.Filter{Locale: locale, Bundle: bundle, Status: state.ReviewStatus(statusValue)}) + if err != nil { + return err + } + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(entries) + } + for _, entry := range entries { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\t%s\t%s\n", entry.ReviewStatus, entry.Locale, entry.Bundle, entry.Key, entry.Origin); err != nil { + return err + } + } + return nil + }, + } + addReviewConfigFlag(cmd) + cmd.Flags().StringP("locale", "l", "", "filter by target locale") + cmd.Flags().String("bundle", "", "filter by bundle ID") + cmd.Flags().String("status", "", "filter by review status (needs_review or approved)") + cmd.Flags().Bool("json", false, "output entries as JSON") + return cmd +} + +func newReviewApproveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "approve", + Short: "Approve current translations after validation", + RunE: func(cmd *cobra.Command, args []string) error { + cfgPath, _ := cmd.Flags().GetString("config") + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + locale, _ := cmd.Flags().GetString("locale") + bundle, _ := cmd.Flags().GetString("bundle") + keys, _ := cmd.Flags().GetStringSlice("key") + all, _ := cmd.Flags().GetBool("all") + approved, err := review.Approve(cfg, review.Filter{Locale: locale, Bundle: bundle, Keys: keys, All: all}, time.Now()) + if err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Approved %d translation(s) for %s.\n", len(approved), locale) + return err + }, + } + addReviewConfigFlag(cmd) + cmd.Flags().StringP("locale", "l", "", "target locale to approve") + cmd.Flags().String("bundle", "", "bundle ID (required with --key)") + cmd.Flags().StringSlice("key", nil, "individual translation key(s) to approve") + cmd.Flags().Bool("all", false, "approve every matching current translation") + _ = cmd.MarkFlagRequired("locale") + cmd.MarkFlagsMutuallyExclusive("key", "all") + return cmd +} + +func addReviewConfigFlag(cmd *cobra.Command) { + cmd.Flags().StringP("config", "c", "", "path to config file (default: .internationalizer.yml)") +} diff --git a/cmd/internationalizer/validate.go b/cmd/internationalizer/validate.go index b3e9987..d22d04d 100644 --- a/cmd/internationalizer/validate.go +++ b/cmd/internationalizer/validate.go @@ -20,7 +20,8 @@ func newValidateCmd() *cobra.Command { 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.`, +that source, policy, and target content still match the translation manifest. +Use --require-approved to additionally require explicit human approval.`, RunE: func(cmd *cobra.Command, args []string) error { cfgPath, _ := cmd.Flags().GetString("config") cfg, err := config.Load(cfgPath) @@ -30,9 +31,11 @@ that source, policy, and target content still match the translation manifest.`, strict, _ := cmd.Flags().GetBool("strict") requireState, _ := cmd.Flags().GetBool("require-state") + requireApproved, _ := cmd.Flags().GetBool("require-approved") reports, err := validate.ValidateWithOptions(cfg, validate.Options{ - Strict: strict, - RequireState: requireState, + Strict: strict, + RequireState: requireState, + RequireApproved: requireApproved, }) if err != nil { return err @@ -65,6 +68,7 @@ that source, policy, and target content still match the translation manifest.`, 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") + cmd.Flags().Bool("require-approved", false, "fail unless current translations have explicit human approval") return cmd } diff --git a/docs/localization-core-v2.md b/docs/localization-core-v2.md index 1a3a816..848c7dc 100644 --- a/docs/localization-core-v2.md +++ b/docs/localization-core-v2.md @@ -31,16 +31,32 @@ framework runtime, translation CDN, or source-rewriting setup command. ## Review transfer -Only an exact source-and-policy match may inherit review state. Fuzzy -candidates, changed policy, or changed message structure require review. +Manifest v2 records provenance origin and human review independently. Provider, +translation-memory, adoption, and pseudo output begin in `needs_review`; only an +exact current source, policy, and target artifact may be approved. Changed +source context, policy, message structure, or target content requires review. ## Source units -Structured documents and source adapters identify a unit by adapter, -normalized project-relative path, semantic location, and source structure. -Formatting offsets alone are not stable identity. Markdown uses the preamble -and each H2 section as independent units, with invisible target-side markers to -preserve identity when sections move or are inserted. +Structured documents pass through a format-neutral semantic-unit boundary. +Each unit has a stable semantic ID, kind, translator context, value, and +adapter-owned structure signature; formatting offsets are not identity. Fluent +resources use this boundary for message values, terms, and attributes while +preserving comments and ordering during serialization. + +Markdown uses the preamble and each H2 section as independent units, with +invisible target-side markers that preserve identity when sections move or are +inserted. + +## Test locales and rich text + +- `pseudo` creates deterministic accented (`en-XA`) and bidirectional (`ar-XB`) + artifacts without a provider or translation-memory lookup. +- ICU and Fluent runtime expressions remain intact while linguistic text is + transformed. +- `data-l10n-name` identifies semantic rich-text slots. Translators may reorder + named slots, but element identity, protected attributes, nesting, and + contained markup must remain compatible. ## Policy identity @@ -54,6 +70,6 @@ are never generated or rewritten by a translation run. ## Compatibility boundary Existing JSON/YAML key workflows and i18next-v4 plural suffixes remain -supported. Canonical locale comparison may reject configurations that currently -spell the same locale more than once. ICU structural failures are deterministic +supported. Canonical locale comparison may reject configurations that spell the +same locale more than once. ICU or Fluent structural failures are deterministic validation errors and block provider output from being written. diff --git a/internal/fluentpattern/pattern.go b/internal/fluentpattern/pattern.go new file mode 100644 index 0000000..ad46dc3 --- /dev/null +++ b/internal/fluentpattern/pattern.go @@ -0,0 +1,478 @@ +// Package fluentpattern validates and compares runtime-significant Fluent +// pattern structure without owning resource-file serialization. +package fluentpattern + +import ( + "fmt" + "sort" + "strings" + "unicode" +) + +// Analysis is the protected structure of one Fluent pattern. +type Analysis struct { + Expressions []string + Selectors []Selector +} + +// Selector is one Fluent select expression and its locale variants. +type Selector struct { + Head string + DefaultKey string + Variants map[string]Analysis +} + +// LooksLike reports whether text contains syntax specific to Fluent rather +// than a plain ICU-style argument. +func LooksLike(value string) bool { + for offset := 0; offset < len(value); { + start := strings.IndexByte(value[offset:], '{') + if start < 0 { + return false + } + start += offset + end, err := matchingBrace(value, start) + if err != nil { + return true + } + expression := strings.TrimSpace(value[start+1 : end]) + if strings.Contains(expression, "->") || strings.ContainsAny(expression, "$.") || strings.HasPrefix(expression, "-") || strings.HasPrefix(expression, "\"") || strings.HasPrefix(expression, "'") { + return true + } + offset = end + 1 + } + return false +} + +// Analyze validates braces and select-expression structure and returns a +// canonical representation of all non-linguistic expressions. +func Analyze(value string) (Analysis, error) { + analysis := Analysis{} + for offset := 0; offset < len(value); { + relative := strings.IndexAny(value[offset:], "{}") + if relative < 0 { + break + } + start := offset + relative + if value[start] == '}' { + return Analysis{}, fmt.Errorf("unexpected closing brace at byte %d", start) + } + end, err := matchingBrace(value, start) + if err != nil { + return Analysis{}, err + } + expression := strings.TrimSpace(value[start+1 : end]) + if expression == "" { + return Analysis{}, fmt.Errorf("empty Fluent placeable at byte %d", start) + } + arrow := topLevelArrow(expression) + if arrow < 0 { + analysis.Expressions = append(analysis.Expressions, canonicalExpression(expression)) + } else { + selector, err := parseSelector(expression[:arrow], expression[arrow+2:]) + if err != nil { + return Analysis{}, fmt.Errorf("fluent selector at byte %d: %w", start, err) + } + analysis.Selectors = append(analysis.Selectors, selector) + } + offset = end + 1 + } + sort.Strings(analysis.Expressions) + sort.Slice(analysis.Selectors, func(left, right int) bool { + return selectorSignature(analysis.Selectors[left]) < selectorSignature(analysis.Selectors[right]) + }) + return analysis, nil +} + +// Compare checks that target preserves source expressions and selectors. +// Target-only locale variants are allowed when they preserve the source +// selector's default-branch structure. +func Compare(source, target string) (expected, actual []string, preserved bool, err error) { + sourceAnalysis, err := Analyze(source) + if err != nil { + return nil, nil, false, fmt.Errorf("source pattern: %w", err) + } + targetAnalysis, err := Analyze(target) + if err != nil { + return sourceAnalysis.Signatures(), nil, false, fmt.Errorf("target pattern: %w", err) + } + return sourceAnalysis.Signatures(), targetAnalysis.Signatures(), compareAnalysis(sourceAnalysis, targetAnalysis), nil +} + +// TransformText applies transform to linguistic text while retaining Fluent +// placeables, selector heads, variant keys, and default markers byte-for-byte. +func TransformText(value string, transform func(string) string) (string, error) { + var result strings.Builder + offset := 0 + for offset < len(value) { + relative := strings.IndexAny(value[offset:], "{}") + if relative < 0 { + result.WriteString(transform(value[offset:])) + break + } + start := offset + relative + if value[start] == '}' { + return "", fmt.Errorf("unexpected closing brace at byte %d", start) + } + result.WriteString(transform(value[offset:start])) + end, err := matchingBrace(value, start) + if err != nil { + return "", err + } + expression := value[start+1 : end] + arrow := topLevelArrow(expression) + if arrow < 0 { + result.WriteString(value[start : end+1]) + } else { + body := expression[arrow+2:] + headers, err := variantHeaders(body) + if err != nil || len(headers) == 0 { + if err == nil { + err = fmt.Errorf("selector has no variants") + } + return "", err + } + result.WriteByte('{') + result.WriteString(expression[:arrow+2]) + result.WriteString(body[:headers[0].end]) + for index, header := range headers { + branchEnd := len(body) + if index+1 < len(headers) { + branchEnd = headers[index+1].start + } + transformed, err := TransformText(body[header.end:branchEnd], transform) + if err != nil { + return "", err + } + result.WriteString(transformed) + if index+1 < len(headers) { + result.WriteString(body[headers[index+1].start:headers[index+1].end]) + } + } + result.WriteByte('}') + } + offset = end + 1 + } + return result.String(), nil +} + +// Signatures returns a deterministic diagnostic view of an analysis. +func (analysis Analysis) Signatures() []string { + result := make([]string, 0, len(analysis.Expressions)+len(analysis.Selectors)) + for _, expression := range analysis.Expressions { + result = append(result, "expression:"+expression) + } + for _, selector := range analysis.Selectors { + result = append(result, selectorSignature(selector)) + } + sort.Strings(result) + return result +} + +func compareAnalysis(source, target Analysis) bool { + if !equalList(source.Expressions, target.Expressions) || len(source.Selectors) != len(target.Selectors) { + return false + } + used := make([]bool, len(target.Selectors)) + for _, sourceSelector := range source.Selectors { + matched := false + for index, targetSelector := range target.Selectors { + if used[index] || sourceSelector.Head != targetSelector.Head || !compareSelector(sourceSelector, targetSelector) { + continue + } + used[index] = true + matched = true + break + } + if !matched { + return false + } + } + return true +} + +func compareSelector(source, target Selector) bool { + if source.DefaultKey != target.DefaultKey { + return false + } + fallback, ok := source.Variants[source.DefaultKey] + if !ok { + return false + } + for key, sourceBranch := range source.Variants { + targetBranch, exists := target.Variants[key] + if !exists || !compareAnalysis(sourceBranch, targetBranch) { + return false + } + } + for key, targetBranch := range target.Variants { + if _, exists := source.Variants[key]; exists { + continue + } + if !compareAnalysis(fallback, targetBranch) { + return false + } + } + return true +} + +func parseSelector(head, body string) (Selector, error) { + head = canonicalExpression(strings.TrimSpace(head)) + if head == "" { + return Selector{}, fmt.Errorf("selector expression is empty") + } + headers, err := variantHeaders(body) + if err != nil { + return Selector{}, err + } + if len(headers) == 0 { + return Selector{}, fmt.Errorf("selector has no variants") + } + selector := Selector{Head: head, Variants: make(map[string]Analysis)} + for index, header := range headers { + end := len(body) + if index+1 < len(headers) { + end = headers[index+1].start + } + branch, err := Analyze(body[header.end:end]) + if err != nil { + return Selector{}, fmt.Errorf("variant %q: %w", header.key, err) + } + if _, duplicate := selector.Variants[header.key]; duplicate { + return Selector{}, fmt.Errorf("duplicate variant %q", header.key) + } + selector.Variants[header.key] = branch + if header.defaultVariant { + if selector.DefaultKey != "" { + return Selector{}, fmt.Errorf("selector has multiple default variants") + } + selector.DefaultKey = header.key + } + } + if selector.DefaultKey == "" { + return Selector{}, fmt.Errorf("selector has no default variant") + } + return selector, nil +} + +type variantHeader struct { + start int + end int + key string + defaultVariant bool +} + +func variantHeaders(body string) ([]variantHeader, error) { + var headers []variantHeader + depth := 0 + var quote byte + escaped := false + lineStart := true + for index := 0; index < len(body); index++ { + character := body[index] + if quote != 0 { + if escaped { + escaped = false + continue + } + switch character { + case '\\': + escaped = true + case quote: + quote = 0 + } + continue + } + if character == '\'' || character == '"' { + quote = character + continue + } + switch character { + case '{': + depth++ + case '}': + if depth == 0 { + return nil, fmt.Errorf("unexpected closing brace in selector") + } + depth-- + case '\n', '\r': + lineStart = true + continue + } + if depth != 0 { + continue + } + if lineStart && (character == ' ' || character == '\t') { + continue + } + if !lineStart { + continue + } + defaultVariant := false + start := index + if character == '*' { + defaultVariant = true + index++ + if index >= len(body) { + return nil, fmt.Errorf("incomplete default variant") + } + character = body[index] + } + if character != '[' { + lineStart = false + continue + } + close := strings.IndexByte(body[index+1:], ']') + if close < 0 { + return nil, fmt.Errorf("unterminated variant key") + } + close += index + 1 + key := strings.TrimSpace(body[index+1 : close]) + if key == "" || strings.ContainsAny(key, "[]{}") { + return nil, fmt.Errorf("invalid variant key %q", key) + } + headers = append(headers, variantHeader{start: start, end: close + 1, key: key, defaultVariant: defaultVariant}) + index = close + lineStart = false + } + if depth != 0 || quote != 0 { + return nil, fmt.Errorf("unterminated selector expression") + } + if len(headers) > 0 && strings.TrimSpace(body[:headers[0].start]) != "" { + return nil, fmt.Errorf("content appears before the first variant") + } + return headers, nil +} + +func matchingBrace(value string, start int) (int, error) { + depth := 0 + var quote byte + escaped := false + for index := start; index < len(value); index++ { + character := value[index] + if quote != 0 { + if escaped { + escaped = false + continue + } + switch character { + case '\\': + escaped = true + case quote: + quote = 0 + } + continue + } + if character == '\'' || character == '"' { + quote = character + continue + } + switch character { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return index, nil + } + } + } + return 0, fmt.Errorf("unterminated Fluent placeable at byte %d", start) +} + +func topLevelArrow(expression string) int { + depth := 0 + var quote byte + escaped := false + for index := 0; index+1 < len(expression); index++ { + character := expression[index] + if quote != 0 { + if escaped { + escaped = false + } else if character == '\\' { + escaped = true + } else if character == quote { + quote = 0 + } + continue + } + if character == '\'' || character == '"' { + quote = character + continue + } + switch character { + case '{', '(': + depth++ + case '}', ')': + if depth > 0 { + depth-- + } + case '-': + if depth == 0 && expression[index+1] == '>' { + return index + } + } + } + return -1 +} + +func canonicalExpression(expression string) string { + var result strings.Builder + var quote rune + escaped := false + for _, character := range strings.TrimSpace(expression) { + if quote != 0 { + result.WriteRune(character) + if escaped { + escaped = false + } else if character == '\\' { + escaped = true + } else if character == quote { + quote = 0 + } + continue + } + if character == '\'' || character == '"' { + quote = character + result.WriteRune(character) + continue + } + if !unicode.IsSpace(character) { + result.WriteRune(character) + } + } + return result.String() +} + +func selectorSignature(selector Selector) string { + keys := make([]string, 0, len(selector.Variants)) + for key := range selector.Variants { + keys = append(keys, key) + } + sort.Strings(keys) + var result strings.Builder + result.WriteString("selector:") + result.WriteString(selector.Head) + result.WriteString(":default=") + result.WriteString(selector.DefaultKey) + for _, key := range keys { + result.WriteString(":variant=") + result.WriteString(key) + result.WriteByte('[') + result.WriteString(strings.Join(selector.Variants[key].Signatures(), ",")) + result.WriteByte(']') + } + return result.String() +} + +func equalList(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/fluentpattern/pattern_test.go b/internal/fluentpattern/pattern_test.go new file mode 100644 index 0000000..b78d4a5 --- /dev/null +++ b/internal/fluentpattern/pattern_test.go @@ -0,0 +1,88 @@ +package fluentpattern + +import ( + "strings" + "testing" +) + +func TestCompareAllowsLocaleSpecificVariantsWithDefaultStructure(t *testing.T) { + source := `{ $count -> + [one] One item for { $name } + *[other] { $count } items for { $name } +}` + target := `{ $count -> + [one] Один элемент для { $name } + [few] { $count } элемента для { $name } + [many] { $count } элементов для { $name } + *[other] { $count } элемента для { $name } +}` + _, _, preserved, err := Compare(source, target) + if err != nil || !preserved { + t.Fatalf("Compare() = preserved %v, error %v", preserved, err) + } +} + +func TestCompareRejectsDamagedFluentRuntimeStructure(t *testing.T) { + source := `{ PLATFORM() -> + [windows] Open { -brand-short-name } for { $user } + *[other] Open { -brand-short-name } for { $user } +}` + tests := map[string]string{ + "selector": `{ $platform -> + [windows] Ouvrir { -brand-short-name } pour { $user } + *[other] Ouvrir { -brand-short-name } pour { $user } +}`, + "reference": `{ PLATFORM() -> + [windows] Ouvrir { -brand-full-name } pour { $user } + *[other] Ouvrir { -brand-short-name } pour { $user } +}`, + "variable": `{ PLATFORM() -> + [windows] Ouvrir { -brand-short-name } pour { $account } + *[other] Ouvrir { -brand-short-name } pour { $user } +}`, + "default": `{ PLATFORM() -> + *[windows] Ouvrir { -brand-short-name } pour { $user } + [other] Ouvrir { -brand-short-name } pour { $user } +}`, + } + for name, target := range tests { + t.Run(name, func(t *testing.T) { + _, _, preserved, err := Compare(source, target) + if err == nil && preserved { + t.Fatal("damaged Fluent structure was accepted") + } + }) + } +} + +func TestAnalyzeRejectsMalformedPatterns(t *testing.T) { + for _, pattern := range []string{ + `Hello { $name`, + `Hello }`, + `{ $count -> [one] One }`, + "{ $count ->\n *[one] One\n *[other] Other\n}", + } { + if _, err := Analyze(pattern); err == nil { + t.Fatalf("Analyze(%q) accepted malformed pattern", pattern) + } + } +} + +func TestTransformTextPreservesFluentSyntaxInsideSelectors(t *testing.T) { + input := `{ $count -> + [one] One item for { -brand-short-name } + *[other] { $count } items for { $user } +}` + output, err := TransformText(input, strings.ToUpper) + if err != nil { + t.Fatal(err) + } + for _, syntax := range []string{"{ $count ->", "[one]", "*[other]", "{ -brand-short-name }", "{ $count }", "{ $user }"} { + if !strings.Contains(output, syntax) { + t.Fatalf("transformed pattern lost %q: %s", syntax, output) + } + } + if !strings.Contains(output, "ONE ITEM FOR") || !strings.Contains(output, "ITEMS FOR ") { + t.Fatalf("literal text was not transformed: %s", output) + } +} diff --git a/internal/formats/fluent.go b/internal/formats/fluent.go new file mode 100644 index 0000000..77b49aa --- /dev/null +++ b/internal/formats/fluent.go @@ -0,0 +1,496 @@ +package formats + +import ( + "bytes" + "fmt" + "sort" + "strings" + "unicode" + + "github.com/Tom-R-Main/Internationalizer/internal/fluentpattern" +) + +// FluentFormat is a lossless adapter for Fluent Translation List resources. +// It exposes message values, terms, and attributes as independent semantic +// units while retaining comments and resource ordering in the source file. +type FluentFormat struct{} + +func (f *FluentFormat) Name() string { return "fluent" } +func (f *FluentFormat) Extensions() []string { return []string{".ftl"} } + +func (f *FluentFormat) Parse(data []byte) (map[string]string, error) { + units, err := f.ParseUnits(data) + if err != nil { + return nil, err + } + return UnitValues(units), nil +} + +func (f *FluentFormat) Serialize(entries map[string]string, original []byte) ([]byte, error) { + units := make([]Unit, 0, len(entries)) + keys := make([]string, 0, len(entries)) + for key := range entries { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + kind := UnitMessage + if strings.HasPrefix(key, "-") { + kind = UnitTerm + } + if strings.Contains(key, ".") { + kind = UnitAttribute + } + units = append(units, Unit{ID: key, Value: entries[key], Kind: kind}) + } + return f.SerializeUnits(units, original) +} + +func (f *FluentFormat) ParseUnits(data []byte) ([]Unit, error) { + document, err := parseFluentDocument(data) + if err != nil { + return nil, err + } + units := make([]Unit, 0, len(document.assignments)) + for _, assignment := range document.assignments { + if assignment.value == "" { + continue + } + if _, err := fluentpattern.Analyze(assignment.value); err != nil { + return nil, fmt.Errorf("fluent parse: pattern %q: %w", assignment.id, err) + } + units = append(units, Unit{ + ID: assignment.id, + Value: assignment.value, + Kind: assignment.kind, + Context: assignment.context, + Structure: "fluent-pattern-v1", + }) + } + return units, nil +} + +func (f *FluentFormat) SerializeUnits(units []Unit, original []byte) ([]byte, error) { + if err := ValidateUnits(units); err != nil { + return nil, err + } + document, err := parseFluentDocument(original) + if err != nil { + return nil, err + } + + edits := make([]fluentEdit, 0) + missing := make([]Unit, 0) + for _, unit := range units { + assignment, ok := document.byID[unit.ID] + if !ok { + missing = append(missing, unit) + continue + } + if assignment.value == unit.Value { + continue + } + edits = append(edits, fluentEdit{ + start: assignment.start, + end: assignment.end, + text: renderFluentAssignment(assignment, unit.Value, document.newline), + }) + } + + entryInsertions := make(map[string][]Unit) + newEntryOrder := make([]string, 0) + newEntries := make(map[string][]Unit) + for _, unit := range missing { + parent, _, attribute := fluentUnitIdentity(unit) + if attribute { + if _, exists := document.entries[parent]; exists { + entryInsertions[parent] = append(entryInsertions[parent], unit) + continue + } + } else if entry, exists := document.entries[parent]; exists { + // The resource has an attribute-only message. Its empty value + // assignment is addressable for serialization but not translation. + assignment := entry.main + edits = append(edits, fluentEdit{ + start: assignment.start, + end: assignment.end, + text: renderFluentAssignment(assignment, unit.Value, document.newline), + }) + continue + } + if _, exists := newEntries[parent]; !exists { + newEntryOrder = append(newEntryOrder, parent) + } + newEntries[parent] = append(newEntries[parent], unit) + } + + for parent, inserted := range entryInsertions { + entry := document.entries[parent] + var text strings.Builder + if entry.end > 0 && original[entry.end-1] != '\n' { + text.WriteString(document.newline) + } + for _, unit := range inserted { + _, attribute, _ := fluentUnitIdentity(unit) + text.WriteString(renderNewFluentAssignment(entry.attributeIndent+"."+attribute+" =", unit.Value, entry.attributeIndent+" ", document.newline)) + } + edits = append(edits, fluentEdit{start: entry.end, end: entry.end, text: text.String()}) + } + + if len(newEntryOrder) > 0 { + var appended strings.Builder + if len(original) > 0 { + if original[len(original)-1] != '\n' { + appended.WriteString(document.newline) + } + if !bytes.HasSuffix(original, []byte(document.newline+document.newline)) { + appended.WriteString(document.newline) + } + } + for index, parent := range newEntryOrder { + if index > 0 { + appended.WriteString(document.newline) + } + group := newEntries[parent] + context := "" + for _, unit := range group { + if unit.Context != "" { + context = unit.Context + break + } + } + if context != "" { + appended.WriteString(strings.TrimRight(context, "\r\n")) + appended.WriteString(document.newline) + } + var main *Unit + attributes := make([]Unit, 0) + for unitIndex := range group { + _, _, attribute := fluentUnitIdentity(group[unitIndex]) + if attribute { + attributes = append(attributes, group[unitIndex]) + } else { + main = &group[unitIndex] + } + } + if main != nil { + appended.WriteString(renderNewFluentAssignment(parent+" =", main.Value, " ", document.newline)) + } else { + appended.WriteString(parent + " =" + document.newline) + } + for _, attributeUnit := range attributes { + _, attribute, _ := fluentUnitIdentity(attributeUnit) + appended.WriteString(renderNewFluentAssignment(" ."+attribute+" =", attributeUnit.Value, " ", document.newline)) + } + } + edits = append(edits, fluentEdit{start: len(original), end: len(original), text: appended.String()}) + } + + sort.SliceStable(edits, func(left, right int) bool { + if edits[left].start != edits[right].start { + return edits[left].start > edits[right].start + } + return edits[left].end > edits[right].end + }) + result := append([]byte(nil), original...) + for _, edit := range edits { + result = append(result[:edit.start], append([]byte(edit.text), result[edit.end:]...)...) + } + return result, nil +} + +type fluentDocument struct { + assignments []fluentAssignment + byID map[string]fluentAssignment + entries map[string]fluentEntry + newline string +} + +type fluentEntry struct { + id string + end int + attributeIndent string + main fluentAssignment +} + +type fluentAssignment struct { + id string + parent string + attribute string + kind UnitKind + context string + value string + header string + indent string + start int + end int +} + +type fluentLine struct { + start int + end int + content string +} + +type fluentEdit struct { + start int + end int + text string +} + +func parseFluentDocument(data []byte) (fluentDocument, error) { + document := fluentDocument{ + byID: make(map[string]fluentAssignment), + entries: make(map[string]fluentEntry), + newline: "\n", + } + if bytes.Contains(data, []byte("\r\n")) { + document.newline = "\r\n" + } + lines := splitFluentLines(data) + for index := 0; index < len(lines); { + parent, equals, ok := parseFluentMessageHeader(lines[index].content) + if !ok { + index++ + continue + } + if _, duplicate := document.entries[parent]; duplicate { + return fluentDocument{}, fmt.Errorf("fluent parse: duplicate message or term %q", parent) + } + entryEndLine := index + 1 + for entryEndLine < len(lines) && isFluentContinuation(lines[entryEndLine].content) { + entryEndLine++ + } + context := fluentCommentContext(lines, index, document.newline) + attributeStarts := make([]int, 0) + for candidate := index + 1; candidate < entryEndLine; candidate++ { + if _, _, _, attribute := parseFluentAttributeHeader(lines[candidate].content); attribute { + attributeStarts = append(attributeStarts, candidate) + } + } + boundaries := append([]int{index}, attributeStarts...) + boundaries = append(boundaries, entryEndLine) + entry := fluentEntry{id: parent, end: lines[entryEndLine-1].end, attributeIndent: " "} + for boundary := 0; boundary < len(boundaries)-1; boundary++ { + startLine := boundaries[boundary] + endLine := boundaries[boundary+1] + assignmentParent := parent + attribute := "" + assignmentEquals := equals + kind := UnitMessage + if startLine != index { + indent, name, attrEquals, _ := parseFluentAttributeHeader(lines[startLine].content) + attribute = name + assignmentEquals = attrEquals + entry.attributeIndent = indent + kind = UnitAttribute + } + if strings.HasPrefix(parent, "-") && attribute == "" { + kind = UnitTerm + } + id := parent + if attribute != "" { + id += "." + attribute + } + if _, duplicate := document.byID[id]; duplicate { + return fluentDocument{}, fmt.Errorf("fluent parse: duplicate translation unit %q", id) + } + assignment := buildFluentAssignment(lines, startLine, endLine, assignmentEquals, id, assignmentParent, attribute, kind, context) + document.assignments = append(document.assignments, assignment) + document.byID[id] = assignment + if attribute == "" { + entry.main = assignment + } + } + document.entries[parent] = entry + index = entryEndLine + } + return document, nil +} + +func buildFluentAssignment(lines []fluentLine, startLine, endLine, equals int, id, parent, attribute string, kind UnitKind, context string) fluentAssignment { + line := lines[startLine] + header := line.content[:equals+1] + first := strings.TrimLeftFunc(line.content[equals+1:], unicode.IsSpace) + continuations := make([]string, 0, endLine-startLine-1) + minimumIndent := -1 + for index := startLine + 1; index < endLine; index++ { + content := lines[index].content + if strings.TrimSpace(content) == "" { + continuations = append(continuations, "") + continue + } + indent := leadingWhitespaceLength(content) + if indent > 0 && (minimumIndent < 0 || indent < minimumIndent) { + minimumIndent = indent + } + continuations = append(continuations, content) + } + if minimumIndent < 0 { + minimumIndent = 4 + if attribute != "" { + minimumIndent = leadingWhitespaceLength(line.content) + 4 + } + } + for index, continuation := range continuations { + if leadingWhitespaceLength(continuation) >= minimumIndent { + continuations[index] = continuation[minimumIndent:] + } + } + parts := continuations + if first != "" { + parts = append([]string{first}, continuations...) + } + value := strings.TrimRight(strings.Join(parts, "\n"), "\n") + indent := strings.Repeat(" ", minimumIndent) + if startLine+1 < endLine { + candidate := lines[startLine+1].content + if len(candidate) >= minimumIndent { + indent = candidate[:minimumIndent] + } + } + return fluentAssignment{ + id: id, parent: parent, attribute: attribute, kind: kind, context: context, + value: value, header: header, indent: indent, start: line.start, + end: lines[endLine-1].end, + } +} + +func renderFluentAssignment(assignment fluentAssignment, value, newline string) string { + return renderNewFluentAssignment(assignment.header, value, assignment.indent, newline) +} + +func renderNewFluentAssignment(header, value, indent, newline string) string { + if !strings.Contains(value, "\n") && value != "" { + return header + " " + value + newline + } + var result strings.Builder + result.WriteString(header) + result.WriteString(newline) + for _, line := range strings.Split(value, "\n") { + result.WriteString(indent) + result.WriteString(line) + result.WriteString(newline) + } + return result.String() +} + +func splitFluentLines(data []byte) []fluentLine { + if len(data) == 0 { + return nil + } + lines := make([]fluentLine, 0, bytes.Count(data, []byte{'\n'})+1) + for start := 0; start < len(data); { + end := bytes.IndexByte(data[start:], '\n') + if end < 0 { + end = len(data) + } else { + end += start + 1 + } + contentEnd := end + if contentEnd > start && data[contentEnd-1] == '\n' { + contentEnd-- + } + if contentEnd > start && data[contentEnd-1] == '\r' { + contentEnd-- + } + lines = append(lines, fluentLine{start: start, end: end, content: string(data[start:contentEnd])}) + start = end + } + return lines +} + +func parseFluentMessageHeader(line string) (string, int, bool) { + if line == "" || unicode.IsSpace(rune(line[0])) { + return "", 0, false + } + equals := strings.IndexByte(line, '=') + if equals < 0 { + return "", 0, false + } + name := strings.TrimSpace(line[:equals]) + if !validFluentIdentifier(name, true) { + return "", 0, false + } + return name, equals, true +} + +func parseFluentAttributeHeader(line string) (string, string, int, bool) { + indentLength := leadingWhitespaceLength(line) + if indentLength == 0 || indentLength >= len(line) || line[indentLength] != '.' { + return "", "", 0, false + } + equals := strings.IndexByte(line[indentLength+1:], '=') + if equals < 0 { + return "", "", 0, false + } + equals += indentLength + 1 + name := strings.TrimSpace(line[indentLength+1 : equals]) + if !validFluentIdentifier(name, false) { + return "", "", 0, false + } + return line[:indentLength], name, equals, true +} + +func validFluentIdentifier(value string, allowTerm bool) bool { + if allowTerm && strings.HasPrefix(value, "-") { + value = value[1:] + } + if value == "" || !isASCIILetter(value[0]) { + return false + } + for index := 1; index < len(value); index++ { + if !isASCIILetter(value[index]) && !isASCIIDigit(value[index]) && value[index] != '-' && value[index] != '_' { + return false + } + } + return true +} + +func isFluentContinuation(line string) bool { + return line != "" && (unicode.IsSpace(rune(line[0])) || strings.TrimSpace(line) == "}") +} + +func leadingWhitespaceLength(value string) int { + for index, character := range value { + if !unicode.IsSpace(character) { + return index + } + } + return len(value) +} + +func fluentCommentContext(lines []fluentLine, entryIndex int, newline string) string { + start := entryIndex + for start > 0 { + candidate := strings.TrimSpace(lines[start-1].content) + if !strings.HasPrefix(candidate, "#") { + break + } + start-- + } + if start == entryIndex { + return "" + } + comments := make([]string, 0, entryIndex-start) + for index := start; index < entryIndex; index++ { + comments = append(comments, lines[index].content) + } + return strings.Join(comments, newline) +} + +func fluentUnitIdentity(unit Unit) (parent, attribute string, isAttribute bool) { + if unit.Kind != UnitAttribute { + return unit.ID, "", false + } + separator := strings.LastIndexByte(unit.ID, '.') + if separator < 1 || separator == len(unit.ID)-1 { + return unit.ID, "", false + } + return unit.ID[:separator], unit.ID[separator+1:], true +} + +func isASCIILetter(value byte) bool { + return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' +} +func isASCIIDigit(value byte) bool { return value >= '0' && value <= '9' } diff --git a/internal/formats/fluent_test.go b/internal/formats/fluent_test.go new file mode 100644 index 0000000..8437bc2 --- /dev/null +++ b/internal/formats/fluent_test.go @@ -0,0 +1,208 @@ +package formats + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +const fluentFixture = `## Account panel + +# Shown when the user has unread messages. +inbox-status = + { $count -> + [one] One unread message + *[other] { $count } unread messages + } + .aria-label = Inbox status + +save-button = + .label = Save + .accesskey = S + +-brand-short-name = Acme +welcome = Welcome to { -brand-short-name }. +` + +func TestFluentParseUnitsPreservesSemanticKindsAndContext(t *testing.T) { + units, err := ParseUnits(&FluentFormat{}, []byte(fluentFixture)) + if err != nil { + t.Fatal(err) + } + wantIDs := []string{"inbox-status", "inbox-status.aria-label", "save-button.label", "save-button.accesskey", "-brand-short-name", "welcome"} + if len(units) != len(wantIDs) { + t.Fatalf("units = %#v", units) + } + for index, want := range wantIDs { + if units[index].ID != want || units[index].Structure != "fluent-pattern-v1" { + t.Fatalf("unit %d = %#v, want ID %q", index, units[index], want) + } + } + if units[0].Kind != UnitMessage || units[1].Kind != UnitAttribute || units[4].Kind != UnitTerm { + t.Fatalf("unexpected semantic kinds: %#v", units) + } + if units[0].Context != "# Shown when the user has unread messages." { + t.Fatalf("context = %q", units[0].Context) + } + if !strings.Contains(units[0].Value, "{ $count ->") || !strings.Contains(units[0].Value, "*[other]") { + t.Fatalf("selector pattern = %q", units[0].Value) + } + if units[2].ID != "save-button.label" { + t.Fatalf("attribute-only message produced unexpected units: %#v", units) + } +} + +func TestFluentUnchangedSerializationIsLossless(t *testing.T) { + format := &FluentFormat{} + units, err := format.ParseUnits([]byte(fluentFixture)) + if err != nil { + t.Fatal(err) + } + output, err := format.SerializeUnits(units, []byte(fluentFixture)) + if err != nil { + t.Fatal(err) + } + if string(output) != fluentFixture { + t.Fatalf("unchanged round trip drifted:\n%s", output) + } +} + +func TestFluentSerializationChangesValuesWithoutLosingResourceStructure(t *testing.T) { + format := &FluentFormat{} + units, err := format.ParseUnits([]byte(fluentFixture)) + if err != nil { + t.Fatal(err) + } + for index := range units { + switch units[index].ID { + case "save-button.label": + units[index].Value = "Enregistrer" + case "inbox-status": + units[index].Value = "{ $count ->\n [one] Un message non lu\n *[other] { $count } messages non lus\n}" + } + } + output, err := format.SerializeUnits(units, []byte(fluentFixture)) + if err != nil { + t.Fatal(err) + } + text := string(output) + for _, preserved := range []string{"## Account panel", "# Shown when", ".accesskey = S", "-brand-short-name = Acme"} { + if !strings.Contains(text, preserved) { + t.Fatalf("serialized resource lost %q:\n%s", preserved, text) + } + } + reparsed, err := format.ParseUnits(output) + if err != nil { + t.Fatal(err) + } + values := UnitValues(reparsed) + if values["save-button.label"] != "Enregistrer" || !strings.Contains(values["inbox-status"], "Un message non lu") { + t.Fatalf("reparsed values = %#v", values) + } +} + +func TestFluentSerializationAppendsNewSourceEntriesInSourceOrder(t *testing.T) { + format := &FluentFormat{} + target := []byte("existing = Existant\n") + baseline, err := format.ParseUnits(target) + if err != nil { + t.Fatal(err) + } + source, err := format.ParseUnits([]byte("existing = Existing\n\n# First note\nfirst = First\nsecond =\n .label = Second\n")) + if err != nil { + t.Fatal(err) + } + values := map[string]string{"existing": "Existant", "first": "Premier", "second.label": "Deuxième"} + merged := MergeUnitValues(baseline, source, values) + if got := []string{merged[0].ID, merged[1].ID, merged[2].ID}; !reflect.DeepEqual(got, []string{"existing", "first", "second.label"}) { + t.Fatalf("merged order = %v", got) + } + output, err := format.SerializeUnits(merged, target) + if err != nil { + t.Fatal(err) + } + text := string(output) + if strings.Index(text, "first = Premier") > strings.Index(text, "second =") || !strings.Contains(text, "# First note") { + t.Fatalf("new entries were not appended in source order with context:\n%s", text) + } + parsed, err := format.ParseUnits(output) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(UnitValues(parsed), values) { + t.Fatalf("parsed appended values = %#v, want %#v", UnitValues(parsed), values) + } +} + +func TestFluentRejectsDuplicateUnitIdentity(t *testing.T) { + _, err := (&FluentFormat{}).ParseUnits([]byte("same = One\nsame = Two\n")) + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("ParseUnits() error = %v", err) + } +} + +func TestFluentFormatIsRegistered(t *testing.T) { + format, err := FormatForFile("browser.ftl") + if err != nil || format.Name() != "fluent" { + t.Fatalf("FormatForFile() = %v, %v", format, err) + } +} + +func TestFluentOptionalCorpusRoundTrip(t *testing.T) { + root := os.Getenv("INTERNATIONALIZER_FLUENT_CORPUS") + if root == "" { + t.Skip("set INTERNATIONALIZER_FLUENT_CORPUS to a directory of .ftl resources") + } + format := &FluentFormat{} + files := 0 + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".ftl" { + return nil + } + clean := filepath.ToSlash(path) + if strings.Contains(clean, "/test/") || strings.Contains(clean, "/tests/") { + return nil + } + files++ + data, err := os.ReadFile(path) + if err != nil { + return err + } + units, err := format.ParseUnits(data) + if err != nil { + return &fluentCorpusError{path: path, err: err} + } + output, err := format.SerializeUnits(units, data) + if err != nil { + return &fluentCorpusError{path: path, err: err} + } + if !reflect.DeepEqual(output, data) { + return &fluentCorpusError{path: path, err: errFluentRoundTripDrift} + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if files == 0 { + t.Fatalf("no .ftl files found under %s", root) + } +} + +type fluentCorpusError struct { + path string + err error +} + +func (err *fluentCorpusError) Error() string { return err.path + ": " + err.err.Error() } + +var errFluentRoundTripDrift = &fluentRoundTripError{} + +type fluentRoundTripError struct{} + +func (*fluentRoundTripError) Error() string { return "unchanged serialization drifted" } diff --git a/internal/formats/formats.go b/internal/formats/formats.go index a69f4ba..32f6c1e 100644 --- a/internal/formats/formats.go +++ b/internal/formats/formats.go @@ -36,6 +36,7 @@ var registry = []Format{ &JSONFormat{}, &YAMLFormat{}, &MarkdownFormat{}, + &FluentFormat{}, } // FormatForFile returns the appropriate format handler for a file path. diff --git a/internal/formats/units.go b/internal/formats/units.go new file mode 100644 index 0000000..73a8924 --- /dev/null +++ b/internal/formats/units.go @@ -0,0 +1,142 @@ +package formats + +import ( + "fmt" + "sort" +) + +// UnitKind describes the semantic role of one independently translatable unit. +// Adapters may add format-specific structure while the translation pipeline +// continues to address units by stable ID. +type UnitKind string + +const ( + UnitMessage UnitKind = "message" + UnitDocument UnitKind = "document" + UnitAttribute UnitKind = "attribute" + UnitTerm UnitKind = "term" +) + +// Unit is the format-neutral boundary between source adapters and translation. +// Context is translator-facing information; Structure is a deterministic +// adapter-owned signature used to detect semantic message changes. +type Unit struct { + ID string `json:"id"` + Value string `json:"value"` + Kind UnitKind `json:"kind"` + Context string `json:"context,omitempty"` + Structure string `json:"structure,omitempty"` +} + +// UnitFormat is implemented by formats with richer semantics than a flat +// key/value catalog. Existing formats automatically receive a compatibility +// adapter through ParseUnits and SerializeUnits. +type UnitFormat interface { + ParseUnits(data []byte) ([]Unit, error) + SerializeUnits(units []Unit, original []byte) ([]byte, error) +} + +// ParseUnits parses a document into stable semantic translation units. +func ParseUnits(format Format, data []byte) ([]Unit, error) { + if adapter, ok := format.(UnitFormat); ok { + units, err := adapter.ParseUnits(data) + if err != nil { + return nil, err + } + if err := ValidateUnits(units); err != nil { + return nil, err + } + return units, nil + } + entries, err := format.Parse(data) + if err != nil { + return nil, err + } + keys := make([]string, 0, len(entries)) + for key := range entries { + keys = append(keys, key) + } + sort.Strings(keys) + units := make([]Unit, 0, len(keys)) + for _, key := range keys { + units = append(units, Unit{ID: key, Value: entries[key], Kind: UnitMessage}) + } + return units, nil +} + +// SerializeUnits writes semantic units through a rich adapter or the legacy +// key/value serializer when the format has no additional structure. +func SerializeUnits(format Format, units []Unit, original []byte) ([]byte, error) { + if err := ValidateUnits(units); err != nil { + return nil, err + } + if adapter, ok := format.(UnitFormat); ok { + return adapter.SerializeUnits(units, original) + } + return format.Serialize(UnitValues(units), original) +} + +// UnitValues returns the compatibility key/value view of semantic units. +func UnitValues(units []Unit) map[string]string { + values := make(map[string]string, len(units)) + for _, unit := range units { + values[unit.ID] = unit.Value + } + return values +} + +// MergeUnitValues preserves baseline unit metadata while replacing values and +// appending source units absent from a new target document. +func MergeUnitValues(baseline, source []Unit, values map[string]string) []Unit { + merged := make([]Unit, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, unit := range baseline { + value, ok := values[unit.ID] + if !ok { + continue + } + unit.Value = value + merged = append(merged, unit) + seen[unit.ID] = struct{}{} + } + for _, sourceUnit := range source { + if _, ok := seen[sourceUnit.ID]; ok { + continue + } + value, ok := values[sourceUnit.ID] + if !ok { + continue + } + sourceUnit.Value = value + merged = append(merged, sourceUnit) + seen[sourceUnit.ID] = struct{}{} + } + missing := make([]string, 0) + for id := range values { + if _, ok := seen[id]; !ok { + missing = append(missing, id) + } + } + sort.Strings(missing) + for _, id := range missing { + unit := Unit{ID: id, Kind: UnitMessage} + unit.Value = values[id] + merged = append(merged, unit) + } + return merged +} + +// ValidateUnits enforces stable non-empty unique unit identities. +func ValidateUnits(units []Unit) error { + seen := make(map[string]struct{}, len(units)) + for _, unit := range units { + if unit.ID == "" { + return fmt.Errorf("translation unit ID must not be empty") + } + if _, duplicate := seen[unit.ID]; duplicate { + return fmt.Errorf("duplicate translation unit ID %q", unit.ID) + } + seen[unit.ID] = struct{}{} + } + return nil +} diff --git a/internal/formats/units_test.go b/internal/formats/units_test.go new file mode 100644 index 0000000..3e46e60 --- /dev/null +++ b/internal/formats/units_test.go @@ -0,0 +1,48 @@ +package formats + +import ( + "reflect" + "testing" +) + +func TestParseUnitsAdaptsLegacyFormatsDeterministically(t *testing.T) { + units, err := ParseUnits(&JSONFormat{}, []byte(`{"z":"Last","a":"First"}`)) + if err != nil { + t.Fatal(err) + } + want := []Unit{ + {ID: "a", Value: "First", Kind: UnitMessage}, + {ID: "z", Value: "Last", Kind: UnitMessage}, + } + if !reflect.DeepEqual(units, want) { + t.Fatalf("units = %#v, want %#v", units, want) + } +} + +func TestMarkdownUsesStableSectionUnits(t *testing.T) { + units, err := ParseUnits(&MarkdownFormat{}, []byte("# Hello\n")) + if err != nil { + t.Fatal(err) + } + if len(units) != 1 || units[0].ID != markdownPreambleKey || units[0].Kind != UnitMessage { + t.Fatalf("units = %#v", units) + } +} + +func TestMergeUnitValuesPreservesMetadataAndSourceUnits(t *testing.T) { + baseline := []Unit{{ID: "title", Value: "Ancien", Kind: UnitAttribute, Context: "window title"}} + source := []Unit{ + {ID: "title", Value: "Title", Kind: UnitAttribute, Context: "window title"}, + {ID: "body", Value: "Body", Kind: UnitMessage, Structure: "text"}, + } + merged := MergeUnitValues(baseline, source, map[string]string{"title": "Nouveau", "body": "Corps"}) + if len(merged) != 2 || merged[0].Value != "Nouveau" || merged[0].Context != "window title" || merged[1].ID != "body" || merged[1].Structure != "text" { + t.Fatalf("merged = %#v", merged) + } +} + +func TestValidateUnitsRejectsDuplicateIdentity(t *testing.T) { + if err := ValidateUnits([]Unit{{ID: "same"}, {ID: "same"}}); err == nil { + t.Fatal("ValidateUnits accepted duplicate identity") + } +} diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index 876cbc3..880f7bc 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -34,11 +34,7 @@ func (a *Anthropic) Name() string { return "anthropic" } func (a *Anthropic) Translate(ctx context.Context, req TranslateRequest) (*TranslateResponse, error) { // Build the user message as a JSON object of keys to translate. - input := make(map[string]string, len(req.Entries)) - for _, e := range req.Entries { - input[e.Key] = e.Value - } - inputJSON, err := json.Marshal(input) + inputJSON, err := marshalEntries(req.Entries) if err != nil { return nil, fmt.Errorf("marshal input: %w", err) } diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 965a5e8..5186481 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -33,11 +33,7 @@ func NewGemini(apiKey, model string) *Gemini { func (g *Gemini) Name() string { return "gemini" } func (g *Gemini) Translate(ctx context.Context, req TranslateRequest) (*TranslateResponse, error) { - input := make(map[string]string, len(req.Entries)) - for _, e := range req.Entries { - input[e.Key] = e.Value - } - inputJSON, err := json.Marshal(input) + inputJSON, err := marshalEntries(req.Entries) if err != nil { return nil, fmt.Errorf("marshal input: %w", err) } diff --git a/internal/llm/openai.go b/internal/llm/openai.go index aac037f..088f148 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -64,11 +64,7 @@ func NewOpenRouter(apiKey, model string) *OpenAI { func (o *OpenAI) Name() string { return o.providerName } func (o *OpenAI) Translate(ctx context.Context, req TranslateRequest) (*TranslateResponse, error) { - input := make(map[string]string, len(req.Entries)) - for _, e := range req.Entries { - input[e.Key] = e.Value - } - inputJSON, err := json.Marshal(input) + inputJSON, err := marshalEntries(req.Entries) if err != nil { return nil, fmt.Errorf("marshal input: %w", err) } diff --git a/internal/llm/provider.go b/internal/llm/provider.go index 7cf1225..3586a2b 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -2,6 +2,7 @@ package llm import ( "context" + "encoding/json" "fmt" "strings" @@ -11,8 +12,9 @@ import ( // Entry is a source key-value pair to be translated. type Entry struct { - Key string - Value string + Key string + Value string + Context string } // TranslateRequest is the input to a translation call. @@ -127,6 +129,34 @@ func BuildSystemPrompt(sourceLocale, targetLocale, styleGuide string, terms []gl return b.String() } +// BuildFluentPrompt constructs the translation contract for semantic units +// extracted from a Fluent resource. +func BuildFluentPrompt(sourceLocale, targetLocale, styleGuide string, terms []glossary.Term) string { + prompt := BuildSystemPrompt(sourceLocale, targetLocale, styleGuide, terms) + prompt += "\n## Fluent rules\n" + prompt += "- Input values with developer context are objects; translate only their value and still return a string.\n" + prompt += "- Developer context is authoritative translator guidance, not text to translate.\n" + prompt += "- Preserve Fluent variables, terms, message references, functions, and named markup slots exactly.\n" + prompt += "- Preserve every source selector variant and its default marker; add target-locale plural variants when required.\n" + prompt += "- Translate only the natural-language content inside each pattern.\n" + return prompt +} + +func marshalEntries(entries []Entry) ([]byte, error) { + input := make(map[string]interface{}, len(entries)) + for _, entry := range entries { + if entry.Context == "" { + input[entry.Key] = entry.Value + continue + } + input[entry.Key] = struct { + Value string `json:"value"` + Context string `json:"context"` + }{Value: entry.Value, Context: entry.Context} + } + return json.Marshal(input) +} + // BuildDocumentPrompt constructs a prompt for whole-document translation (e.g. Markdown). func BuildDocumentPrompt(sourceLocale, targetLocale, styleGuide string, terms []glossary.Term) string { var b strings.Builder diff --git a/internal/llm/provider_test.go b/internal/llm/provider_test.go index 46abcbd..e042c93 100644 --- a/internal/llm/provider_test.go +++ b/internal/llm/provider_test.go @@ -1,6 +1,7 @@ package llm import ( + "encoding/json" "strings" "testing" @@ -14,6 +15,36 @@ func TestBuildDocumentPromptKeepsProviderJSONContract(t *testing.T) { } } +func TestBuildFluentPromptDefinesSemanticRuntimeContract(t *testing.T) { + prompt := BuildFluentPrompt("en", "fr", "", nil) + for _, rule := range []string{"developer context", "Fluent variables", "target-locale plural variants"} { + if !strings.Contains(prompt, rule) { + t.Fatalf("Fluent prompt lacks %q: %s", rule, prompt) + } + } +} + +func TestMarshalEntriesIncludesDeveloperContextWithoutChangingOutputKey(t *testing.T) { + data, err := marshalEntries([]Entry{ + {Key: "plain", Value: "Save"}, + {Key: "guided", Value: "Open", Context: "Verb used on a button."}, + }) + if err != nil { + t.Fatal(err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded["plain"] != "Save" { + t.Fatalf("plain entry = %#v", decoded["plain"]) + } + guided, ok := decoded["guided"].(map[string]interface{}) + if !ok || guided["value"] != "Open" || guided["context"] != "Verb used on a button." { + t.Fatalf("guided entry = %#v", decoded["guided"]) + } +} + func TestEffectiveReasoningEffort(t *testing.T) { tests := []struct { name string diff --git a/internal/message/message.go b/internal/message/message.go index 5eeeeca..2a90d9e 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -940,6 +940,31 @@ func (message *Message) String() string { return output.String() } +// TransformText applies transform only to literal text while preserving ICU +// arguments, selectors, formatter styles, plural offsets, and pound tokens. +func TransformText(input string, transform func(string) string) (string, error) { + parsed, err := Parse(input) + if err != nil { + return "", err + } + transformMessageText(parsed, transform) + return parsed.String(), nil +} + +func transformMessageText(message *Message, transform func(string) string) { + for index := range message.elements { + element := &message.elements[index] + switch element.kind { + case elementText: + element.text = transform(element.text) + case elementArgument: + for optionIndex := range element.argument.Options { + transformMessageText(element.argument.Options[optionIndex].Message, transform) + } + } + } +} + func (message *Message) writeTo(output *strings.Builder, pluralContext bool) { for _, element := range message.elements { switch element.kind { diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 3f30c67..be7a0ed 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -41,8 +41,11 @@ func Resolve(cfg *config.Config, targetLocale, format, styleGuide string, terms effectiveLLM := cfg.LLMForLocale(canonicalTarget) prompt := llm.BuildSystemPrompt(canonicalSource, canonicalTarget, styleGuide, terms) - if format == "markdown" { + switch format { + case "markdown": prompt = llm.BuildDocumentPrompt(canonicalSource, canonicalTarget, styleGuide, terms) + case "fluent": + prompt = llm.BuildFluentPrompt(canonicalSource, canonicalTarget, styleGuide, terms) } guideHash, err := state.HashValue(styleGuide) diff --git a/internal/pseudo/generate.go b/internal/pseudo/generate.go new file mode 100644 index 0000000..49fa1e5 --- /dev/null +++ b/internal/pseudo/generate.go @@ -0,0 +1,257 @@ +package pseudo + +import ( + "bytes" + "fmt" + "os" + "strings" + "time" + + "github.com/Tom-R-Main/Internationalizer/internal/config" + "github.com/Tom-R-Main/Internationalizer/internal/formats" + localeid "github.com/Tom-R-Main/Internationalizer/internal/locale" + "github.com/Tom-R-Main/Internationalizer/internal/state" +) + +// GenerateOptions configures pseudolocale generation. +type GenerateOptions struct { + Strategy Strategy + Locale string + Force bool + DryRun bool +} + +// GenerateResult describes one generated bundle artifact. +type GenerateResult struct { + Bundle string + Locale string + TargetPath string + Units int + Written bool +} + +// Generate creates deterministic pseudolocale artifacts without an LLM or TM. +func Generate(cfg *config.Config, opts GenerateOptions) ([]GenerateResult, error) { + effectiveConfig := *cfg + effectiveConfig.ApplyDefaults() + cfg = &effectiveConfig + if err := cfg.ValidateProject(); err != nil { + return nil, err + } + if _, err := DefaultLocale(opts.Strategy); err != nil { + return nil, err + } + locale := opts.Locale + if locale == "" { + var err error + locale, err = DefaultLocale(opts.Strategy) + if err != nil { + return nil, err + } + } + canonicalLocale, err := localeid.Canonical(locale) + if err != nil { + return nil, fmt.Errorf("invalid pseudo locale %q: %w", locale, err) + } + + manifest, err := state.Load(cfg.ManifestPath) + if err != nil { + return nil, err + } + policyHash, err := state.HashValue(struct { + Version int `json:"version"` + Strategy Strategy `json:"strategy"` + }{Version: 1, Strategy: opts.Strategy}) + if err != nil { + return nil, err + } + + results := make([]GenerateResult, 0, len(cfg.EffectiveBundles())) + now := time.Now().UTC() + for _, bundle := range cfg.EffectiveBundles() { + format, err := formatForBundle(bundle) + if err != nil { + return nil, fmt.Errorf("bundle %q format: %w", bundle.ID, err) + } + sourceData, err := os.ReadFile(bundle.Source) + if err != nil { + return nil, fmt.Errorf("reading bundle %q source %s: %w", bundle.ID, bundle.Source, err) + } + sourceUnits, err := formats.ParseUnits(format, sourceData) + if err != nil { + return nil, fmt.Errorf("parsing bundle %q source: %w", bundle.ID, err) + } + targetPath, err := bundle.TargetPath(locale) + if err != nil { + return nil, err + } + if existing, readErr := os.ReadFile(targetPath); readErr == nil { + if !opts.Force && !pseudoOwnsArtifact(manifest, bundle.ID, canonicalLocale, format, sourceData, existing) { + return nil, fmt.Errorf("refusing to overwrite %s without --force because it is not a tracked pseudo artifact", targetPath) + } + } else if !os.IsNotExist(readErr) { + return nil, fmt.Errorf("reading pseudo target %s: %w", targetPath, readErr) + } + + pseudoUnits := make([]formats.Unit, len(sourceUnits)) + for index, unit := range sourceUnits { + transformed, err := transformUnit(format, unit, opts.Strategy) + if err != nil { + return nil, fmt.Errorf("pseudolocalizing bundle %q unit %q: %w", bundle.ID, unit.ID, err) + } + unit.Value = transformed + pseudoUnits[index] = unit + } + var output []byte + if paired, ok := format.(formats.PairedFormat); ok { + output, err = paired.SerializeTarget(formats.UnitValues(pseudoUnits), sourceData, sourceData) + } else { + output, err = formats.SerializeUnits(format, pseudoUnits, sourceData) + } + if err != nil { + return nil, fmt.Errorf("serializing pseudo target %s: %w", targetPath, err) + } + output = appendOneNewline(output) + serializedValues, err := parseTargetValues(format, sourceData, output) + if err != nil { + return nil, fmt.Errorf("validating pseudo target %s: %w", targetPath, err) + } + result := GenerateResult{Bundle: bundle.ID, Locale: canonicalLocale, TargetPath: targetPath, Units: len(pseudoUnits), Written: !opts.DryRun} + results = append(results, result) + if opts.DryRun { + continue + } + if err := state.WriteFileAtomic(targetPath, output, 0o644); err != nil { + return nil, err + } + for _, sourceUnit := range sourceUnits { + targetValue, ok := serializedValues[sourceUnit.ID] + if !ok { + return nil, fmt.Errorf("validating pseudo target %s: unit %q is missing after serialization", targetPath, sourceUnit.ID) + } + manifest.Set(state.Entry{ + Bundle: bundle.ID, + Key: sourceUnit.ID, + Locale: canonicalLocale, + SourceHash: state.SourceUnitHash(format.Name(), sourceUnit.Value, sourceUnit.Context, sourceUnit.Structure), + PolicyHash: policyHash, + TargetHash: state.TargetHash(targetValue), + Origin: "pseudo", + ReviewStatus: state.ReviewNeedsReview, + UpdatedAt: now, + }) + } + } + if !opts.DryRun { + if err := manifest.Save(cfg.ManifestPath); err != nil { + return nil, err + } + } + return results, nil +} + +func formatForBundle(bundle config.Bundle) (formats.Format, error) { + if bundle.Format != "" { + return formats.FormatByName(bundle.Format) + } + return formats.FormatForFile(bundle.Source) +} + +func transformUnit(format formats.Format, unit formats.Unit, strategy Strategy) (string, error) { + if format.Name() != "markdown" { + return Transform(unit.Value, strategy) + } + lineEnd := strings.IndexByte(unit.Value, '\n') + if lineEnd < 0 { + lineEnd = len(unit.Value) + } + line := strings.TrimSuffix(unit.Value[:lineEnd], "\r") + prefixEnd := 0 + for prefixEnd < len(line) && (line[prefixEnd] == ' ' || line[prefixEnd] == '\t') { + prefixEnd++ + } + headingStart := prefixEnd + for prefixEnd < len(line) && line[prefixEnd] == '#' && prefixEnd-headingStart < 6 { + prefixEnd++ + } + if prefixEnd == headingStart || prefixEnd >= len(line) || (line[prefixEnd] != ' ' && line[prefixEnd] != '\t') { + return Transform(unit.Value, strategy) + } + for prefixEnd < len(line) && (line[prefixEnd] == ' ' || line[prefixEnd] == '\t') { + prefixEnd++ + } + heading, err := Transform(line[prefixEnd:], strategy) + if err != nil { + return "", err + } + newline := "" + body := "" + if lineEnd < len(unit.Value) { + newline = "\n" + if lineEnd > 0 && unit.Value[lineEnd-1] == '\r' { + newline = "\r\n" + } + body = unit.Value[lineEnd+1:] + } + transformedBody, err := transformPadded(body, strategy) + if err != nil { + return "", err + } + return line[:prefixEnd] + heading + newline + transformedBody, nil +} + +func transformPadded(value string, strategy Strategy) (string, error) { + start := 0 + for start < len(value) && strings.ContainsRune(" \t\r\n", rune(value[start])) { + start++ + } + end := len(value) + for end > start && strings.ContainsRune(" \t\r\n", rune(value[end-1])) { + end-- + } + if start == end { + return value, nil + } + transformed, err := Transform(value[start:end], strategy) + if err != nil { + return "", err + } + return value[:start] + transformed + value[end:], nil +} + +func pseudoOwnsArtifact(manifest *state.Manifest, bundle, locale string, format formats.Format, source, target []byte) bool { + sourceUnits, err := formats.ParseUnits(format, source) + if err != nil || len(sourceUnits) == 0 { + return false + } + targetValues, err := parseTargetValues(format, source, target) + if err != nil || len(targetValues) != len(sourceUnits) { + return false + } + for _, sourceUnit := range sourceUnits { + targetValue, ok := targetValues[sourceUnit.ID] + if !ok { + return false + } + entry, ok := manifest.Get(bundle, sourceUnit.ID, locale) + if !ok || entry.Origin != "pseudo" || entry.TargetHash != state.TargetHash(targetValue) { + return false + } + } + return true +} + +func parseTargetValues(format formats.Format, source, target []byte) (map[string]string, error) { + if paired, ok := format.(formats.PairedFormat); ok { + return paired.ParseTarget(source, target) + } + targetUnits, err := formats.ParseUnits(format, target) + if err != nil { + return nil, err + } + return formats.UnitValues(targetUnits), nil +} + +func appendOneNewline(data []byte) []byte { + return append(bytes.TrimRight(data, "\n"), '\n') +} diff --git a/internal/pseudo/generate_test.go b/internal/pseudo/generate_test.go new file mode 100644 index 0000000..4f74991 --- /dev/null +++ b/internal/pseudo/generate_test.go @@ -0,0 +1,143 @@ +package pseudo + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Tom-R-Main/Internationalizer/internal/config" + "github.com/Tom-R-Main/Internationalizer/internal/state" +) + +func TestGenerateWritesTrackedPseudoArtifactAndCanRefreshIt(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "locales", "en.json") + targetTemplate := filepath.Join(dir, "locales", "{locale}.json") + manifestPath := filepath.Join(dir, ".internationalizer.lock") + writeTestFile(t, sourcePath, `{"greeting":"Hello {name}","link":"Read docs"}`) + cfg := pseudoTestConfig(sourcePath, targetTemplate, manifestPath) + + results, err := Generate(cfg, GenerateOptions{Strategy: Accented}) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + if len(results) != 1 || !results[0].Written || results[0].Locale != "en-XA" || results[0].Units != 2 { + t.Fatalf("Generate() results = %#v", results) + } + targetPath := filepath.Join(dir, "locales", "en-XA.json") + data, err := os.ReadFile(targetPath) + if err != nil { + t.Fatal(err) + } + output := string(data) + if !strings.Contains(output, "{name}") || !strings.Contains(output, ``) || !strings.Contains(output, "[!!") { + t.Fatalf("pseudo output did not preserve runtime syntax: %s", output) + } + manifest, err := state.Load(manifestPath) + if err != nil { + t.Fatal(err) + } + entry, ok := manifest.Get("app", "greeting", "en-XA") + if !ok || entry.Origin != "pseudo" || entry.ReviewStatus != state.ReviewNeedsReview { + t.Fatalf("pseudo manifest entry = %#v, present = %v", entry, ok) + } + + // A tracked, byte-identical pseudo artifact is safe to regenerate. + if _, err := Generate(cfg, GenerateOptions{Strategy: Accented}); err != nil { + t.Fatalf("second Generate() error = %v", err) + } +} + +func TestGenerateRefusesUntrackedTargetUnlessForced(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "en.json") + targetTemplate := filepath.Join(dir, "{locale}.json") + writeTestFile(t, sourcePath, `{"greeting":"Hello"}`) + writeTestFile(t, filepath.Join(dir, "en-XA.json"), `{"greeting":"Human text"}`) + cfg := pseudoTestConfig(sourcePath, targetTemplate, filepath.Join(dir, "manifest.json")) + + _, err := Generate(cfg, GenerateOptions{Strategy: Accented}) + if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { + t.Fatalf("Generate() error = %v, want overwrite refusal", err) + } + if _, err := Generate(cfg, GenerateOptions{Strategy: Accented, Force: true}); err != nil { + t.Fatalf("forced Generate() error = %v", err) + } +} + +func TestGenerateDryRunDoesNotWriteArtifactOrManifest(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "en.json") + targetTemplate := filepath.Join(dir, "{locale}.json") + manifestPath := filepath.Join(dir, "manifest.json") + writeTestFile(t, sourcePath, `{"greeting":"Hello"}`) + cfg := pseudoTestConfig(sourcePath, targetTemplate, manifestPath) + + results, err := Generate(cfg, GenerateOptions{Strategy: Bidi, DryRun: true}) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + if len(results) != 1 || results[0].Written || results[0].Locale != "ar-XB" { + t.Fatalf("Generate() results = %#v", results) + } + for _, path := range []string{filepath.Join(dir, "ar-XB.json"), manifestPath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("dry run unexpectedly wrote %s", path) + } + } +} + +func TestGenerateWritesRefreshableMarkdownPseudoArtifact(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "README.md") + targetTemplate := filepath.Join(dir, "docs", "{locale}.md") + manifestPath := filepath.Join(dir, "manifest.json") + writeTestFile(t, sourcePath, "# Project\n\n## Install\n\nRead [the guide](docs/guide.md).\n\n## Usage\n\nRun `internationalizer`.\n") + cfg := pseudoTestConfig(sourcePath, targetTemplate, manifestPath) + cfg.Bundles[0].Format = "markdown" + + if _, err := Generate(cfg, GenerateOptions{Strategy: Accented}); err != nil { + t.Fatalf("first Generate() error = %v", err) + } + if _, err := Generate(cfg, GenerateOptions{Strategy: Accented}); err != nil { + t.Fatalf("refresh Generate() error = %v", err) + } + target, err := os.ReadFile(filepath.Join(dir, "docs", "en-XA.md")) + if err != nil { + t.Fatal(err) + } + text := string(target) + if !strings.HasPrefix(text, "# [!! ") { + t.Fatalf("Markdown pseudo output did not preserve the document heading:\n%s", text) + } + for _, marker := range []string{"", ""} { + if !strings.Contains(text, marker) { + t.Fatalf("Markdown pseudo output lacks %q:\n%s", marker, text) + } + } +} + +func pseudoTestConfig(source, target, manifest string) *config.Config { + return &config.Config{ + SourceLocale: "en", + TargetLocales: []string{"fr"}, + ManifestPath: manifest, + Bundles: []config.Bundle{{ + ID: "app", + Source: source, + Target: target, + Format: "json", + }}, + } +} + +func writeTestFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/pseudo/transform.go b/internal/pseudo/transform.go new file mode 100644 index 0000000..c4279d9 --- /dev/null +++ b/internal/pseudo/transform.go @@ -0,0 +1,202 @@ +// Package pseudo generates deterministic accented and bidirectional test text. +package pseudo + +import ( + "fmt" + "regexp" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "github.com/Tom-R-Main/Internationalizer/internal/fluentpattern" + "github.com/Tom-R-Main/Internationalizer/internal/message" +) + +// Strategy selects a pseudolocalization transformation. +type Strategy string + +const ( + Accented Strategy = "accented" + Bidi Strategy = "bidi" +) + +var protectedTokenRe = regexp.MustCompile("(?s)|```.*?```|~~~.*?~~~|`+[^`\\n]*`+|]*>|\\{\\{[A-Za-z0-9_.-]+\\}\\}|%\\{[A-Za-z0-9_.-]+\\}|\\{[A-Za-z0-9_.-]+\\}") + +type span struct { + start int + end int +} + +// Transform transforms linguistic text while preserving runtime syntax. +func Transform(input string, strategy Strategy) (string, error) { + if strategy != Accented && strategy != Bidi { + return "", fmt.Errorf("unsupported pseudolocalization strategy %q", strategy) + } + transformLiteral := func(value string) string { + return transformPreserving(value, strategy) + } + output := "" + var err error + if fluentpattern.LooksLike(input) { + output, err = fluentpattern.TransformText(input, transformLiteral) + if err != nil { + return "", fmt.Errorf("pseudolocalizing Fluent pattern: %w", err) + } + } else if message.LooksLike(input) { + output, err = message.TransformText(input, transformLiteral) + if err != nil { + return "", fmt.Errorf("pseudolocalizing ICU message: %w", err) + } + } else { + output = transformLiteral(input) + } + if strategy == Accented { + return "[!! " + output + " !!]", nil + } + return "\u2067" + output + "\u2069", nil +} + +// DefaultLocale returns the conventional Unicode pseudo-locale for strategy. +func DefaultLocale(strategy Strategy) (string, error) { + switch strategy { + case Accented: + return "en-XA", nil + case Bidi: + return "ar-XB", nil + default: + return "", fmt.Errorf("unsupported pseudolocalization strategy %q", strategy) + } +} + +func transformPreserving(input string, strategy Strategy) string { + spans := protectedSpans(input) + var output strings.Builder + position := 0 + for _, protected := range spans { + if protected.start > position { + output.WriteString(transformText(input[position:protected.start], strategy)) + } + output.WriteString(input[protected.start:protected.end]) + position = protected.end + } + output.WriteString(transformText(input[position:], strategy)) + return output.String() +} + +func protectedSpans(input string) []span { + spans := make([]span, 0) + for _, location := range protectedTokenRe.FindAllStringIndex(input, -1) { + spans = append(spans, span{start: location[0], end: location[1]}) + } + spans = append(spans, markdownDestinationSpans(input)...) + sort.Slice(spans, func(i, j int) bool { + if spans[i].start != spans[j].start { + return spans[i].start < spans[j].start + } + return spans[i].end > spans[j].end + }) + merged := spans[:0] + for _, candidate := range spans { + if len(merged) == 0 || candidate.start > merged[len(merged)-1].end { + merged = append(merged, candidate) + continue + } + if candidate.end > merged[len(merged)-1].end { + merged[len(merged)-1].end = candidate.end + } + } + return merged +} + +func markdownDestinationSpans(input string) []span { + var spans []span + 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 { + break + } + } + if depth != 0 { + break + } + spans = append(spans, span{start: start, end: end}) + offset = end + 1 + } + return spans +} + +func transformText(input string, strategy Strategy) string { + if strategy == Bidi { + return mapRunes(input, bidiRune) + } + mapped := mapRunes(input, accentedRune) + var expanded strings.Builder + for _, character := range mapped { + expanded.WriteRune(character) + if isVowel(character) { + expanded.WriteRune(character) + } + } + return expanded.String() +} + +func mapRunes(input string, transform func(rune) rune) string { + return strings.Map(transform, input) +} + +func accentedRune(character rune) rune { + if replacement, ok := accentedCharacters[character]; ok { + return replacement + } + return character +} + +func bidiRune(character rune) rune { + if replacement, ok := bidiCharacters[character]; ok { + return replacement + } + return character +} + +func isVowel(character rune) bool { + decomposed := unicode.ToLower(character) + return strings.ContainsRune("aàáâãäåæeèéêëiìíîïoòóôõöøuùúûüyýÿȧḗī", decomposed) && utf8.RuneLen(character) > 0 +} + +var accentedCharacters = map[rune]rune{ + 'a': 'ȧ', 'b': 'ƀ', 'c': 'ƈ', 'd': 'ḓ', 'e': 'ḗ', 'f': 'ƒ', 'g': 'ɠ', 'h': 'ħ', 'i': 'ī', 'j': 'ĵ', 'k': 'ķ', 'l': 'ŀ', 'm': 'ḿ', + 'n': 'ƞ', 'o': 'ǿ', 'p': 'ƥ', 'q': 'ɋ', 'r': 'ř', 's': 'ş', 't': 'ŧ', 'u': 'ŭ', 'v': 'ṽ', 'w': 'ẇ', 'x': 'ẋ', 'y': 'ẏ', 'z': 'ž', + 'A': 'Ȧ', 'B': 'Ɓ', 'C': 'Ƈ', 'D': 'Ḓ', 'E': 'Ḗ', 'F': 'Ƒ', 'G': 'Ɠ', 'H': 'Ħ', 'I': 'Ī', 'J': 'Ĵ', 'K': 'Ķ', 'L': 'Ŀ', 'M': 'Ḿ', + 'N': 'Ƞ', 'O': 'Ø', 'P': 'Ƥ', 'Q': 'Ɋ', 'R': 'Ř', 'S': 'Ş', 'T': 'Ŧ', 'U': 'Ŭ', 'V': 'Ṽ', 'W': 'Ẇ', 'X': 'Ẋ', 'Y': 'Ẏ', 'Z': 'Ž', +} + +var bidiCharacters = map[rune]rune{ + 'a': 'ɐ', 'b': 'q', 'c': 'ɔ', 'd': 'p', 'e': 'ǝ', 'f': 'ɟ', 'g': 'ƃ', 'h': 'ɥ', 'i': 'ı', 'j': 'ɾ', 'k': 'ʞ', 'l': 'ן', 'm': 'ɯ', + 'n': 'u', 'o': 'o', 'p': 'd', 'q': 'b', 'r': 'ɹ', 's': 's', 't': 'ʇ', 'u': 'n', 'v': 'ʌ', 'w': 'ʍ', 'x': 'x', 'y': 'ʎ', 'z': 'z', + 'A': '∀', 'B': 'ꓭ', 'C': 'Ɔ', 'D': '◖', 'E': 'Ǝ', 'F': 'Ⅎ', 'G': '⅁', 'H': 'H', 'I': 'I', 'J': 'ſ', 'K': 'ꓘ', 'L': '˥', 'M': 'W', + 'N': 'N', 'O': 'O', 'P': 'Ԁ', 'Q': 'Ό', 'R': 'ꓤ', 'S': 'S', 'T': '⊥', 'U': '∩', 'V': 'Λ', 'W': 'M', 'X': 'X', 'Y': '⅄', 'Z': 'Z', +} diff --git a/internal/pseudo/transform_test.go b/internal/pseudo/transform_test.go new file mode 100644 index 0000000..a74e851 --- /dev/null +++ b/internal/pseudo/transform_test.go @@ -0,0 +1,134 @@ +package pseudo + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Tom-R-Main/Internationalizer/internal/fluentpattern" + "github.com/Tom-R-Main/Internationalizer/internal/formats" + "github.com/Tom-R-Main/Internationalizer/internal/message" + "github.com/Tom-R-Main/Internationalizer/internal/validate" +) + +func TestAccentedPreservesProtectedSyntax(t *testing.T) { + source := "Save {{name}} in Account; read [guide](https://example.com/a_(b)) and run `go test`." + target, err := Transform(source, Accented) + if err != nil { + t.Fatal(err) + } + if target == source || !strings.HasPrefix(target, "[!! ") { + t.Fatalf("target = %q", target) + } + if findings := validate.ProtectedFindings("message", source, target, "en-XA"); len(findings) != 0 { + t.Fatalf("protected findings = %#v; target = %q", findings, target) + } +} + +func TestTransformsOnlyICULiteralText(t *testing.T) { + source := `{count, plural, one {One item for {name}} other {# items for {name}}}` + target, err := Transform(source, Accented) + if err != nil { + t.Fatal(err) + } + if issues := message.Compare(source, target, "en-XA"); len(issues) != 0 { + t.Fatalf("ICU issues = %#v; target = %q", issues, target) + } + if findings := validate.ProtectedFindings("items", source, target, "en-XA"); len(findings) != 0 { + t.Fatalf("protected findings = %#v; target = %q", findings, target) + } +} + +func TestBidiUsesIsolateAndPreservesInterpolation(t *testing.T) { + source := "Hello {name}; read [guide](https://example.com) in Account." + target, err := Transform(source, Bidi) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(target, "\u2067") || !strings.HasSuffix(target, "\u2069") || !strings.Contains(target, "{name}") { + t.Fatalf("target = %q", target) + } + if findings := validate.ProtectedFindings("message", source, target, "ar-XB"); len(findings) != 0 { + t.Fatalf("protected findings = %#v; target = %q", findings, target) + } +} + +func TestRejectsUnknownStrategy(t *testing.T) { + if _, err := Transform("Hello", Strategy("unknown")); err == nil { + t.Fatal("Transform accepted unknown strategy") + } +} + +func TestTransformPreservesFluentSelectorStructure(t *testing.T) { + source := `{ $count -> + [one] One item for { -brand-short-name } + *[other] { $count } items for { $user } +}` + target, err := Transform(source, Accented) + if err != nil { + t.Fatal(err) + } + for _, syntax := range []string{"{ $count ->", "[one]", "*[other]", "{ -brand-short-name }", "{ $count }", "{ $user }"} { + if !strings.Contains(target, syntax) { + t.Fatalf("pseudo target lost %q: %s", syntax, target) + } + } +} + +func TestPseudoOptionalFluentCorpus(t *testing.T) { + root := os.Getenv("INTERNATIONALIZER_FLUENT_CORPUS") + if root == "" { + t.Skip("set INTERNATIONALIZER_FLUENT_CORPUS to a directory of .ftl resources") + } + files := 0 + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + clean := filepath.ToSlash(path) + if entry.IsDir() || filepath.Ext(path) != ".ftl" || strings.Contains(clean, "/test/") || strings.Contains(clean, "/tests/") { + return nil + } + files++ + data, err := os.ReadFile(path) + if err != nil { + return err + } + units, err := formats.ParseUnits(&formats.FluentFormat{}, data) + if err != nil { + return err + } + for _, unit := range units { + target, err := Transform(unit.Value, Accented) + if err != nil { + return err + } + _, _, preserved, err := fluentpattern.Compare(unit.Value, target) + if err != nil || !preserved { + return &fluentPseudoCorpusError{path: path, unit: unit.ID, err: err} + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if files == 0 { + t.Fatalf("no .ftl files found under %s", root) + } +} + +type fluentPseudoCorpusError struct { + path string + unit string + err error +} + +func (err *fluentPseudoCorpusError) Error() string { + detail := "structure mismatch" + if err.err != nil { + detail = err.err.Error() + } + return err.path + ": " + err.unit + ": " + detail +} diff --git a/internal/review/review.go b/internal/review/review.go new file mode 100644 index 0000000..278a03b --- /dev/null +++ b/internal/review/review.go @@ -0,0 +1,176 @@ +// Package review manages explicit human approval of current translations. +package review + +import ( + "fmt" + "sort" + "time" + + "github.com/Tom-R-Main/Internationalizer/internal/config" + localeid "github.com/Tom-R-Main/Internationalizer/internal/locale" + "github.com/Tom-R-Main/Internationalizer/internal/state" + "github.com/Tom-R-Main/Internationalizer/internal/validate" +) + +// Filter selects manifest entries for listing or approval. +type Filter struct { + Locale string + Bundle string + Keys []string + Status state.ReviewStatus + All bool +} + +// List returns deterministically ordered manifest entries matching filter. +func List(manifest *state.Manifest, filter Filter) ([]state.Entry, error) { + canonicalLocale := "" + var err error + if filter.Locale != "" { + canonicalLocale, err = localeid.Canonical(filter.Locale) + if err != nil { + return nil, err + } + } + if filter.Status != "" && filter.Status != state.ReviewNeedsReview && filter.Status != state.ReviewApproved { + return nil, fmt.Errorf("unsupported review status %q", filter.Status) + } + keySet := make(map[string]struct{}, len(filter.Keys)) + for _, key := range filter.Keys { + keySet[key] = struct{}{} + } + + entries := make([]state.Entry, 0, len(manifest.Translations)) + for _, entry := range manifest.Translations { + if canonicalLocale != "" { + entryLocale, err := localeid.Canonical(entry.Locale) + if err != nil || entryLocale != canonicalLocale { + continue + } + } + if filter.Bundle != "" && entry.Bundle != filter.Bundle { + continue + } + if filter.Status != "" && entry.ReviewStatus != filter.Status { + continue + } + if len(keySet) > 0 { + if _, ok := keySet[entry.Key]; !ok { + continue + } + } + entries = append(entries, entry) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].Locale != entries[j].Locale { + return entries[i].Locale < entries[j].Locale + } + if entries[i].Bundle != entries[j].Bundle { + return entries[i].Bundle < entries[j].Bundle + } + return entries[i].Key < entries[j].Key + }) + return entries, nil +} + +// Approve verifies selected entries against source, policy, and target state, +// then records explicit approval. It never approves a stale or invalid value. +func Approve(cfg *config.Config, filter Filter, reviewedAt time.Time) ([]state.Entry, error) { + effectiveConfig := *cfg + effectiveConfig.ApplyDefaults() + cfg = &effectiveConfig + if err := cfg.ValidateProject(); err != nil { + return nil, err + } + if filter.Locale == "" { + return nil, fmt.Errorf("locale is required") + } + configuredLocale, ok := cfg.ConfiguredTargetLocale(filter.Locale) + if !ok { + return nil, fmt.Errorf("locale %q is not in target_locales", filter.Locale) + } + if filter.All == (len(filter.Keys) > 0) { + return nil, fmt.Errorf("choose exactly one of all entries or one or more keys") + } + if len(filter.Keys) > 0 && filter.Bundle == "" { + return nil, fmt.Errorf("bundle is required when approving individual keys") + } + + manifest, err := state.Load(cfg.ManifestPath) + if err != nil { + return nil, err + } + selection := filter + selection.Locale = configuredLocale + selection.Status = "" + entries, err := List(manifest, selection) + if err != nil { + return nil, err + } + if len(entries) == 0 { + return nil, fmt.Errorf("no tracked translations match the approval selection") + } + if len(filter.Keys) > 0 { + found := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + found[entry.Key] = struct{}{} + } + for _, key := range filter.Keys { + if _, ok := found[key]; !ok { + return nil, fmt.Errorf("translation %s/%s/%s is not tracked", filter.Bundle, key, configuredLocale) + } + } + } + + reports, err := validate.ValidateWithOptions(cfg, validate.Options{RequireState: true}) + if err != nil { + return nil, err + } + for _, entry := range entries { + if err := entryValidationError(entry, reports); err != nil { + return nil, err + } + } + + approved := make([]state.Entry, 0, len(entries)) + for _, entry := range entries { + updated, err := manifest.Approve(entry.Bundle, entry.Key, entry.Locale, reviewedAt) + if err != nil { + return nil, err + } + approved = append(approved, updated) + } + if err := manifest.Save(cfg.ManifestPath); err != nil { + return nil, err + } + return approved, nil +} + +func entryValidationError(entry state.Entry, reports []validate.Report) error { + for _, report := range reports { + reportLocale, reportLocaleErr := localeid.Canonical(report.Locale) + entryLocale, entryLocaleErr := localeid.Canonical(entry.Locale) + if report.Bundle != entry.Bundle || reportLocaleErr != nil || entryLocaleErr != nil || reportLocale != entryLocale { + continue + } + if len(report.Errors) > 0 { + return fmt.Errorf("cannot approve %s/%s/%s: %s", entry.Bundle, entry.Key, entry.Locale, report.Errors[0]) + } + for _, missing := range report.Missing { + if missing == entry.Key { + return fmt.Errorf("cannot approve %s/%s/%s: target key is missing", entry.Bundle, entry.Key, entry.Locale) + } + } + for _, mismatch := range report.Mismatches { + if mismatch.Key == entry.Key { + return fmt.Errorf("cannot approve %s/%s/%s: interpolation mismatch", entry.Bundle, entry.Key, entry.Locale) + } + } + for _, finding := range report.Findings { + if finding.Key == entry.Key && finding.Severity == validate.SeverityError { + return fmt.Errorf("cannot approve %s/%s/%s: %s", entry.Bundle, entry.Key, entry.Locale, finding.Message) + } + } + return nil + } + return fmt.Errorf("cannot approve %s/%s/%s: no configured bundle/locale report", entry.Bundle, entry.Key, entry.Locale) +} diff --git a/internal/review/review_test.go b/internal/review/review_test.go new file mode 100644 index 0000000..736316c --- /dev/null +++ b/internal/review/review_test.go @@ -0,0 +1,104 @@ +package review + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Tom-R-Main/Internationalizer/internal/config" + "github.com/Tom-R-Main/Internationalizer/internal/policy" + "github.com/Tom-R-Main/Internationalizer/internal/state" +) + +func TestApproveVerifiesCurrentArtifactAndPreservesOrigin(t *testing.T) { + cfg := reviewFixture(t, "Enregistrer") + reviewedAt := time.Date(2026, time.September, 4, 16, 0, 0, 0, time.UTC) + + approved, err := Approve(cfg, Filter{Locale: "fr", Bundle: "default", Keys: []string{"save"}}, reviewedAt) + if err != nil { + t.Fatal(err) + } + if len(approved) != 1 || approved[0].Origin != "provider" || approved[0].ReviewStatus != state.ReviewApproved { + t.Fatalf("approved = %#v", approved) + } + + loaded, err := state.Load(cfg.ManifestPath) + if err != nil { + t.Fatal(err) + } + entry, ok := loaded.Get("default", "save", "fr") + if !ok || entry.ReviewedAt == nil || !entry.ReviewedAt.Equal(reviewedAt) { + t.Fatalf("saved approval = %#v, found = %v", entry, ok) + } +} + +func TestApproveRejectsModifiedTarget(t *testing.T) { + cfg := reviewFixture(t, "Enregistrer") + targetPath := filepath.Join(filepath.Dir(cfg.SourcePath), "fr.json") + if err := os.WriteFile(targetPath, []byte(`{"save":"Sauvegarder"}`), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Approve(cfg, Filter{Locale: "fr", All: true}, time.Now()) + if err == nil || !strings.Contains(err.Error(), "target changed") { + t.Fatalf("Approve error = %v, want target-changed rejection", err) + } +} + +func TestListFiltersAndSortsReviewState(t *testing.T) { + manifest := state.New() + manifest.Set(state.Entry{Bundle: "b", Key: "z", Locale: "fr", ReviewStatus: state.ReviewNeedsReview}) + manifest.Set(state.Entry{Bundle: "a", Key: "a", Locale: "fr", ReviewStatus: state.ReviewNeedsReview}) + manifest.Set(state.Entry{Bundle: "a", Key: "a", Locale: "de", ReviewStatus: state.ReviewNeedsReview}) + + entries, err := List(manifest, Filter{Locale: "fr", Status: state.ReviewNeedsReview}) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 || entries[0].Bundle != "a" || entries[1].Bundle != "b" { + t.Fatalf("entries = %#v", entries) + } +} + +func reviewFixture(t *testing.T, target string) *config.Config { + t.Helper() + dir := t.TempDir() + sourcePath := filepath.Join(dir, "en.json") + targetPath := filepath.Join(dir, "fr.json") + if err := os.WriteFile(sourcePath, []byte(`{"save":"Save"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(targetPath, []byte(`{"save":"`+target+`"}`), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.Config{ + SourceLocale: "en", + TargetLocales: []string{"fr"}, + SourcePath: sourcePath, + ManifestPath: filepath.Join(dir, "manifest.json"), + LLM: config.LLM{Provider: "gemini", Model: "test-model"}, + } + cfg.ApplyDefaults() + 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(target), + Origin: "provider", + ReviewStatus: state.ReviewNeedsReview, + UpdatedAt: time.Now().UTC(), + }) + if err := manifest.Save(cfg.ManifestPath); err != nil { + t.Fatal(err) + } + return cfg +} diff --git a/internal/state/manifest.go b/internal/state/manifest.go index ff89270..db504ed 100644 --- a/internal/state/manifest.go +++ b/internal/state/manifest.go @@ -13,7 +13,20 @@ import ( "github.com/Tom-R-Main/Internationalizer/internal/locale" ) -const SchemaVersion = 1 +const ( + SchemaVersion = 2 + legacySchemaVersion = 1 +) + +// ReviewStatus records human approval independently from translation origin. +// Provider, TM, adoption, and manual provenance answer how a value arrived; +// review status answers whether a person approved that exact artifact. +type ReviewStatus string + +const ( + ReviewNeedsReview ReviewStatus = "needs_review" + ReviewApproved ReviewStatus = "approved" +) // Manifest is the versioned on-disk translation state. type Manifest struct { @@ -23,19 +36,21 @@ type Manifest struct { // Entry records the inputs and output for one bundle key and target locale. type Entry struct { - Bundle string `json:"bundle"` - Key string `json:"key"` - Locale string `json:"locale"` - SourceHash string `json:"source_hash"` - PolicyHash string `json:"policy_hash"` - GuideHash string `json:"guide_hash,omitempty"` - GlossaryHash string `json:"glossary_hash,omitempty"` - PromptVersion int `json:"prompt_version,omitempty"` - TargetHash string `json:"target_hash"` - Origin string `json:"origin,omitempty"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + Bundle string `json:"bundle"` + Key string `json:"key"` + Locale string `json:"locale"` + SourceHash string `json:"source_hash"` + PolicyHash string `json:"policy_hash"` + GuideHash string `json:"guide_hash,omitempty"` + GlossaryHash string `json:"glossary_hash,omitempty"` + PromptVersion int `json:"prompt_version,omitempty"` + TargetHash string `json:"target_hash"` + Origin string `json:"origin,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + ReviewStatus ReviewStatus `json:"review_status"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } // New returns an empty manifest using the current schema. @@ -61,15 +76,24 @@ func Load(path string) (*Manifest, error) { if err := json.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("parsing manifest %s: %w", path, err) } - if manifest.SchemaVersion != SchemaVersion { + if manifest.SchemaVersion != SchemaVersion && manifest.SchemaVersion != legacySchemaVersion { return nil, fmt.Errorf("manifest %s uses schema version %d; supported version is %d", path, manifest.SchemaVersion, SchemaVersion) } + legacy := manifest.SchemaVersion == legacySchemaVersion + manifest.SchemaVersion = SchemaVersion if manifest.Translations == nil { manifest.Translations = make(map[string]Entry) } canonicalTranslations := make(map[string]Entry, len(manifest.Translations)) for _, entry := range manifest.Translations { entry.Locale = canonicalLocaleOrOriginal(entry.Locale) + if legacy || entry.ReviewStatus == "" { + entry.ReviewStatus = ReviewNeedsReview + entry.ReviewedAt = nil + } + if err := validateReviewState(entry); err != nil { + return nil, fmt.Errorf("manifest %s contains invalid review state for %s/%s/%s: %w", path, entry.Bundle, entry.Key, entry.Locale, err) + } identity := Identity(entry.Bundle, entry.Key, entry.Locale) if existing, duplicate := canonicalTranslations[identity]; duplicate && existing != entry { return nil, fmt.Errorf("manifest %s contains conflicting entries for %s/%s/%s", path, entry.Bundle, entry.Key, entry.Locale) @@ -82,6 +106,12 @@ func Load(path string) (*Manifest, error) { // Save replaces the manifest only after the complete new file is durable. func (m *Manifest) Save(path string) error { + m.SchemaVersion = SchemaVersion + for _, entry := range m.Translations { + if err := validateReviewState(entry); err != nil { + return fmt.Errorf("saving manifest %s with invalid review state for %s/%s/%s: %w", path, entry.Bundle, entry.Key, entry.Locale, err) + } + } data, err := json.MarshalIndent(m, "", " ") if err != nil { return fmt.Errorf("encoding manifest: %w", err) @@ -101,9 +131,42 @@ func (m *Manifest) Set(entry Entry) { m.Translations = make(map[string]Entry) } entry.Locale = canonicalLocaleOrOriginal(entry.Locale) + if entry.ReviewStatus == "" { + entry.ReviewStatus = ReviewNeedsReview + entry.ReviewedAt = nil + } m.Translations[Identity(entry.Bundle, entry.Key, entry.Locale)] = entry } +// Approve marks the exact recorded artifact approved at reviewedAt. +func (m *Manifest) Approve(bundle, key, locale string, reviewedAt time.Time) (Entry, error) { + entry, ok := m.Get(bundle, key, locale) + if !ok { + return Entry{}, fmt.Errorf("translation %s/%s/%s is not tracked", bundle, key, locale) + } + stamp := reviewedAt.UTC() + entry.ReviewStatus = ReviewApproved + entry.ReviewedAt = &stamp + m.Set(entry) + return entry, nil +} + +func validateReviewState(entry Entry) error { + switch entry.ReviewStatus { + case ReviewNeedsReview: + if entry.ReviewedAt != nil { + return fmt.Errorf("needs_review entry has reviewed_at") + } + case ReviewApproved: + if entry.ReviewedAt == nil { + return fmt.Errorf("approved entry lacks reviewed_at") + } + default: + return fmt.Errorf("unknown review_status %q", entry.ReviewStatus) + } + return nil +} + // Identity returns a stable full SHA-256 identifier for a logical translation. func Identity(bundle, key, locale string) string { return hashBytes([]byte(bundle + "\x00" + key + "\x00" + canonicalLocaleOrOriginal(locale))) @@ -123,6 +186,16 @@ func SourceHash(format, source string) string { return hashBytes([]byte(canonical)) } +// SourceUnitHash also binds translator context and adapter-owned structure to +// provenance. Flat legacy units retain their existing SourceHash identity. +func SourceUnitHash(format, value, context, structure string) string { + if context == "" && structure == "" { + return SourceHash(format, value) + } + canonical := fmt.Sprintf("2:%d:%s:%d:%s:%d:%s:%d:%s", len(format), format, len(value), value, len(context), context, len(structure), structure) + return hashBytes([]byte(canonical)) +} + // TargetHash records the exact translated value last applied or adopted. func TargetHash(target string) string { return hashBytes([]byte(target)) diff --git a/internal/state/manifest_test.go b/internal/state/manifest_test.go index eafc7b9..69b290f 100644 --- a/internal/state/manifest_test.go +++ b/internal/state/manifest_test.go @@ -11,16 +11,17 @@ func TestManifestRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "state", "manifest.json") manifest := New() entry := Entry{ - Bundle: "app", - Key: "common.save", - Locale: "fr", - SourceHash: SourceHash("json", "Save"), - PolicyHash: mustHashValue(t, "policy"), - TargetHash: TargetHash("Enregistrer"), - Origin: "provider", - Provider: "openai", - Model: "model", - UpdatedAt: time.Now().UTC().Truncate(time.Second), + Bundle: "app", + Key: "common.save", + Locale: "fr", + SourceHash: SourceHash("json", "Save"), + PolicyHash: mustHashValue(t, "policy"), + TargetHash: TargetHash("Enregistrer"), + Origin: "provider", + Provider: "openai", + Model: "model", + ReviewStatus: ReviewNeedsReview, + UpdatedAt: time.Now().UTC().Truncate(time.Second), } manifest.Set(entry) if err := manifest.Save(path); err != nil { @@ -64,6 +65,34 @@ func TestLoadMigratesLegacyLocaleIdentityInMemory(t *testing.T) { if !ok || entry.Locale != "pt-BR" { t.Fatalf("legacy entry = %#v, %v; want canonicalized lookup", entry, ok) } + if manifest.SchemaVersion != SchemaVersion || entry.ReviewStatus != ReviewNeedsReview || entry.ReviewedAt != nil { + t.Fatalf("legacy review state = %#v, schema = %d; want needs_review schema v2", entry, manifest.SchemaVersion) + } +} + +func TestApproveRecordsExplicitReviewSeparatelyFromOrigin(t *testing.T) { + manifest := New() + manifest.Set(Entry{Bundle: "app", Key: "save", Locale: "fr", Origin: "provider"}) + reviewedAt := time.Date(2026, time.September, 4, 12, 0, 0, 0, time.FixedZone("EDT", -4*60*60)) + + approved, err := manifest.Approve("app", "save", "fr", reviewedAt) + if err != nil { + t.Fatal(err) + } + if approved.Origin != "provider" || approved.ReviewStatus != ReviewApproved || approved.ReviewedAt == nil || !approved.ReviewedAt.Equal(reviewedAt.UTC()) { + t.Fatalf("approved entry = %#v", approved) + } +} + +func TestLoadRejectsInvalidApprovedState(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest.json") + data := []byte(`{"schema_version":2,"translations":{"entry":{"bundle":"app","key":"save","locale":"fr","review_status":"approved","updated_at":"2026-01-01T00:00:00Z"}}}`) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("Load accepted approved state without reviewed_at") + } } func TestLoadRejectsConflictingCanonicalLocaleIdentities(t *testing.T) { @@ -86,6 +115,18 @@ func TestHashesSeparateSourceFormatAndPolicy(t *testing.T) { } } +func TestSourceUnitHashTracksTranslatorContextWithoutChangingFlatIdentity(t *testing.T) { + legacy := SourceHash("json", "Save") + if got := SourceUnitHash("json", "Save", "", ""); got != legacy { + t.Fatalf("flat unit hash = %q, want legacy %q", got, legacy) + } + first := SourceUnitHash("fluent", "Open", "Verb used on a button.", "fluent-pattern-v1") + second := SourceUnitHash("fluent", "Open", "Noun shown in a menu.", "fluent-pattern-v1") + if first == second { + t.Fatal("translator context did not affect source unit hash") + } +} + func TestManifestIdentityCanonicalizesLocale(t *testing.T) { manifest := New() entry := Entry{Bundle: "app", Key: "save", Locale: "pt-br"} diff --git a/internal/translate/translate.go b/internal/translate/translate.go index 5c8dd90..54aab98 100644 --- a/internal/translate/translate.go +++ b/internal/translate/translate.go @@ -64,10 +64,11 @@ func (e *RunError) Error() string { } type preparedBundle struct { - bundle config.Bundle - format formats.Format - sourceKeys map[string]string - sourceData []byte + bundle config.Bundle + format formats.Format + sourceUnits []formats.Unit + sourceKeys map[string]string + sourceData []byte } type job struct { @@ -227,11 +228,11 @@ func prepareBundles(bundles []config.Bundle) ([]preparedBundle, error) { if err != nil { return nil, fmt.Errorf("reading bundle %q source %s: %w", bundle.ID, bundle.Source, err) } - keys, err := format.Parse(data) + units, err := formats.ParseUnits(format, data) if err != nil { return nil, fmt.Errorf("parsing bundle %q source: %w", bundle.ID, err) } - prepared = append(prepared, preparedBundle{bundle: bundle, format: format, sourceKeys: keys, sourceData: data}) + prepared = append(prepared, preparedBundle{bundle: bundle, format: format, sourceUnits: units, sourceKeys: formats.UnitValues(units), sourceData: data}) } return prepared, nil } @@ -298,6 +299,7 @@ func translateLocale( policyHash := translationPolicy.Hash targetKeys := make(map[string]string) + var targetUnits []formats.Unit var targetData []byte targetExists := false data, err := os.ReadFile(targetPath) @@ -305,7 +307,12 @@ func translateLocale( case err == nil: targetExists = true targetData = data - targetKeys, err = parseTarget(bundle.format, bundle.sourceData, data) + if paired, ok := bundle.format.(formats.PairedFormat); ok { + targetKeys, err = paired.ParseTarget(bundle.sourceData, data) + } else { + targetUnits, err = formats.ParseUnits(bundle.format, data) + targetKeys = formats.UnitValues(targetUnits) + } if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("parsing target %s: %v", targetPath, err)) return jobOutput{result: result} @@ -317,14 +324,19 @@ func translateLocale( } keys := sortedKeys(sourceKeys) + sourceUnits := make(map[string]formats.Unit, len(bundle.sourceUnits)) + for _, unit := range bundle.sourceUnits { + sourceUnits[unit.ID] = unit + } plans := make([]plannedEntry, 0, len(keys)) for _, key := range keys { sourceValue := sourceKeys[key] - sourceHash := state.SourceHash(bundle.format.Name(), sourceValue) + sourceUnit := sourceUnits[key] + sourceHash := state.SourceUnitHash(bundle.format.Name(), sourceValue, sourceUnit.Context, sourceUnit.Structure) targetValue, exists := targetKeys[key] recorded, recordedOK := manifest.Get(bundle.bundle.ID, key, locale) entryState := classify(exists, targetValue, sourceHash, policyHash, recorded, recordedOK) - plans = append(plans, plannedEntry{key: key, source: sourceValue, sourceHash: sourceHash, state: entryState}) + plans = append(plans, plannedEntry{key: key, source: sourceValue, context: sourceUnit.Context, sourceHash: sourceHash, state: entryState}) result.addState(entryState) } if opts.AdoptExisting { @@ -387,7 +399,7 @@ func translateLocale( batchPlans := toTranslate[i:end] entries := make([]llm.Entry, len(batchPlans)) for j, plan := range batchPlans { - entries[j] = llm.Entry{Key: plan.key, Value: plan.source} + entries[j] = llm.Entry{Key: plan.key, Value: plan.source, Context: plan.context} } response, err := provider.Translate(ctx, llm.TranslateRequest{ @@ -452,7 +464,17 @@ func translateLocale( } } } - output, err := serializeTarget(bundle.format, staged, bundle.sourceData, serializationBaseline) + var output []byte + if paired, ok := bundle.format.(formats.PairedFormat); ok { + output, err = paired.SerializeTarget(staged, bundle.sourceData, serializationBaseline) + } else { + unitBaseline := targetUnits + if !targetExists { + unitBaseline = bundle.sourceUnits + } + stagedUnits := formats.MergeUnitValues(unitBaseline, bundle.sourceUnits, staged) + output, err = formats.SerializeUnits(bundle.format, stagedUnits, serializationBaseline) + } if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("serializing target %s: %v", targetPath, err)) return jobOutput{result: result} @@ -510,6 +532,7 @@ func translateLocale( Origin: origin.kind, Provider: origin.provider, Model: origin.model, + ReviewStatus: state.ReviewNeedsReview, UpdatedAt: now, }) } @@ -524,13 +547,6 @@ func parseTarget(format formats.Format, source, target []byte) (map[string]strin return format.Parse(target) } -func serializeTarget(format formats.Format, entries map[string]string, source, target []byte) ([]byte, error) { - if paired, ok := format.(formats.PairedFormat); ok { - return paired.SerializeTarget(entries, source, target) - } - return format.Serialize(entries, target) -} - type entryState struct { missing bool sourceStale bool @@ -553,6 +569,7 @@ type translationOrigin struct { type plannedEntry struct { key string source string + context string sourceHash string state entryState } diff --git a/internal/translate/translate_test.go b/internal/translate/translate_test.go index 100280d..0195ba0 100644 --- a/internal/translate/translate_test.go +++ b/internal/translate/translate_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "time" @@ -36,6 +37,67 @@ func (p *fakeProvider) Translate(_ context.Context, request llm.TranslateRequest return p.response, nil } +func TestRunTranslatesFluentUnitsWithDeveloperContext(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "en.ftl") + source := "# A command, not a noun.\nopen-button = Open { -brand-short-name }\n .aria-label = Open the application\n\n-brand-short-name = Acme\n" + if err := os.WriteFile(sourcePath, []byte(source), 0o644); err != nil { + t.Fatal(err) + } + cfg := testConfig(dir, sourcePath) + provider := &fakeProvider{response: &llm.TranslateResponse{Translations: map[string]string{ + "open-button": "Ouvrir { -brand-short-name }", + "open-button.aria-label": "Ouvrir l’application", + "-brand-short-name": "Acme", + }}} + + if _, err := Run(context.Background(), cfg, provider, Options{}); err != nil { + t.Fatal(err) + } + if len(provider.requests) != 1 { + t.Fatalf("provider requests = %#v, want one request", provider.requests) + } + context := "" + for _, entry := range provider.requests[0].Entries { + if entry.Key == "open-button" { + context = entry.Context + } + } + if len(provider.requests[0].Entries) != 3 || context != "# A command, not a noun." { + t.Fatalf("provider requests = %#v", provider.requests) + } + if !strings.Contains(provider.requests[0].SystemPrompt, "Fluent variables") { + t.Fatalf("provider did not receive Fluent prompt: %s", provider.requests[0].SystemPrompt) + } + output, err := os.ReadFile(filepath.Join(dir, "fr.ftl")) + if err != nil { + t.Fatal(err) + } + text := string(output) + for _, expected := range []string{"# A command, not a noun.", "open-button = Ouvrir { -brand-short-name }", ".aria-label = Ouvrir l’application", "-brand-short-name = Acme"} { + if !strings.Contains(text, expected) { + t.Fatalf("Fluent output lacks %q:\n%s", expected, text) + } + } + changedContext := strings.Replace(source, "# A command, not a noun.", "# A noun shown in the File menu.", 1) + if err := os.WriteFile(sourcePath, []byte(changedContext), 0o644); err != nil { + t.Fatal(err) + } + reports, err := validation.ValidateWithOptions(cfg, validation.Options{RequireState: true}) + if err != nil { + t.Fatal(err) + } + foundStale := false + for _, finding := range reports[0].Findings { + if finding.Key == "open-button" && finding.Code == validation.CodeSourceStale { + foundStale = true + } + } + if !foundStale { + t.Fatalf("developer-context change did not stale provenance: %#v", reports[0].Findings) + } +} + func TestRunRejectsIncompleteProviderResponseWithoutWritingTarget(t *testing.T) { dir := t.TempDir() sourcePath := filepath.Join(dir, "en.json") diff --git a/internal/validate/findings.go b/internal/validate/findings.go index c446101..1be3c32 100644 --- a/internal/validate/findings.go +++ b/internal/validate/findings.go @@ -20,6 +20,7 @@ const ( CodeSourceStale FindingCode = "source_stale" CodePolicyStale FindingCode = "policy_stale" CodeTargetModified FindingCode = "target_modified" + CodeNeedsReview FindingCode = "needs_review" ) // Severity determines whether a finding fails validation. diff --git a/internal/validate/markup.go b/internal/validate/markup.go new file mode 100644 index 0000000..b76376c --- /dev/null +++ b/internal/validate/markup.go @@ -0,0 +1,326 @@ +package validate + +import ( + "fmt" + "sort" + "strings" + "unicode" +) + +const localizationNameAttribute = "data-l10n-name" + +type markupToken struct { + raw string + name string + attributes []markupAttribute + closing bool + selfClosing bool + comment bool + valid bool +} + +type markupAttribute struct { + name string + value string + hasValue bool +} + +type markupFrame struct { + tag string + slot string + owner string +} + +// extractHTMLStructure preserves ordinary HTML structure in order while +// comparing data-l10n-name elements by semantic identity. This permits a +// translator to move named rich-text slots without changing their element, +// protected attributes, nesting, or contained markup. +func extractHTMLStructure(input string) []string { + tokens := scanMarkup(input) + root := make([]string, 0, len(tokens)) + named := make([]string, 0, len(tokens)) + bodies := make(map[string][]string) + stack := make([]markupFrame, 0) + + appendOwned := func(owner, value string) { + if owner == "" { + root = append(root, value) + return + } + bodies[owner] = append(bodies[owner], value) + } + currentOwner := func() string { + for index := len(stack) - 1; index >= 0; index-- { + if stack[index].slot != "" { + return stack[index].slot + } + } + return "" + } + + for _, token := range tokens { + owner := currentOwner() + if token.comment { + appendOwned(owner, "comment:"+token.raw) + continue + } + if !token.valid { + appendOwned(owner, "invalid:"+token.raw) + continue + } + if token.closing { + if len(stack) == 0 || stack[len(stack)-1].tag != token.name { + appendOwned(owner, "unmatched-close:"+token.canonical()) + continue + } + frame := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if frame.slot != "" { + named = append(named, fmt.Sprintf("slot:%s:parent:%s:close:%s", frame.slot, frame.owner, token.name)) + } else { + appendOwned(owner, "close:"+token.name) + } + continue + } + + slot := token.attribute(localizationNameAttribute) + if slot != "" { + named = append(named, fmt.Sprintf("slot:%s:parent:%s:open:%s", slot, owner, token.canonical())) + if owner != "" { + appendOwned(owner, "child-slot:"+slot) + } + if !token.selfClosing && !isVoidElement(token.name) { + stack = append(stack, markupFrame{tag: token.name, slot: slot, owner: owner}) + } + continue + } + + appendOwned(owner, "open:"+token.canonical()) + if !token.selfClosing && !isVoidElement(token.name) { + stack = append(stack, markupFrame{tag: token.name, owner: owner}) + } + } + + for len(stack) > 0 { + frame := stack[len(stack)-1] + stack = stack[:len(stack)-1] + owner := frame.owner + if frame.slot != "" { + owner = frame.slot + } + appendOwned(owner, "unclosed:"+frame.tag) + } + + slotNames := make([]string, 0, len(bodies)) + for slot := range bodies { + slotNames = append(slotNames, slot) + } + sort.Strings(slotNames) + structure := append([]string(nil), root...) + for _, slot := range slotNames { + for index, value := range bodies[slot] { + structure = append(structure, fmt.Sprintf("slot-body:%s:%08d:%s", slot, index, value)) + } + } + sort.Strings(named) + return append(structure, named...) +} + +func scanMarkup(input string) []markupToken { + var tokens []markupToken + for offset := 0; offset < len(input); { + relative := strings.IndexByte(input[offset:], '<') + if relative < 0 { + break + } + start := offset + relative + if strings.HasPrefix(input[start:], "") + if end < 0 { + break + } + end += start + 7 + tokens = append(tokens, markupToken{raw: input[start:end], comment: true, valid: true}) + offset = end + continue + } + cursor := start + 1 + if cursor < len(input) && input[cursor] == '/' { + cursor++ + } + if cursor >= len(input) || !isMarkupNameStart(rune(input[cursor])) { + offset = start + 1 + continue + } + end := markupTagEnd(input, cursor) + if end < 0 { + break + } + raw := input[start : end+1] + token := parseMarkupToken(raw) + token.raw = raw + tokens = append(tokens, token) + offset = end + 1 + } + return tokens +} + +func markupTagEnd(input string, start int) int { + var quote byte + for index := start; index < len(input); index++ { + character := input[index] + if quote != 0 { + if character == quote { + quote = 0 + } + continue + } + if character == '\'' || character == '"' { + quote = character + continue + } + if character == '>' { + return index + } + } + return -1 +} + +func parseMarkupToken(raw string) markupToken { + inside := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "<"), ">")) + token := markupToken{valid: true} + if strings.HasPrefix(inside, "/") { + token.closing = true + inside = strings.TrimSpace(inside[1:]) + } + if strings.HasSuffix(inside, "/") { + token.selfClosing = true + inside = strings.TrimSpace(inside[:len(inside)-1]) + } + nameEnd := 0 + for nameEnd < len(inside) && isMarkupNameRune(rune(inside[nameEnd])) { + nameEnd++ + } + if nameEnd == 0 { + token.valid = false + return token + } + token.name = strings.ToLower(inside[:nameEnd]) + remainder := inside[nameEnd:] + if token.closing { + token.valid = strings.TrimSpace(remainder) == "" && !token.selfClosing + return token + } + + seen := make(map[string]struct{}) + for position := 0; ; { + position = skipMarkupSpace(remainder, position) + if position >= len(remainder) { + break + } + start := position + for position < len(remainder) && isMarkupAttributeRune(rune(remainder[position])) { + position++ + } + if position == start { + token.valid = false + return token + } + attribute := markupAttribute{name: strings.ToLower(remainder[start:position])} + position = skipMarkupSpace(remainder, position) + if position < len(remainder) && remainder[position] == '=' { + attribute.hasValue = true + position++ + position = skipMarkupSpace(remainder, position) + if position >= len(remainder) { + token.valid = false + return token + } + if remainder[position] == '\'' || remainder[position] == '"' { + quote := remainder[position] + position++ + valueStart := position + for position < len(remainder) && remainder[position] != quote { + position++ + } + if position >= len(remainder) { + token.valid = false + return token + } + attribute.value = remainder[valueStart:position] + position++ + } else { + valueStart := position + for position < len(remainder) && !unicode.IsSpace(rune(remainder[position])) { + position++ + } + attribute.value = remainder[valueStart:position] + } + } + if _, duplicate := seen[attribute.name]; duplicate { + token.valid = false + return token + } + seen[attribute.name] = struct{}{} + token.attributes = append(token.attributes, attribute) + } + return token +} + +func (token markupToken) attribute(name string) string { + for _, attribute := range token.attributes { + if attribute.name == name && attribute.hasValue { + return attribute.value + } + } + return "" +} + +func (token markupToken) canonical() string { + if token.closing { + return "/" + token.name + } + attributes := append([]markupAttribute(nil), token.attributes...) + sort.Slice(attributes, func(left, right int) bool { return attributes[left].name < attributes[right].name }) + var result strings.Builder + result.WriteString(token.name) + for _, attribute := range attributes { + result.WriteByte('|') + result.WriteString(attribute.name) + if attribute.hasValue { + fmt.Fprintf(&result, "=%d:%s", len(attribute.value), attribute.value) + } + } + if token.selfClosing { + result.WriteString("|/") + } + return result.String() +} + +func skipMarkupSpace(input string, position int) int { + for position < len(input) && unicode.IsSpace(rune(input[position])) { + position++ + } + return position +} + +func isMarkupNameStart(character rune) bool { + return unicode.IsLetter(character) +} + +func isMarkupNameRune(character rune) bool { + return unicode.IsLetter(character) || unicode.IsDigit(character) || character == ':' || character == '-' +} + +func isMarkupAttributeRune(character rune) bool { + return isMarkupNameRune(character) || character == '_' || character == '.' +} + +func isVoidElement(name string) bool { + switch name { + case "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr": + return true + default: + return false + } +} diff --git a/internal/validate/protected.go b/internal/validate/protected.go index 1a20495..4415797 100644 --- a/internal/validate/protected.go +++ b/internal/validate/protected.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" + "github.com/Tom-R-Main/Internationalizer/internal/fluentpattern" "github.com/Tom-R-Main/Internationalizer/internal/message" ) @@ -41,6 +42,15 @@ func protectedFindings(key, source, target, targetLocale, sourcePath, targetPath if mismatch := InterpolationMismatch(key, source, target); mismatch != nil { findings = append(findings, protectedFinding(key, "interpolation variables", mismatch.SourceVars, mismatch.TargetVars)) } + if fluentpattern.LooksLike(source) || fluentpattern.LooksLike(target) { + expected, actual, preserved, err := fluentpattern.Compare(source, target) + if err != nil { + actual = []string{err.Error()} + } + if err != nil || !preserved { + findings = append(findings, protectedFinding(key, "Fluent pattern", expected, actual)) + } + } } checks := []struct { name string @@ -88,7 +98,7 @@ func protectedFindings(key, source, target, targetLocale, sourcePath, targetPath } func extractDocumentHTMLTags(input, documentPath string) []string { - tags := extractHTMLTags(input) + tags := htmlTagRe.FindAllString(input, -1) for index, tag := range tags { tags[index] = htmlPathAttrRe.ReplaceAllStringFunc(tag, func(attribute string) string { match := htmlPathAttrRe.FindStringSubmatch(attribute) @@ -144,7 +154,7 @@ func resolveDocumentDestination(destination, documentPath string) string { } func extractHTMLTags(input string) []string { - return htmlTagRe.FindAllString(input, -1) + return extractHTMLStructure(input) } func extractInlineCode(input string) []string { diff --git a/internal/validate/strict_test.go b/internal/validate/strict_test.go index 7a61fc8..7939f82 100644 --- a/internal/validate/strict_test.go +++ b/internal/validate/strict_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "reflect" "sort" + "strings" "testing" "time" @@ -318,6 +319,49 @@ func TestRequireStateReportsAndClearsBoundProvenanceFindings(t *testing.T) { assertFindingCodes(t, reports[0], CodePolicyStale, CodeSourceStale, CodeTargetModified) } +func TestRequireApprovedRequiresExplicitReview(t *testing.T) { + cfg := validationConfig(t, map[string]string{"save": "Save"}, map[string]string{"save": "Enregistrer"}) + 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"), + Origin: "provider", + ReviewStatus: state.ReviewNeedsReview, + UpdatedAt: time.Now().UTC(), + }) + if err := manifest.Save(cfg.ManifestPath); err != nil { + t.Fatal(err) + } + + reports, err := ValidateWithOptions(cfg, Options{RequireApproved: true}) + if err != nil { + t.Fatal(err) + } + assertFindingCodes(t, reports[0], CodeNeedsReview) + + if _, err := manifest.Approve("default", "save", "fr", time.Now()); err != nil { + t.Fatal(err) + } + if err := manifest.Save(cfg.ManifestPath); err != nil { + t.Fatal(err) + } + reports, err = ValidateWithOptions(cfg, Options{RequireApproved: true}) + if err != nil { + t.Fatal(err) + } + if HasFailures(reports) { + t.Fatalf("approved current translation failed: %#v", reports[0]) + } +} + 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 { @@ -422,6 +466,51 @@ func TestProtectedFindingsCarryContextIntoNestedICUBranches(t *testing.T) { } } +func TestProtectedFindingsAllowNamedMarkupSlotsToMove(t *testing.T) { + source := `Read the guide or .` + target := `Vous pouvez ou lire le guide.` + if findings := ProtectedFindings("actions", source, target, "fr"); len(findings) != 0 { + t.Fatalf("valid named markup reordering produced findings: %#v", findings) + } +} + +func TestProtectedFindingsRejectDamageToNamedMarkupSlots(t *testing.T) { + source := `Read the guide.` + tests := map[string]string{ + "changed protected attribute": `Lire le guide.`, + "changed element": `Lire .`, + "changed contained markup": `Lire le guide.`, + "changed slot name": `Lire le guide.`, + "unbalanced markup": `Lire le guide.`, + } + for name, target := range tests { + t.Run(name, func(t *testing.T) { + if findings := ProtectedFindings("docs", source, target, "fr"); len(findings) == 0 { + t.Fatal("damaged named markup was accepted") + } + }) + } +} + +func TestProtectedFindingsValidateFluentPatterns(t *testing.T) { + source := `{ $count -> + [one] One message for { $user } + *[other] { $count } messages for { $user } +}` + valid := `{ $count -> + [one] Un message pour { $user } + [many] { $count } messages pour { $user } + *[other] { $count } messages pour { $user } +}` + if findings := ProtectedFindings("messages", source, valid, "fr"); len(findings) != 0 { + t.Fatalf("valid Fluent target produced findings: %#v", findings) + } + damaged := strings.Replace(valid, "{ $user }", "{ $account }", 1) + if findings := ProtectedFindings("messages", source, damaged, "fr"); len(findings) == 0 { + t.Fatal("damaged Fluent target was accepted") + } +} + func TestEvaluationCorpusSchemaAndIDsAreStable(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "test", "evaluation", "v1", "cases.json")) if err != nil { diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 471d002..57c9910 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -20,8 +20,9 @@ import ( // Options enables validation contracts that are intentionally opt-in during // their compatibility period. type Options struct { - Strict bool - RequireState bool + Strict bool + RequireState bool + RequireApproved bool } // Report holds validation results for a single locale. Coverage remains a @@ -56,6 +57,9 @@ func Validate(cfg *config.Config) ([]Report, error) { // ValidateWithOptions checks all target locales against the source locale. func ValidateWithOptions(cfg *config.Config, opts Options) ([]Report, error) { + if opts.RequireApproved { + opts.RequireState = true + } effectiveConfig := *cfg effectiveConfig.ApplyDefaults() cfg = &effectiveConfig @@ -82,10 +86,11 @@ func ValidateWithOptions(cfg *config.Config, opts Options) ([]Report, error) { if err != nil { return nil, fmt.Errorf("reading bundle %q source %s: %w", bundle.ID, bundle.Source, err) } - sourceKeys, err := format.Parse(sourceData) + sourceUnits, err := formats.ParseUnits(format, sourceData) if err != nil { return nil, fmt.Errorf("parsing bundle %q source: %w", bundle.ID, err) } + sourceKeys := formats.UnitValues(sourceUnits) for _, locale := range cfg.TargetLocales { targetPath, err := bundle.TargetPath(locale) if err != nil { @@ -113,7 +118,7 @@ func ValidateWithOptions(cfg *config.Config, opts Options) ([]Report, error) { policyHash = resolved.Hash } - report := validateLocale(bundle.ID, cfg.SourceLocale, locale, bundle.Source, sourceData, sourceKeys, targetPath, format, terms, manifest, policyHash, cfg.Validation.PluralStyle, opts) + report := validateLocale(bundle.ID, cfg.SourceLocale, locale, bundle.Source, sourceData, sourceUnits, sourceKeys, targetPath, format, terms, manifest, policyHash, cfg.Validation.PluralStyle, opts) reports = append(reports, report) } } @@ -127,7 +132,7 @@ func formatForBundle(bundle config.Bundle) (formats.Format, error) { return formats.FormatForFile(bundle.Source) } -func validateLocale(bundle, sourceLocale, locale, sourcePath string, sourceData []byte, sourceKeys map[string]string, targetPath string, format formats.Format, terms []glossary.Term, manifest *state.Manifest, policyHash, pluralStyle string, opts Options) Report { +func validateLocale(bundle, sourceLocale, locale, sourcePath string, sourceData []byte, sourceUnits []formats.Unit, 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 @@ -172,6 +177,10 @@ func validateLocale(bundle, sourceLocale, locale, sourcePath string, sourceData sortFindings(report.Findings) return report } + sourceUnitsByID := make(map[string]formats.Unit, len(sourceUnits)) + for _, unit := range sourceUnits { + sourceUnitsByID[unit.ID] = unit + } present := 0 translated := 0 @@ -225,7 +234,9 @@ func validateLocale(bundle, sourceLocale, locale, sourcePath string, sourceData 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)...) + sourceUnit := sourceUnitsByID[key] + sourceUnit.Value = sourceValue + report.Findings = append(report.Findings, provenanceFindings(bundle, key, locale, format.Name(), sourceUnit, targetValue, policyHash, manifest, opts.RequireApproved)...) } } @@ -304,13 +315,13 @@ func missingFinding(key string, requiredPluralKeys map[string]struct{}) Finding 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 { +func provenanceFindings(bundle, key, locale, format string, source formats.Unit, target, policyHash string, manifest *state.Manifest, requireApproved bool) []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) { + if recorded.SourceHash != state.SourceUnitHash(format, source.Value, source.Context, source.Structure) { findings = append(findings, Finding{Code: CodeSourceStale, Severity: SeverityError, Key: key, Message: "source changed after the recorded translation"}) } if recorded.PolicyHash != policyHash { @@ -319,6 +330,9 @@ func provenanceFindings(bundle, key, locale, format, source, target, policyHash if recorded.TargetHash != state.TargetHash(target) { findings = append(findings, Finding{Code: CodeTargetModified, Severity: SeverityError, Key: key, Message: "target changed after the recorded translation"}) } + if requireApproved && recorded.ReviewStatus != state.ReviewApproved { + findings = append(findings, Finding{Code: CodeNeedsReview, Severity: SeverityError, Key: key, Message: "translation has not been explicitly approved"}) + } return findings } diff --git a/package.json b/package.json index 39f7e1e..6ccee5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer", - "version": "0.1.1", + "version": "0.1.2", "description": "AI-native internationalization CLI for software projects", "license": "AGPL-3.0-only", "bin": { @@ -16,11 +16,11 @@ "node": ">=18" }, "optionalDependencies": { - "internationalizer-darwin-arm64": "0.1.1", - "internationalizer-darwin-x64": "0.1.1", - "internationalizer-linux-arm64": "0.1.1", - "internationalizer-linux-x64": "0.1.1", - "internationalizer-win32-x64": "0.1.1" + "internationalizer-darwin-arm64": "0.1.2", + "internationalizer-darwin-x64": "0.1.2", + "internationalizer-linux-arm64": "0.1.2", + "internationalizer-linux-x64": "0.1.2", + "internationalizer-win32-x64": "0.1.2" }, "scripts": { "test:npm-wrapper": "node --test test/npm-wrapper.test.cjs", diff --git a/packages/darwin-arm64/package.json b/packages/darwin-arm64/package.json index 48a01ae..9901c0a 100644 --- a/packages/darwin-arm64/package.json +++ b/packages/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-darwin-arm64", - "version": "0.1.1", + "version": "0.1.2", "description": "darwin arm64 binary for the internationalizer CLI", "license": "AGPL-3.0-only", "os": [ diff --git a/packages/darwin-x64/package.json b/packages/darwin-x64/package.json index 97a8bd0..fe1d72e 100644 --- a/packages/darwin-x64/package.json +++ b/packages/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-darwin-x64", - "version": "0.1.1", + "version": "0.1.2", "description": "darwin x64 binary for the internationalizer CLI", "license": "AGPL-3.0-only", "os": [ diff --git a/packages/linux-arm64/package.json b/packages/linux-arm64/package.json index d7db2a7..48a85ee 100644 --- a/packages/linux-arm64/package.json +++ b/packages/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-linux-arm64", - "version": "0.1.1", + "version": "0.1.2", "description": "linux arm64 binary for the internationalizer CLI", "license": "AGPL-3.0-only", "os": [ diff --git a/packages/linux-x64/package.json b/packages/linux-x64/package.json index 33a011a..9728090 100644 --- a/packages/linux-x64/package.json +++ b/packages/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-linux-x64", - "version": "0.1.1", + "version": "0.1.2", "description": "linux x64 binary for the internationalizer CLI", "license": "AGPL-3.0-only", "os": [ diff --git a/packages/win32-x64/package.json b/packages/win32-x64/package.json index 973d4c6..105b62d 100644 --- a/packages/win32-x64/package.json +++ b/packages/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-win32-x64", - "version": "0.1.1", + "version": "0.1.2", "description": "win32 x64 binary for the internationalizer CLI", "license": "AGPL-3.0-only", "os": [ diff --git a/test/acceptance/acceptance_test.go b/test/acceptance/acceptance_test.go index b2a0be1..0c6f069 100644 --- a/test/acceptance/acceptance_test.go +++ b/test/acceptance/acceptance_test.go @@ -202,6 +202,81 @@ manifest_path: .internationalizer.lock } } +func TestFluentPseudoAndExplicitReviewLifecycle(t *testing.T) { + projectDir := t.TempDir() + mustWriteFile(t, filepath.Join(projectDir, "locales", "en.ftl"), `# Link shown below the item count. +items = + { $count -> + [one] One item + *[other] { $count } items + } +learn-more = See the documentation. +save-button = + .label = Save +`) + mustWriteFile(t, filepath.Join(projectDir, "locales", "fr.ftl"), `# Link shown below the item count. +items = + { $count -> + [one] Un élément + *[other] { $count } éléments + } +learn-more = Consultez la documentation. +save-button = + .label = Enregistrer +`) + mustWriteFile(t, filepath.Join(projectDir, ".internationalizer.yml"), `source_locale: en +target_locales: [fr] +bundles: + - id: browser + source: locales/en.ftl + target: locales/{locale}.ftl + format: fluent +manifest_path: .internationalizer.lock +`) + + adopt := runCLI(t, projectDir, nil, "translate", "--adopt-existing") + adopt.requireSuccess(t) + listed := runCLI(t, projectDir, nil, "review", "list", "--status", "needs_review") + listed.requireSuccess(t) + if got := strings.Count(listed.stdout, "needs_review"); got != 3 { + t.Fatalf("needs-review entries = %d, want 3:\n%s", got, listed.stdout) + } + + required := runCLI(t, projectDir, nil, "validate", "--require-approved", "--json") + if required.exitCode != 1 || !strings.Contains(required.stdout, `"code": "needs_review"`) { + t.Fatalf("unapproved validation = exit %d:\n%s\n%s", required.exitCode, required.stdout, required.stderr) + } + approve := runCLI(t, projectDir, nil, "review", "approve", "--locale", "fr", "--all") + approve.requireSuccess(t) + approved := runCLI(t, projectDir, nil, "validate", "--require-approved", "--json") + approved.requireSuccess(t) + + pseudo := runCLI(t, projectDir, nil, "pseudo", "--strategy", "accented") + pseudo.requireSuccess(t) + pseudoOutput := string(mustReadFile(t, filepath.Join(projectDir, "locales", "en-XA.ftl"))) + for _, protected := range []string{"{ $count ->", "*[other]", "{ $count }", `data-l10n-name="docs"`, ".label ="} { + if !strings.Contains(pseudoOutput, protected) { + t.Fatalf("pseudo Fluent output lost %q:\n%s", protected, pseudoOutput) + } + } + manifest, err := state.Load(filepath.Join(projectDir, ".internationalizer.lock")) + if err != nil { + t.Fatal(err) + } + for _, entry := range manifest.Translations { + switch entry.Locale { + case "fr": + if entry.ReviewStatus != state.ReviewApproved { + t.Fatalf("French entry was not approved: %#v", entry) + } + case "en-XA": + if entry.Origin != "pseudo" || entry.ReviewStatus != state.ReviewNeedsReview { + t.Fatalf("pseudo entry provenance = %#v", entry) + } + } + } +} + func TestOpenAITranslationExercisesResponsesContractAndPreservesSourceShape(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {