From 4c161144e8e636de926876bc32c3ccfc618c7726 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Fri, 4 Sep 2026 17:49:05 -0400 Subject: [PATCH 1/2] feat: retarget existing bundles through explicit config plans Preserve bundle identities and omitted settings while allowing safe replacement targets. Include contextual, bounded symlink guidance and synthetic 21-target acceptance coverage. Signed-off-by: Tom Main --- CHANGELOG.md | 5 + cmd/internationalizer/config.go | 26 ++- cmd/internationalizer/config_update_test.go | 149 +++++++++++++ cmd/internationalizer/schema_test.go | 3 + docs/cli-onboarding.md | 36 ++++ internal/onboarding/plan.go | 157 ++++++++++++-- internal/onboarding/retarget_test.go | 221 ++++++++++++++++++++ test/acceptance/retarget_test.go | 100 +++++++++ 8 files changed, 677 insertions(+), 20 deletions(-) create mode 100644 cmd/internationalizer/config_update_test.go create mode 100644 internal/onboarding/retarget_test.go create mode 100644 test/acceptance/retarget_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1700e27..b351c57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- 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. + ## 0.2.0 - 2026-09-04 ### Added diff --git a/cmd/internationalizer/config.go b/cmd/internationalizer/config.go index 0682fac..04e9411 100644 --- a/cmd/internationalizer/config.go +++ b/cmd/internationalizer/config.go @@ -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) @@ -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 { @@ -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)") diff --git a/cmd/internationalizer/config_update_test.go b/cmd/internationalizer/config_update_test.go new file mode 100644 index 0000000..2f79662 --- /dev/null +++ b/cmd/internationalizer/config_update_test.go @@ -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()) + } + }) + } +} diff --git a/cmd/internationalizer/schema_test.go b/cmd/internationalizer/schema_test.go index 56f38ac..8c86c16 100644 --- a/cmd/internationalizer/schema_test.go +++ b/cmd/internationalizer/schema_test.go @@ -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") diff --git a/docs/cli-onboarding.md b/docs/cli-onboarding.md index bb03854..0324465 100644 --- a/docs/cli-onboarding.md +++ b/docs/cli-onboarding.md @@ -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 diff --git a/internal/onboarding/plan.go b/internal/onboarding/plan.go index a8ad9a4..7229ef3 100644 --- a/internal/onboarding/plan.go +++ b/internal/onboarding/plan.go @@ -25,7 +25,9 @@ const maxPlanConfigSize = 2 << 20 // PlanOptions contains explicit user decisions; discovery alone never selects a // new bundle or overwrites an existing bundle's runtime profile. type PlanOptions struct { - AddBundles []config.Bundle + AddBundles []config.Bundle + // UpdateTargets changes only explicitly selected existing bundle targets. + UpdateTargets map[string]string Syntax map[string]message.Syntax ConfirmSources []string SourceLocale string @@ -137,7 +139,7 @@ func BuildPlan(root, configPath string, options PlanOptions) (*ConfigPlan, error if !exists && options.SourceLocale == "" { setPlanScalar(content, "source_locale", "en") } - if len(options.AddBundles) > 0 || len(options.Syntax) > 0 { + if len(options.AddBundles) > 0 || len(options.UpdateTargets) > 0 || len(options.Syntax) > 0 { if len(cfg.Bundles) == 0 && cfg.SourcePath != "" { bundles := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} for _, b := range cfg.EffectiveBundles() { @@ -181,22 +183,44 @@ func BuildPlan(root, configPath string, options PlanOptions) (*ConfigPlan, error additions := append([]config.Bundle(nil), options.AddBundles...) sort.Slice(additions, func(i, j int) bool { return additions[i].ID < additions[j].ID }) for _, b := range additions { + if _, updating := options.UpdateTargets[b.ID]; updating { + return nil, planError("invalid_decision", "bundle cannot be both added and updated: "+b.ID) + } if b.ID == "" || seen[b.ID] != nil { return nil, planError("invalid_decision", "added bundle identity must be nonempty and not already configured") } if b.Source == "" || b.Target == "" { return nil, planError("invalid_decision", "added bundles require explicit source and target paths") } - if _, err := safePlanPath(root, b.Source); err != nil { + if _, err := safePlanBundlePath(root, b.Source, b.ID, "", "source"); err != nil { return nil, err } - if _, err := safePlanPath(root, b.Target); err != nil { + if _, err := safePlanBundlePath(root, b.Target, b.ID, "", "target"); err != nil { return nil, err } node := planBundleNode(b) bundleNodes.Content = append(bundleNodes.Content, node) seen[b.ID] = node } + updateIDs := make([]string, 0, len(options.UpdateTargets)) + for id := range options.UpdateTargets { + updateIDs = append(updateIDs, id) + } + sort.Strings(updateIDs) + for _, id := range updateIDs { + if id == "" || seen[id] == nil { + return nil, planError("invalid_decision", "target update references an unknown bundle: "+id) + } + if options.UpdateTargets[id] == "" { + return nil, planError("invalid_decision", "target update requires an explicit target path: "+id) + } + // Validate the replacement, not the old target. Retargeting never reads, + // moves, or follows the old catalog, even when it is an unsafe symlink. + if _, err := safePlanBundlePath(root, options.UpdateTargets[id], id, "", "target"); err != nil { + return nil, err + } + setPlanScalar(seen[id], "target", options.UpdateTargets[id]) + } for id, syntax := range options.Syntax { if seen[id] == nil { return nil, planError("invalid_decision", "syntax selection references an unknown bundle: "+id) @@ -240,7 +264,7 @@ func BuildPlan(root, configPath string, options PlanOptions) (*ConfigPlan, error } paths := map[string]bool{} for _, b := range proposed.EffectiveBundles() { - source, err := safePlanPath(root, b.Source) + source, err := safePlanBundlePath(root, b.Source, b.ID, "", "source") if err != nil { return nil, err } @@ -255,7 +279,7 @@ func BuildPlan(root, configPath string, options PlanOptions) (*ConfigPlan, error if err != nil { return nil, planError("invalid_config", err.Error()) } - if _, err := safePlanPath(root, target); err != nil { + if _, err := safePlanBundlePath(root, target, b.ID, locale, "target"); err != nil { return nil, err } } @@ -339,7 +363,7 @@ func BuildPlan(root, configPath string, options PlanOptions) (*ConfigPlan, error } plan.ProposedYAML = encoded.String() // No-op planning must not rewrite formatting or comments. - if exists && len(options.AddBundles) == 0 && len(options.Syntax) == 0 && options.SourceLocale == "" && len(options.TargetLocales) == 0 { + if exists && len(options.AddBundles) == 0 && len(options.UpdateTargets) == 0 && len(options.Syntax) == 0 && options.SourceLocale == "" && len(options.TargetLocales) == 0 { plan.ProposedYAML = string(before) } plan.AfterSHA256 = planHash([]byte(plan.ProposedYAML)) @@ -388,12 +412,12 @@ func ApplyPlan(plan *ConfigPlan) (*ApplyReceipt, error) { return nil, planError("invalid_plan", "proposed configuration is invalid: "+err.Error()) } for _, b := range cfg.EffectiveBundles() { - if _, err := safePlanPath(root, b.Source); err != nil { + if _, err := safePlanBundlePath(root, b.Source, b.ID, "", "source"); err != nil { return nil, err } for _, locale := range cfg.TargetLocales { target, _ := b.TargetPath(locale) - if _, err := safePlanPath(root, target); err != nil { + if _, err := safePlanBundlePath(root, target, b.ID, locale, "target"); err != nil { return nil, err } } @@ -552,12 +576,15 @@ func safePlanPath(root, path string) (string, error) { path = filepath.Join(root, path) } path = filepath.Clean(path) + fail := func(reason string) (string, error) { + return "", planError("unsafe_path", fmt.Sprintf("path %q: %s", relativePath(root, path), reason)) + } if sensitivePlanPath(path) { - return "", planError("unsafe_path", "configuration plans do not read or write credential-shaped files") + return fail("configuration plans do not read or write credential-shaped files") } rel, err := filepath.Rel(root, path) if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", planError("unsafe_path", "configuration plans may only access files inside the project root") + return fail("configuration plans may only access files inside the project root") } current := root parts := strings.Split(rel, string(filepath.Separator)) @@ -568,21 +595,121 @@ func safePlanPath(root, path string) (string, error) { continue } if err != nil { - return "", planError("unsafe_path", "cannot inspect a project path safely") + return fail("cannot inspect a project path safely") } if info.Mode()&os.ModeSymlink != 0 { - return "", planError("unsafe_path", "configuration plans do not follow symlinks") + destination, reason := planLinkDestination(root, path) + guidance := "; " + reason + if destination != "" { + guidance = fmt.Sprintf("; review in-root destination %q and explicitly retarget the bundle", destination) + } + return fail(fmt.Sprintf("configuration plans do not follow symlinks (link component %q)%s", relativePath(root, current), guidance)) } if i < len(parts)-1 && !info.IsDir() { - return "", planError("unsafe_path", "project path has a non-directory ancestor") + return fail("project path has a non-directory ancestor") } if i == len(parts)-1 && !info.Mode().IsRegular() { - return "", planError("unsafe_path", "configuration plans require regular files") + return fail("configuration plans require regular files") } } return path, nil } +func safePlanBundlePath(root, path, bundle, locale, role string) (string, error) { + abs, err := safePlanPath(root, path) + if err == nil { + return abs, nil + } + context := fmt.Sprintf("bundle %q %s", bundle, role) + if locale != "" { + context += fmt.Sprintf(" locale %q", locale) + } + return "", fmt.Errorf("%s: %w", context, err) +} + +// planLinkDestination inspects bounded link metadata only. It never opens +// catalog contents or inspects an outside-root destination. Suggestions are +// diagnostics, not plan inputs: explicit replacement targets are independently +// validated and no proposal depends on the identity of an old target link. +func planLinkDestination(root, path string) (string, string) { + rel, err := filepath.Rel(root, path) + if err != nil || !filepath.IsLocal(rel) { + return "", "link destination is outside the project root" + } + pending := strings.Split(rel, string(filepath.Separator)) + current := root + links := 0 + for steps := 0; len(pending) > 0 && steps < 256; steps++ { + part := pending[0] + pending = pending[1:] + switch part { + case "", ".": + continue + case "..": + if current == root { + return "", "link destination is outside the project root" + } + current = filepath.Dir(current) + continue + } + next := filepath.Join(current, part) + if sensitivePlanPath(next) { + return "", "link destination is credential-shaped; no destination inspected" + } + info, err := os.Lstat(next) + if errors.Is(err, os.ErrNotExist) { + return "", "link destination is dangling" + } + if err != nil { + return "", "link destination cannot be inspected safely" + } + if info.Mode()&os.ModeSymlink != 0 { + links++ + if links > 32 { + return "", "link chain is cyclic or exceeds the inspection limit" + } + target, err := os.Readlink(next) + if err != nil || len(target) > 4096 { + return "", "link metadata cannot be inspected within limits" + } + if filepath.VolumeName(target) != "" && !filepath.IsAbs(target) { + return "", "link destination has an unsupported volume-relative path" + } + if filepath.IsAbs(target) { + // Do not Clean/Rel the target: link/../file must resolve link + // before its parent component, not erase that traversal. + target = filepath.FromSlash(target) + prefix := root + string(filepath.Separator) + if target == root { + target = "." + } else if strings.HasPrefix(target, prefix) { + target = strings.TrimPrefix(target, prefix) + } else { + return "", "link destination is outside the project root" + } + current = root + } + pending = append(strings.Split(target, string(filepath.Separator)), pending...) + continue + } + if len(pending) > 0 && !info.IsDir() { + return "", "link destination has a non-directory ancestor" + } + if len(pending) == 0 && !info.Mode().IsRegular() { + return "", "link destination is not a regular catalog file" + } + current = next + } + if len(pending) != 0 || current == root { + return "", "link metadata exceeds the inspection limit" + } + info, err := os.Lstat(current) + if err != nil || !info.Mode().IsRegular() { + return "", "link destination is not a regular catalog file" + } + return relativePath(root, current), "" +} + func sensitivePlanPath(path string) bool { name := strings.ToLower(filepath.Base(path)) return name == ".env" || strings.HasPrefix(name, ".env.") || strings.HasSuffix(name, ".key") || strings.HasSuffix(name, ".pem") || strings.HasSuffix(name, ".p12") diff --git a/internal/onboarding/retarget_test.go b/internal/onboarding/retarget_test.go new file mode 100644 index 0000000..07fa273 --- /dev/null +++ b/internal/onboarding/retarget_test.go @@ -0,0 +1,221 @@ +package onboarding + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Tom-R-Main/Internationalizer/internal/config" +) + +func retargetFixture(t *testing.T, explicit bool) string { + t.Helper() + root := t.TempDir() + body := "# keep header\nsource_locale: en\ntarget_locales: [fr]\nmessage_syntax: plain\nllm:\n provider: openai\n locale_overrides:\n fr:\n model: custom\nglossary_dir: custom/glossary\nfuture_setting: retained\n" + if explicit { + body += "bundles:\n - id: marketing\n source: en.json\n target: linked/{locale}.json # keep target comment\n format: json\n message_syntax: plain\n future_bundle_setting: retained\n" + } else { + body += "source_path: en.json # keep source comment\n" + } + planWrite(t, root, ".internationalizer.yml", body) + planWrite(t, root, "en.json", `{"hello":"Hello"}`) + return root +} + +func TestRetargetPreservesExistingSettings(t *testing.T) { + for _, explicit := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy", true: "explicit"}[explicit], func(t *testing.T) { + root := retargetFixture(t, explicit) + id := "default" + if explicit { + id = "marketing" + } + p, err := BuildPlan(root, "", PlanOptions{UpdateTargets: map[string]string{id: "catalogs/{locale}.json"}}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"# keep header", "source_locale: en", "provider: openai", "model: custom", "glossary_dir: custom/glossary", "future_setting: retained", "id: " + id, "source: en.json", "target: catalogs/{locale}.json", "message_syntax: plain"} { + if !strings.Contains(p.ProposedYAML, want) { + t.Errorf("lost %q: %s", want, p.ProposedYAML) + } + } + if explicit && (!strings.Contains(p.ProposedYAML, "# keep target comment") || !strings.Contains(p.ProposedYAML, "future_bundle_setting: retained")) { + t.Fatal("lost bundle metadata") + } + if !explicit && !strings.Contains(p.ProposedYAML, "# keep source comment") { + t.Fatal("lost legacy comment") + } + receipt, err := ApplyPlan(p) + if err != nil || receipt.Status != "applied" { + t.Fatalf("apply: %+v %v", receipt, err) + } + receipt, err = ApplyPlan(p) + if err != nil || receipt.Status != "already_applied" { + t.Fatalf("replay: %+v %v", receipt, err) + } + }) + } +} + +func TestRetargetRejectsInvalidDecisions(t *testing.T) { + for name, options := range map[string]PlanOptions{ + "unknown": {UpdateTargets: map[string]string{"unknown": "catalogs/{locale}.json"}}, + "empty_id": {UpdateTargets: map[string]string{"": "catalogs/{locale}.json"}}, + "empty_target": {UpdateTargets: map[string]string{"default": ""}}, + "add_existing": {AddBundles: []config.Bundle{{ID: "default", Source: "en.json", Target: "catalogs/{locale}.json"}}}, + "add_and_update": {AddBundles: []config.Bundle{{ID: "new", Source: "en.json", Target: "catalogs/{locale}.json"}}, UpdateTargets: map[string]string{"new": "catalogs/{locale}.json"}}, + } { + t.Run(name, func(t *testing.T) { + root := retargetFixture(t, false) + _, err := BuildPlan(root, "", options) + assertPlanCode(t, err, "invalid_decision") + }) + } +} + +func TestRetargetRepairsUnsafeOldLinksWithoutReadingThem(t *testing.T) { + for _, kind := range []string{"external", "dangling", "cyclic", "ancestor"} { + t.Run(kind, func(t *testing.T) { + root := retargetFixture(t, true) + linkPath := filepath.Join(root, "linked", "fr.json") + if err := os.MkdirAll(filepath.Dir(linkPath), 0755); err != nil { + t.Fatal(err) + } + var target string + switch kind { + case "external": + target = filepath.Join(t.TempDir(), "never-read.json") + case "dangling": + target = "missing.json" + case "cyclic": + target = "fr.json" + case "ancestor": + if err := os.Remove(filepath.Dir(linkPath)); err != nil { + t.Fatal(err) + } + linkPath = filepath.Dir(linkPath) + target = "missing-dir" + } + if err := os.Symlink(target, linkPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + before, err := os.ReadFile(filepath.Join(root, ".internationalizer.yml")) + if err != nil { + t.Fatal(err) + } + _, err = BuildPlan(root, "", PlanOptions{}) + assertPlanCode(t, err, "unsafe_path") + if !strings.Contains(err.Error(), `bundle "marketing" target locale "fr"`) || !strings.Contains(err.Error(), "linked/fr.json") { + t.Fatalf("missing path context: %v", err) + } + p, err := BuildPlan(root, "", PlanOptions{UpdateTargets: map[string]string{"marketing": "safe/{locale}.json"}}) + if err != nil { + t.Fatal(err) + } + current, _ := os.ReadFile(filepath.Join(root, ".internationalizer.yml")) + if !bytes.Equal(current, before) { + t.Fatal("planning changed config") + } + // A caller supplied the replacement independently: old link identity + // is not plan evidence and changing it must not require following it. + if err := os.Remove(linkPath); err != nil { + t.Fatal(err) + } + if err := os.Symlink("another-missing-path", linkPath); err != nil { + t.Fatal(err) + } + if _, err := ApplyPlan(p); err != nil { + t.Fatal(err) + } + if after, err := os.Readlink(linkPath); err != nil || after != "another-missing-path" { + t.Fatalf("old link mutated: %q %v", after, err) + } + }) + } +} + +func TestRetargetRejectsUnsafeReplacementAndLateLink(t *testing.T) { + root := retargetFixture(t, false) + _, err := BuildPlan(root, "", PlanOptions{UpdateTargets: map[string]string{"default": "../outside/{locale}.json"}}) + assertPlanCode(t, err, "unsafe_path") + p, err := BuildPlan(root, "", PlanOptions{UpdateTargets: map[string]string{"default": "safe/{locale}.json"}}) + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), filepath.Join(root, "safe")); err != nil { + t.Skip(err) + } + _, err = ApplyPlan(p) + assertPlanCode(t, err, "unsafe_path") +} + +func TestSymlinkDiagnosticsBoundedAndInRoot(t *testing.T) { + for _, kind := range []string{"regular", "ancestor", "external", "dangling", "cyclic", "sensitive", "directory"} { + t.Run(kind, func(t *testing.T) { + root := retargetFixture(t, false) + planWrite(t, root, "real/fr.json", `not-json-and-never-opened`) + link, target, want := "fr.json", "real/fr.json", `review in-root destination "real/fr.json"` + switch kind { + case "ancestor": + link, target = "linked", "real" + case "external": + target, want = filepath.Join(t.TempDir(), "secret.json"), "outside the project root" + case "dangling": + target, want = "missing.json", "dangling" + case "cyclic": + target, want = "fr.json", "cyclic" + case "sensitive": + target, want = ".env", "credential-shaped" + case "directory": + target, want = "real/.", "not a regular catalog file" + } + if err := os.Symlink(target, filepath.Join(root, link)); err != nil { + t.Skip(err) + } + path := link + if kind == "ancestor" { + path += "/fr.json" + } + _, err := safePlanPath(root, path) + assertPlanCode(t, err, "unsafe_path") + if !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), `link component "`+link+`"`) { + t.Fatalf("missing %q: %v", want, err) + } + if kind == "external" && strings.Contains(err.Error(), target) { + t.Fatalf("leaked external destination: %v", err) + } + }) + } +} + +func TestSymlinkDiagnosticResolvesParentsAfterLinks(t *testing.T) { + for _, absolute := range []bool{false, true} { + t.Run(map[bool]string{false: "relative", true: "absolute"}[absolute], func(t *testing.T) { + root := retargetFixture(t, false) + // Canonicalize platform aliases before constructing an absolute link. + root, err := canonicalPlanRoot(root) + if err != nil { + t.Fatal(err) + } + planWrite(t, root, "other/child/unused.json", "{}") + planWrite(t, root, "other/real/fr.json", "{}") + if err := os.Symlink(filepath.Join("other", "child"), filepath.Join(root, "branch")); err != nil { + t.Skip(err) + } + target := "branch" + string(filepath.Separator) + ".." + string(filepath.Separator) + "real" + string(filepath.Separator) + "fr.json" + if absolute { + target = root + string(filepath.Separator) + target + } + if err := os.Symlink(target, filepath.Join(root, "fr.json")); err != nil { + t.Fatal(err) + } + _, err = safePlanPath(root, "fr.json") + assertPlanCode(t, err, "unsafe_path") + if !strings.Contains(err.Error(), `review in-root destination "other/real/fr.json"`) { + t.Fatalf("wrong parent resolution: %v", err) + } + }) + } +} diff --git a/test/acceptance/retarget_test.go b/test/acceptance/retarget_test.go new file mode 100644 index 0000000..1fb315e --- /dev/null +++ b/test/acceptance/retarget_test.go @@ -0,0 +1,100 @@ +package acceptance_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOnboardingRetargetTwentyOneMarketingSymlinks(t *testing.T) { + root := t.TempDir() + locales := []string{"fr", "ja", "de", "es", "it", "pt", "nl", "sv", "da", "fi", "pl", "cs", "sk", "hu", "ro", "tr", "ru", "uk", "ko", "zh", "ar"} + configPath := filepath.Join(root, ".internationalizer.yml") + original := []byte(fmt.Sprintf(`# keep marketing ownership +source_locale: en +target_locales: [%s] +source_path: tmp/english-keys.json +message_syntax: plain +llm: + provider: openai + api_key_env: RETARGET_TEST_KEY + locale_overrides: + ja: + provider: gemini + model: preserved-model + api_key_env: RETARGET_JA_TEST_KEY +glossary_dir: custom/glossary +future_setting: retained +`, strings.Join(locales, ", "))) + if err := os.WriteFile(configPath, original, 0600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "tmp"), 0755); err != nil { + t.Fatal(err) + } + catalog := []byte(`{"hello":"Hello","code":"{.sift,.agents}"}`) + if err := os.WriteFile(filepath.Join(root, "tmp", "english-keys.json"), catalog, 0600); err != nil { + t.Fatal(err) + } + for _, locale := range locales { + target := "translations-" + locale + ".json" + if err := os.WriteFile(filepath.Join(root, "tmp", target), catalog, 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "tmp", locale+".json")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + } + blocked := runCLI(t, root, nil, "config", "plan", "--confirm-source", "tmp/english-keys.json", "--json") + if blocked.exitCode == 0 { + t.Fatal("unrepaired symlink targets accepted") + } + decodeOnboardingJSON(t, blocked) + for _, want := range []string{`"code": "unsafe_path"`, "tmp/fr.json", "tmp/translations-fr.json", `locale \"fr\"`} { + if !strings.Contains(blocked.stdout, want) { + t.Fatalf("missing %q: %s", want, blocked.stdout) + } + } + planned := runCLI(t, root, nil, "config", "plan", "--update-bundle", "default", "--target", "default=tmp/translations-{locale}.json", "--confirm-source", "tmp/english-keys.json", "--out", "repair-plan.json", "--json") + planned.requireSuccess(t) + if decodeOnboardingJSON(t, planned)["status"] != "planned" { + t.Fatalf("repair requires unexpected decisions: %s", planned.stdout) + } + if !bytes.Equal(original, mustReadFile(t, configPath)) { + t.Fatal("plan changed config") + } + applied := runCLI(t, root, nil, "config", "apply", "--plan", "repair-plan.json", "--no-input", "--json") + applied.requireSuccess(t) + configured := string(mustReadFile(t, configPath)) + for _, want := range []string{"# keep marketing ownership", "id: default", "source: tmp/english-keys.json", "target: tmp/translations-{locale}.json", "preserved-model", "RETARGET_JA_TEST_KEY", "custom/glossary", "future_setting: retained"} { + if !strings.Contains(configured, want) { + t.Fatalf("lost %q: %s", want, configured) + } + } + replay := runCLI(t, root, nil, "config", "apply", "--plan", "repair-plan.json", "--no-input", "--json") + replay.requireSuccess(t) + if decodeOnboardingJSON(t, replay)["status"] != "already_applied" { + t.Fatalf("replay: %s", replay.stdout) + } + dry := runCLI(t, root, nil, "translate", "--dry-run", "--json") + dry.requireSuccess(t) + decodeOnboardingJSON(t, dry) + if !strings.Contains(dry.stdout, `"provider_called": false`) { + t.Fatalf("dry-run called provider: %s", dry.stdout) + } + for _, locale := range locales { + target := "translations-" + locale + ".json" + if link, err := os.Readlink(filepath.Join(root, "tmp", locale+".json")); err != nil || link != target { + t.Fatalf("link changed: %q %v", link, err) + } + if !bytes.Equal(catalog, mustReadFile(t, filepath.Join(root, "tmp", target))) { + t.Fatalf("catalog %s changed", locale) + } + } + if _, err := os.Stat(filepath.Join(root, ".internationalizer.lock")); !os.IsNotExist(err) { + t.Fatalf("dry-run wrote state: %v", err) + } +} From 2e4e7fd4a3e4798d86d9e2573fdd514a30314351 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Fri, 4 Sep 2026 18:05:43 -0400 Subject: [PATCH 2/2] chore: prepare CLI packages for 0.2.1 Signed-off-by: Tom Main --- package.json | 12 ++++++------ packages/darwin-arm64/package.json | 2 +- packages/darwin-x64/package.json | 2 +- packages/linux-arm64/package.json | 2 +- packages/linux-x64/package.json | 2 +- packages/win32-x64/package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index c05646a..0c7427f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer", - "version": "0.2.0", + "version": "0.2.1", "description": "AI-native internationalization CLI for software projects", "license": "AGPL-3.0-only", "bin": { @@ -18,11 +18,11 @@ "node": ">=18" }, "optionalDependencies": { - "internationalizer-darwin-arm64": "0.2.0", - "internationalizer-darwin-x64": "0.2.0", - "internationalizer-linux-arm64": "0.2.0", - "internationalizer-linux-x64": "0.2.0", - "internationalizer-win32-x64": "0.2.0" + "internationalizer-darwin-arm64": "0.2.1", + "internationalizer-darwin-x64": "0.2.1", + "internationalizer-linux-arm64": "0.2.1", + "internationalizer-linux-x64": "0.2.1", + "internationalizer-win32-x64": "0.2.1" }, "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 4d678ec..b293522 100644 --- a/packages/darwin-arm64/package.json +++ b/packages/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-darwin-arm64", - "version": "0.2.0", + "version": "0.2.1", "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 81947be..5453e0b 100644 --- a/packages/darwin-x64/package.json +++ b/packages/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-darwin-x64", - "version": "0.2.0", + "version": "0.2.1", "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 285571a..827da3d 100644 --- a/packages/linux-arm64/package.json +++ b/packages/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-linux-arm64", - "version": "0.2.0", + "version": "0.2.1", "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 8781b81..cdf8572 100644 --- a/packages/linux-x64/package.json +++ b/packages/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-linux-x64", - "version": "0.2.0", + "version": "0.2.1", "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 b185534..08df6da 100644 --- a/packages/win32-x64/package.json +++ b/packages/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "internationalizer-win32-x64", - "version": "0.2.0", + "version": "0.2.1", "description": "win32 x64 binary for the internationalizer CLI", "license": "AGPL-3.0-only", "os": [