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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Changelog

## Unreleased
## 0.2.1 - 2026-09-04

- Retarget existing bundles with `config plan --update-bundle ID --target ID=TEMPLATE`, preserving bundle identities and omitted settings.
- Explain rejected symlink paths with bundle and locale context and, where safely available, an in-project destination to review. Explicit retargeting can repair the configuration without following or changing old links.
- Reject duplicate JSON object members and flattened-key collisions before catalog content can be lost. These integrity checks apply with or without `--strict`, including LLM translation responses and catalog rewrite operations.
- Report malformed catalogs during discovery and configuration checks. JSON errors identify conflicting member paths without printing translation values.
- Refuse ambiguous existing JSON pseudolocale targets even with `--force`; repair the catalog before replacing it.
- Append missing JSON array entries in numeric index order, including nested and root arrays, while preserving existing object and dotted-key identities.

## 0.2.0 - 2026-09-04

Expand Down
26 changes: 21 additions & 5 deletions cmd/internationalizer/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func assignments(values []string) (map[string]string, error) {

func newConfigPlanCmd() *cobra.Command {
var path, out, sourceLocale string
var additions, syntaxes, targets, confirm, locales []string
var additions, updates, syntaxes, targets, confirm, locales []string
var asJSON bool
cmd := &cobra.Command{Use: "plan", Short: "Propose a reviewable config change; never apply it", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
adds, err := assignments(additions)
Expand All @@ -53,13 +53,28 @@ func newConfigPlanCmd() *cobra.Command {
if err != nil {
return err
}
opts := onboarding.PlanOptions{Syntax: map[string]message.Syntax{}, ConfirmSources: confirm, SourceLocale: sourceLocale, TargetLocales: locales}
opts := onboarding.PlanOptions{UpdateTargets: map[string]string{}, Syntax: map[string]message.Syntax{}, ConfirmSources: confirm, SourceLocale: sourceLocale, TargetLocales: locales}
for _, id := range updates {
if id == "" {
return fmt.Errorf("--update-bundle requires an existing bundle ID")
}
if _, duplicate := opts.UpdateTargets[id]; duplicate {
return fmt.Errorf("duplicate update decision for %q", id)
}
if _, adding := adds[id]; adding {
return fmt.Errorf("bundle %q cannot be both added and updated", id)
}
if targetMap[id] == "" {
return fmt.Errorf("--update-bundle %s requires --target %s=path/{locale}.json", id, id)
}
opts.UpdateTargets[id] = targetMap[id]
}
for id, mode := range modes {
opts.Syntax[id] = message.Syntax(mode)
}
for id := range targetMap {
if _, ok := adds[id]; !ok {
return fmt.Errorf("--target %s requires --add-bundle %s=source", id, id)
if _, ok := adds[id]; !ok && opts.UpdateTargets[id] == "" {
return fmt.Errorf("--target %s requires --add-bundle %s=source or --update-bundle %s", id, id, id)
}
}
if len(adds) > 0 {
Expand Down Expand Up @@ -138,8 +153,9 @@ func newConfigPlanCmd() *cobra.Command {
cmd.Flags().StringVar(&path, "config", "", "Configuration path")
cmd.Flags().StringVar(&out, "out", "", "Save plan to a new file (never overwrites)")
cmd.Flags().StringArrayVar(&additions, "add-bundle", nil, "Explicit bundle ID=discovered-source-path (repeatable)")
cmd.Flags().StringArrayVar(&updates, "update-bundle", nil, "Explicit existing bundle ID to retarget with --target (repeatable)")
cmd.Flags().StringArrayVar(&syntaxes, "syntax", nil, "Explicit bundle ID=plain|i18next|icu|auto (repeatable)")
cmd.Flags().StringArrayVar(&targets, "target", nil, "Target override for added bundle ID=path/{locale}.json")
cmd.Flags().StringArrayVar(&targets, "target", nil, "Explicit target for added or updated bundle ID=path/{locale}.json (repeatable)")
cmd.Flags().StringArrayVar(&confirm, "confirm-source", nil, "Confirm authoritative source path, including tmp/ (repeatable)")
cmd.Flags().StringVar(&sourceLocale, "source-locale", "", "Explicit source locale (existing setting preserved when omitted)")
cmd.Flags().StringArrayVar(&locales, "locale", nil, "Explicit target locale set (repeatable; existing set preserved when omitted)")
Expand Down
149 changes: 149 additions & 0 deletions cmd/internationalizer/config_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package main

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"gopkg.in/yaml.v3"
)

func TestConfigPlanRepeatedUpdatesPreserveUntouchedBundle(t *testing.T) {
root := t.TempDir()
t.Chdir(root)
original := []byte(`# preserve project comment
source_locale: en
target_locales: [fr, ja]
message_syntax: plain
llm:
provider: openai
locale_overrides:
ja:
model: preserved-model
glossary_dir: custom/glossary
future_setting: retained
bundles:
- id: marketing
source: marketing.json
target: old-marketing/{locale}.json
format: json
future_setting: marketing-value
- id: web
source: web.json
target: old-web/{locale}.json
message_syntax: i18next
- id: mobile # untouched bundle comment
source: mobile.json
target: mobile/{locale}.json
message_syntax: plain
future_setting: mobile-value
`)
if err := os.WriteFile(filepath.Join(root, ".internationalizer.yml"), original, 0600); err != nil {
t.Fatal(err)
}
for _, name := range []string{"marketing.json", "web.json", "mobile.json"} {
if err := os.WriteFile(filepath.Join(root, name), []byte(`{"hello":"Hello"}`), 0600); err != nil {
t.Fatal(err)
}
}
command := newRootCmd()
var out bytes.Buffer
command.SetOut(&out)
command.SetErr(&out)
if err := execute(command, []string{"config", "plan", "--update-bundle", "web", "--update-bundle", "marketing", "--target", "marketing=new-marketing/{locale}.json", "--target", "web=new-web/{locale}.json", "--json"}); err != nil {
t.Fatalf("repeated retarget: %v: %s", err, out.String())
}
var result struct {
Status string `json:"status"`
Data struct {
ProposedYAML string `json:"proposed_yaml"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Status != "planned" {
t.Fatalf("updates were not planned: %s", out.String())
}
var expected, actual map[string]any
if err := yaml.Unmarshal(original, &expected); err != nil {
t.Fatal(err)
}
if err := yaml.Unmarshal([]byte(result.Data.ProposedYAML), &actual); err != nil {
t.Fatal(err)
}
for _, value := range expected["bundles"].([]any) {
bundle := value.(map[string]any)
switch bundle["id"] {
case "marketing":
bundle["target"] = "new-marketing/{locale}.json"
case "web":
bundle["target"] = "new-web/{locale}.json"
}
}
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("updates changed settings beyond selected targets:\n%s", result.Data.ProposedYAML)
}
for _, comment := range []string{"# preserve project comment", "# untouched bundle comment"} {
if !strings.Contains(result.Data.ProposedYAML, comment) {
t.Errorf("lost comment %q", comment)
}
}
current, err := os.ReadFile(filepath.Join(root, ".internationalizer.yml"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(original, current) {
t.Fatal("planning changed the original config")
}
}

func TestConfigPlanRetargetExistingBundle(t *testing.T) {
root := t.TempDir()
t.Chdir(root)
for name, contents := range map[string]string{
".internationalizer.yml": "source_locale: en\ntarget_locales: [fr]\nsource_path: en.json\nmessage_syntax: plain\n",
"en.json": `{"hello":"Hello"}`,
} {
if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0600); err != nil {
t.Fatal(err)
}
}
command := newRootCmd()
var out bytes.Buffer
command.SetOut(&out)
command.SetErr(&out)
if err := execute(command, []string{"config", "plan", "--update-bundle", "default", "--target", "default=translations/{locale}.json", "--json"}); err != nil {
t.Fatalf("retarget existing bundle: %v: %s", err, out.String())
}
if !strings.Contains(out.String(), "translations/{locale}.json") || !strings.Contains(out.String(), `"status": "planned"`) {
t.Fatalf("missing proposed target: %s", out.String())
}
}

func TestConfigPlanRejectsInvalidUpdateFlags(t *testing.T) {
for _, args := range [][]string{
{"--target", "default=safe/{locale}.json"},
{"--update-bundle", "default"},
{"--update-bundle", "default", "--update-bundle", "default", "--target", "default=safe/{locale}.json"},
{"--update-bundle", "default", "--add-bundle", "default=en.json", "--target", "default=safe/{locale}.json"},
{"--update-bundle", "", "--target", "default=safe/{locale}.json"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
command := newRootCmd()
var out bytes.Buffer
command.SetOut(&out)
command.SetErr(&out)
if err := execute(command, append([]string{"config", "plan", "--json"}, args...)); err == nil {
t.Fatalf("invalid update accepted: %s", out.String())
}
if !strings.Contains(out.String(), `"status": "error"`) {
t.Fatalf("missing JSON failure: %s", out.String())
}
})
}
}
3 changes: 3 additions & 0 deletions cmd/internationalizer/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ func TestWorkflowInputSchemaHasTypedFlags(t *testing.T) {
if flagProps["add-bundle"].(map[string]any)["type"] != "array" {
t.Fatal("repeatable add-bundle flag is not an array")
}
if flagProps["update-bundle"].(map[string]any)["type"] != "array" || flagProps["target"].(map[string]any)["type"] != "array" {
t.Fatal("repeatable update-bundle and target flags must be arrays")
}
case "apply":
if !slices.Contains(flags["required"].([]string), "plan") {
t.Fatal("apply does not require an explicit plan")
Expand Down
36 changes: 36 additions & 0 deletions docs/cli-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,42 @@ new local config. Choose an explicit local `--config` path to start fresh.

## Apply and verify

### Retarget an existing bundle

Use an explicit update decision to change an existing bundle's target template:

```sh
internationalizer config plan --update-bundle default \
--target 'default=tmp/translations-{locale}.json' \
--confirm-source tmp/english-keys.json --out repair-plan.json --json
```

The example assumes that the marketing runtime syntax is already configured.
If it remains ambiguous, also supply the appropriate `--syntax default=PROFILE`.
`--update-bundle` requires an existing ID and a matching `--target`; it cannot
be combined with `--add-bundle` for the same ID. Source, format, syntax, locale,
provider, and other settings remain unchanged unless separately selected by an
existing explicit flag. Legacy `source_path` configuration becomes the stable
`default` bundle when retargeted. Renaming or deleting bundles is not supported.

Review the proposed YAML and diff before applying. Application only updates the
configuration: it does not copy catalogs, replace links, translate messages, or
approve translations. A destination with different content must still satisfy
the existing validation and approval checks.

When a target is a symlink, planning reports the offending path, bundle, and
locale. A bounded metadata check may suggest an existing in-project destination;
it does not inspect outside-project contents or select a path automatically.
External, dangling, cyclic, and unsafe ancestor links remain rejected.

An explicit safe replacement can repair the config even when the old targets
are unsafe. The replacement is checked independently, so the old link is not
an input to the saved plan and its identity is not attested by the receipt.
The saved-plan schema is unchanged. Apply rechecks replacement paths and still
rejects symlinks introduced after planning; normal drift and lock checks apply.

### Apply a reviewed proposal

```sh
internationalizer config apply --plan config-plan.json --no-input --json
internationalizer config check --json
Expand Down
Loading