From f4714f4c179ee695e687c9ad48acc777274975ae Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 16 Jul 2026 18:18:17 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(state):=20never=20drop=20manual=20flags?= =?UTF-8?q?=20=E2=80=94=20sync=20merges,=20translate=20preserves,=20accept?= =?UTF-8?q?-edits=20scoped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three extraction-era flaws made .i18n-state churn unrelated keys across consuming-app PRs: - sync_state! replaced every target entry with {source_hash} only, wiping target_hash and manual: true app-wide (the pre-extraction bin/translate preserved both). Sync now merges: it refreshes source_hash and leaves hand-edit protection untouched. - translate --force-key rewrote the state entry without the manual flag — and the outdated validator's suggestion pushed agents to run exactly that. The flag now survives retranslation; no command removes manual: true. - accept-edits stamped manual: true on every key of a locale. Unscoped it now accepts only the keys the manual_edits validator flags; --key KEY (repeatable) accepts specific keys; --all keeps the blanket for adoption. StateStore#save also skips byte-identical files so unrelated namespace files are never rewritten, and validator suggestions are key-scoped so they are safe to follow verbatim. --- CHANGELOG.md | 22 ++ lib/locallingo/cli.rb | 22 +- lib/locallingo/manager.rb | 89 ++++++-- lib/locallingo/state_store.rb | 10 +- lib/locallingo/validators/manual_edits.rb | 2 +- lib/locallingo/validators/outdated.rb | 15 +- spec/locallingo/cli_spec.rb | 68 ++++++ spec/locallingo/manager_spec.rb | 259 ++++++++++++++++++++++ spec/locallingo/state_store_spec.rb | 44 ++++ spec/support/locale_fixtures.rb | 12 + 10 files changed, 513 insertions(+), 30 deletions(-) create mode 100644 spec/locallingo/state_store_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 938fc4f..4ad2f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- `lingo sync` no longer destroys hand-edit protection: it now only refreshes + each entry's `source_hash` and preserves `target_hash` and `manual: true` + (the pre-extraction `bin/translate` behavior). Previously a single sync wiped + both fields for every key, blinding the `manual_edits` validator and causing + `manual` flags to flip back and forth across branches. +- `translate --force-key` on a `manual`-flagged key keeps the flag. The value is + still retranslated on explicit request, but no command removes `manual: true` + anymore — unprotecting a key requires editing the state JSON by hand. +- State files whose content is unchanged are no longer rewritten, so operations + never touch `.i18n-state/` files for unrelated namespaces. + +### Changed +- Unscoped `lingo accept-edits` now accepts only the keys the `manual_edits` + validator flags (actually hand-edited), instead of stamping `manual: true` on + every key of the locale. Use the new `--key KEY` (repeatable) for surgical + accepts, or `--all` for the old blanket behavior (initial adoption). +- Validator suggestions are scoped and safe to follow verbatim: `manual_edit` + violations suggest `accept-edits --locale --key `, and `outdated` + violations on manual keys tell you to update the value by hand and re-accept + it rather than force-translating over curated text. + ### Added - `Locallingo.configure { |c| c.anthropic_api_key = ... }` — gem-level provider credentials as Strings or lazy callables, for apps whose keys don't live in diff --git a/lib/locallingo/cli.rb b/lib/locallingo/cli.rb index 8326576..3770e75 100644 --- a/lib/locallingo/cli.rb +++ b/lib/locallingo/cli.rb @@ -109,6 +109,10 @@ def build_parser opts.on("--force-key KEY", "Force re-translation of a specific key") do |v| (@options[:force_keys] ||= []) << v end + opts.on("--key KEY", "accept-edits: accept a specific key (repeatable)") do |v| + (@options[:keys] ||= []) << v + end + opts.on("--all", "accept-edits: mark every translated key as manual") { @options[:all] = true } opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true } opts.on("-n", "--dry-run", "Show what would be done without changing files") { @options[:dry_run] = true } opts.on("--strict", "Fail on strict-tier issues (for CI)") { @options[:strict] = true } @@ -185,11 +189,25 @@ def cmd_accept_edits(config, options) warn "manual_edits validator is disabled in .locallingo.yml — nothing to accept." return end - manager(config, options).accept_edits!(locale: options[:locale]) - puts "✅ Marked current translations as intentional." + + results = manager(config, options).accept_edits!( + locale: options[:locale], keys: options[:keys] || [], all: options.fetch(:all, false) + ) + report_accepted(results) puts "(dry run - no changes made)" if options[:dry_run] end + def report_accepted(results) + total = results.values.sum(&:size) + if total.zero? + puts "Nothing to accept — no hand-edited translations found." + return + end + + results.each { |locale, keys| puts " #{locale}: #{keys.size} key(s)" if keys.any? } + puts "✅ Marked #{total} translation(s) as intentional." + end + def cmd_hash(config, options) hash = manager(config, options).source_hash options[:format] == :json ? puts(JSON.generate({ hash: })) : puts(hash) diff --git a/lib/locallingo/manager.rb b/lib/locallingo/manager.rb index 969a2be..1131648 100644 --- a/lib/locallingo/manager.rb +++ b/lib/locallingo/manager.rb @@ -97,27 +97,35 @@ def translate!(locale: nil, force: false, force_keys: []) locales_to_process.each { |target_locale| translate_locale(source, target_locale, force:, force_keys:) } end - # Mark every current target value as intentional (source_hash + target_hash + - # manual flag) so the manual-edits validator stops flagging it. - def accept_edits!(locale: nil) + # Mark hand-edited target values as intentional (source_hash + target_hash + + # manual flag) so the manual-edits validator stops flagging them and + # translate won't overwrite them. Unscoped, it accepts exactly the keys the + # manual-edits validator flags; `keys:` accepts the named keys regardless of + # drift; `all: true` marks every translated key (initial adoption). Returns + # `{ locale => accepted_keys }`. + def accept_edits!(locale: nil, keys: [], all: false) source = load_source_translations locales = locale ? [locale] : config.target_locales - locales.each do |target_locale| + plans = locales.map do |target_locale| target = load_locale_translations(target_locale) locale_state = @state.load(target_locale) + accepted = keys_to_accept(source, target, locale_state, keys:, all:) + [target_locale, target, locale_state, accepted] + end - target.each do |key, value| - next unless source[key] + ensure_keys_matched!(keys, plans) + plans.each_with_object({}) do |(target_locale, target, locale_state, accepted), results| + accepted.each do |key| locale_state[key] = { "source_hash" => @state.hash(source[key]), - "target_hash" => @state.hash(value), + "target_hash" => @state.hash(target[key]), "manual" => true } end - @state.save(target_locale, locale_state) unless dry_run + results[target_locale] = accepted end end @@ -126,8 +134,11 @@ def source_hash format("%08x", Zlib.crc32(load_source_translations.to_json)) end - # Rewrite state from the current translation files (initial setup / after - # manual edits). Returns the combined state. + # Refresh source hashes from the current translation files and prune state + # for keys that no longer exist. Existing `target_hash`/`manual` fields are + # preserved — hand-edit protection is never dropped by a sync; use + # `accept_edits!` to resolve hand-edit drift explicitly. Returns the + # combined state. def sync_state! source = load_source_translations @@ -136,18 +147,7 @@ def sync_state! en_state.each_key { |key| en_state.delete(key) unless source.key?(key) } @state.save(config.source_locale, en_state) unless dry_run - config.target_locales.each do |locale| - target = load_locale_translations(locale) - locale_state = @state.load(locale) - - target.each_key do |key| - next unless source[key] - - locale_state[key] = { "source_hash" => @state.hash(source[key]) } - end - locale_state.each_key { |key| locale_state.delete(key) unless target.key?(key) } - @state.save(locale, locale_state) unless dry_run - end + config.target_locales.each { |locale| sync_locale_state(source, locale) } combined = { config.source_locale => @state.load(config.source_locale) } config.target_locales.each { |locale| combined[locale] = @state.load(locale) } @@ -167,6 +167,44 @@ def run_after_translate_hooks def missing_validator = @missing_validator ||= Validators::Missing.new(cli_name:) def outdated_validator = @outdated_validator ||= Validators::Outdated.new(cli_name:) + def sync_locale_state(source, locale) + target = load_locale_translations(locale) + locale_state = @state.load(locale) + + target.each_key do |key| + next unless source[key] + + existing = locale_state[key] + entry = existing.is_a?(Hash) ? existing.dup : {} + entry["source_hash"] = @state.hash(source[key]) + locale_state[key] = entry + end + locale_state.each_key { |key| locale_state.delete(key) unless target.key?(key) } + @state.save(locale, locale_state) unless dry_run + end + + def keys_to_accept(source, target, locale_state, keys:, all:) + return keys.select { |key| source.key?(key) && target.key?(key) } if keys.any? + return target.keys.select { |key| source.key?(key) } if all + + target.keys.select do |key| + entry = locale_state[key] + next false unless source.key?(key) && entry.is_a?(Hash) && !entry["manual"] + + entry["target_hash"] && entry["target_hash"] != @state.hash(target[key]) + end + end + + def ensure_keys_matched!(keys, plans) + return if keys.empty? + + matched = plans.flat_map { |_, _, _, accepted| accepted } + missing = keys - matched + return if missing.empty? + + raise Error, "accept-edits: key(s) not found in any target locale: #{missing.join(", ")}" + end + def translate_locale(source, target_locale, force:, force_keys:) log("Processing #{target_locale}...") @@ -301,10 +339,15 @@ def update_locale_state(source, locale_state, translations) translations.each_key do |key| next unless source[key] - locale_state[key] = { + existing = locale_state[key] + entry = { "source_hash" => @state.hash(source[key]), "target_hash" => @state.hash(translations[key]) } + # A force-keyed retranslation may overwrite a manual value on explicit + # request, but the protection flag itself must survive. + entry["manual"] = true if existing.is_a?(Hash) && existing["manual"] + locale_state[key] = entry end end diff --git a/lib/locallingo/state_store.rb b/lib/locallingo/state_store.rb index e79ce4c..e076179 100644 --- a/lib/locallingo/state_store.rb +++ b/lib/locallingo/state_store.rb @@ -40,8 +40,9 @@ def load(locale) "This would cause state loss. Fix the JSON manually or restore from git." end - # Save a locale's state, split back into per-namespace files. Namespace files - # that no longer have keys are removed. + # Save a locale's state, split back into per-namespace files. Files whose + # content is unchanged are left untouched so unrelated namespaces never + # churn in diffs. Namespace files that no longer have keys are removed. def save(locale, locale_state) by_namespace = locale_state.each_with_object({}) do |(key, value), groups| namespace = key.split(".").first @@ -50,7 +51,10 @@ def save(locale, locale_state) by_namespace.each do |namespace, keys| state_file = File.join(state_dir, "#{namespace}.#{locale}.json") - File.write(state_file, JSON.pretty_generate(keys.sort.to_h)) + content = JSON.pretty_generate(keys.sort.to_h) + next if File.exist?(state_file) && File.read(state_file) == content + + File.write(state_file, content) end Dir.glob(File.join(state_dir, "*.#{locale}.json")).each do |file| diff --git a/lib/locallingo/validators/manual_edits.rb b/lib/locallingo/validators/manual_edits.rb index f1cf19d..2466e46 100644 --- a/lib/locallingo/validators/manual_edits.rb +++ b/lib/locallingo/validators/manual_edits.rb @@ -30,7 +30,7 @@ def call(target:, locale_state:, locale:) type: :manual_edit, locale:, key:, - suggestion: "Value was hand-edited. Protect it: #{@cli_name} accept-edits --locale #{locale}" + suggestion: "Value was hand-edited. Protect it: #{@cli_name} accept-edits --locale #{locale} --key #{key}" } end end diff --git a/lib/locallingo/validators/outdated.rb b/lib/locallingo/validators/outdated.rb index 8bd2779..4745f6e 100644 --- a/lib/locallingo/validators/outdated.rb +++ b/lib/locallingo/validators/outdated.rb @@ -19,7 +19,7 @@ def call(source:, locale_state:, locale:) type: :outdated, locale:, key:, - suggestion: "Source changed. Run: #{@cli_name} translate --locale #{locale} --force-key #{key}" + suggestion: suggestion_for(key, locale, locale_state) } end end @@ -31,6 +31,19 @@ def outdated_keys(source, locale_state) key if stored && stored != StateStore.hash(value) end end + + private + + # Manual keys must not be pushed toward machine retranslation — the value + # is hand-curated, so the human updates it and re-accepts. + def suggestion_for(key, locale, locale_state) + if locale_state.dig(key, "manual") + "Source changed for a manually-curated key. Update the #{locale} value by hand, " \ + "then run: #{@cli_name} accept-edits --locale #{locale} --key #{key}" + else + "Source changed. Run: #{@cli_name} translate --locale #{locale} --force-key #{key}" + end + end end end end diff --git a/spec/locallingo/cli_spec.rb b/spec/locallingo/cli_spec.rb index 84b0a67..c7ec045 100644 --- a/spec/locallingo/cli_spec.rb +++ b/spec/locallingo/cli_spec.rb @@ -84,6 +84,74 @@ def run_cli(root, argv) end end + describe "accept-edits" do + let(:accept_config) do + { "target_locales" => %w[de], "validators" => { "manual_edits" => true } } + end + let(:accept_locales) do + { + "en" => { "g" => { "hi" => "Hello", "bye" => "Goodbye" } }, + "de" => { "g" => { "hi" => "Hallo edited", "bye" => "Tschau" } } + } + end + + def seed_state(root) + write_state(root, "g.de.json", + "g.hi" => { "source_hash" => Locallingo::StateStore.hash("Hello"), + "target_hash" => "00000000" }, + "g.bye" => { "source_hash" => Locallingo::StateStore.hash("Goodbye"), + "target_hash" => Locallingo::StateStore.hash("Tschau") }) + end + + it "unscoped accepts only drifted keys" do + with_app(config: accept_config, locales: accept_locales) do |root| + seed_state(root) + + out, _err, _code = run_cli(root, %w[accept-edits]) + + state = read_state(root, "g.de.json") + expect(state.dig("g.hi", "manual")).to be(true) + expect(state.fetch("g.bye")).not_to have_key("manual") + expect(out).to include("1") + end + end + + it "--key stamps only the named key" do + with_app(config: accept_config, locales: accept_locales) do |root| + seed_state(root) + + run_cli(root, %w[accept-edits --locale de --key g.bye]) + + state = read_state(root, "g.de.json") + expect(state.dig("g.bye", "manual")).to be(true) + expect(state.fetch("g.hi")).not_to have_key("manual") + end + end + + it "--all stamps every translated key" do + with_app(config: accept_config, locales: accept_locales) do |root| + seed_state(root) + + run_cli(root, %w[accept-edits --all]) + + state = read_state(root, "g.de.json") + expect(state.dig("g.hi", "manual")).to be(true) + expect(state.dig("g.bye", "manual")).to be(true) + end + end + + it "fails loudly for an unknown --key" do + with_app(config: accept_config, locales: accept_locales) do |root| + seed_state(root) + + _out, err, code = run_cli(root, %w[accept-edits --key g.nope]) + + expect(err).to include("g.nope") + expect(code).to eq(1) + end + end + end + describe ".locallingo.rb setup file" do it "loads it before dispatch so it can configure credentials" do with_app(config: { "target_locales" => %w[de] }, locales:) do |root| diff --git a/spec/locallingo/manager_spec.rb b/spec/locallingo/manager_spec.rb index c3a5fe9..d6b859a 100644 --- a/spec/locallingo/manager_spec.rb +++ b/spec/locallingo/manager_spec.rb @@ -150,6 +150,265 @@ end end + describe "#sync_state! state preservation" do + it "preserves target_hash and manual while refreshing source_hash" do + with_app( + config: { "target_locales" => %w[de] }, + locales: { + "en" => { "greeting" => { "hi" => "Hello" } }, + "de" => { "greeting" => { "hi" => "Hallo" } } + } + ) do |root| + write_state(root, "greeting.de.json", + "greeting.hi" => { + "source_hash" => "stale000", "target_hash" => "cafecafe", "manual" => true + }) + + described_class.new(config: config_for(root)).sync_state! + + entry = read_state(root, "greeting.de.json").fetch("greeting.hi") + expect(entry["source_hash"]).to eq(Locallingo::StateStore.hash("Hello")) + expect(entry["target_hash"]).to eq("cafecafe") # untouched, not recomputed + expect(entry["manual"]).to be(true) + end + end + + it "leaves bare entries bare (no target_hash or manual invented)" do + with_app( + config: { "target_locales" => %w[de] }, + locales: { + "en" => { "greeting" => { "hi" => "Hello" } }, + "de" => { "greeting" => { "hi" => "Hallo" } } + } + ) do |root| + write_state(root, "greeting.de.json", "greeting.hi" => { "source_hash" => "stale000" }) + + described_class.new(config: config_for(root)).sync_state! + + entry = read_state(root, "greeting.de.json").fetch("greeting.hi") + expect(entry).to eq("source_hash" => Locallingo::StateStore.hash("Hello")) + end + end + + it "still prunes entries for keys no longer in the target files" do + with_app( + config: { "target_locales" => %w[de] }, + locales: { + "en" => { "greeting" => { "hi" => "Hello" } }, + "de" => { "greeting" => { "hi" => "Hallo" } } + } + ) do |root| + write_state(root, "greeting.de.json", + "greeting.hi" => { "source_hash" => "stale000" }, + "greeting.gone" => { "source_hash" => "dead0000", "manual" => true }) + + described_class.new(config: config_for(root)).sync_state! + + expect(read_state(root, "greeting.de.json").keys).to eq(["greeting.hi"]) + end + end + end + + describe "#translate! and manual keys" do + it "retranslates a manual key via force_keys but preserves the manual flag" do + with_app( + config: { "target_locales" => %w[de] }, + locales: { + "en" => { "greeting" => { "hi" => "Hello" } }, + "de" => { "greeting" => { "hi" => "Hallo" } } + } + ) do |root| + write_state(root, "greeting.de.json", + "greeting.hi" => { + "source_hash" => "stale000", + "target_hash" => Locallingo::StateStore.hash("Hallo"), + "manual" => true + }) + stub_llm_chat { |payload:, **| payload.transform_values { |v| "DE:#{v}" } } + + described_class.new(config: config_for(root)).translate!(locale: "de", force_keys: ["greeting.hi"]) + + de = YAML.load_file(File.join(root, "config/locales/greeting.de.yml")) + expect(de.dig("de", "greeting", "hi")).to eq("DE:Hello") + + entry = read_state(root, "greeting.de.json").fetch("greeting.hi") + expect(entry["source_hash"]).to eq(Locallingo::StateStore.hash("Hello")) + expect(entry["target_hash"]).to eq(Locallingo::StateStore.hash("DE:Hello")) + expect(entry["manual"]).to be(true) + end + end + + it "skips manual keys under --force" do + with_app( + config: { "target_locales" => %w[de] }, + locales: { + "en" => { "greeting" => { "hi" => "Hello", "bye" => "Goodbye" } }, + "de" => { "greeting" => { "hi" => "Hallo", "bye" => "Tschau" } } + } + ) do |root| + write_state(root, "greeting.de.json", + "greeting.hi" => { + "source_hash" => Locallingo::StateStore.hash("Hello"), + "target_hash" => Locallingo::StateStore.hash("Hallo"), + "manual" => true + }) + stub_llm_chat { |payload:, **| payload.transform_values { |v| "DE:#{v}" } } + + described_class.new(config: config_for(root)).translate!(locale: "de", force: true) + + de = YAML.load_file(File.join(root, "config/locales/greeting.de.yml")) + expect(de.dig("de", "greeting", "hi")).to eq("Hallo") # protected + expect(de.dig("de", "greeting", "bye")).to eq("DE:Goodbye") + expect(read_state(root, "greeting.de.json").dig("greeting.hi", "manual")).to be(true) + end + end + end + + describe "#accept_edits!" do + let(:matrix_locales) do + { + "en" => { "g" => { "machine" => "M", "edited" => "E", "bare" => "B", "fresh" => "F" } }, + "de" => { "g" => { "machine" => "M-de", "edited" => "E-de", "bare" => "B-de", "fresh" => "F-de" } } + } + end + + def seed_matrix_state(root) + write_state(root, "g.de.json", + "g.machine" => { + "source_hash" => Locallingo::StateStore.hash("M"), + "target_hash" => Locallingo::StateStore.hash("M-de") + }, + "g.edited" => { + "source_hash" => Locallingo::StateStore.hash("E"), + "target_hash" => "00000000" + }, + "g.bare" => { "source_hash" => Locallingo::StateStore.hash("B") }) + end + + it "unscoped accepts only hand-edited (drifted) keys" do + with_app(config: { "target_locales" => %w[de] }, locales: matrix_locales) do |root| + seed_matrix_state(root) + + described_class.new(config: config_for(root)).accept_edits! + + state = read_state(root, "g.de.json") + expect(state.dig("g.edited", "manual")).to be(true) + expect(state.dig("g.edited", "target_hash")).to eq(Locallingo::StateStore.hash("E-de")) + expect(state.fetch("g.machine")).not_to have_key("manual") + expect(state.fetch("g.bare")).to eq("source_hash" => Locallingo::StateStore.hash("B")) + expect(state).not_to have_key("g.fresh") + end + end + + it "with keys: stamps exactly the named keys and leaves other namespaces byte-identical" do + with_app( + config: { "target_locales" => %w[de] }, + locales: { + "en" => { "g" => { "hi" => "Hello" }, "admin" => { "title" => "Admin" } }, + "de" => { "g" => { "hi" => "Hallo-edited" }, "admin" => { "title" => "Verwaltung" } } + } + ) do |root| + write_state(root, "g.de.json", + "g.hi" => { "source_hash" => Locallingo::StateStore.hash("Hello"), + "target_hash" => "deadbeef" }) + write_state(root, "admin.de.json", + "admin.title" => { "source_hash" => Locallingo::StateStore.hash("Admin"), + "target_hash" => "cafebabe" }) + admin_before = File.read(File.join(root, ".i18n-state", "admin.de.json")) + + described_class.new(config: config_for(root)).accept_edits!(keys: ["g.hi"]) + + expect(read_state(root, "g.de.json").fetch("g.hi")).to eq( + "source_hash" => Locallingo::StateStore.hash("Hello"), + "target_hash" => Locallingo::StateStore.hash("Hallo-edited"), + "manual" => true + ) + expect(File.read(File.join(root, ".i18n-state", "admin.de.json"))).to eq(admin_before) + end + end + + it "with keys: raises for a key present in no locale" do + with_app(config: { "target_locales" => %w[de] }, locales: matrix_locales) do |root| + seed_matrix_state(root) + + expect { described_class.new(config: config_for(root)).accept_edits!(keys: ["g.nope"]) } + .to raise_error(Locallingo::Error, /g\.nope/) + end + end + + it "with all: true stamps every translated key" do + with_app(config: { "target_locales" => %w[de] }, locales: matrix_locales) do |root| + seed_matrix_state(root) + + described_class.new(config: config_for(root)).accept_edits!(all: true) + + state = read_state(root, "g.de.json") + expect(state.keys).to match_array(%w[g.machine g.edited g.bare g.fresh]) + expect(state.values).to all(include("manual" => true)) + end + end + end + + describe "validator suggestions" do + it "tells the operator to hand-update outdated manual keys instead of force-translating" do + with_app( + config: { "target_locales" => %w[de], "validators" => { "manual_edits" => true } }, + locales: { + "en" => { "g" => { "hi" => "Hello" } }, + "de" => { "g" => { "hi" => "Hallo" } } + } + ) do |root| + write_state(root, "g.de.json", + "g.hi" => { + "source_hash" => "stale000", + "target_hash" => Locallingo::StateStore.hash("Hallo"), + "manual" => true + }) + + violations = described_class.new(config: config_for(root)).validate + outdated = violations.find { |v| v[:type] == :outdated } + expect(outdated[:suggestion]).to include("by hand") + expect(outdated[:suggestion]).to include("accept-edits --locale de --key g.hi") + end + end + + it "keeps the force-key suggestion for outdated non-manual keys" do + with_app( + config: { "target_locales" => %w[de] }, + locales: { + "en" => { "g" => { "hi" => "Hello" } }, + "de" => { "g" => { "hi" => "Hallo" } } + } + ) do |root| + write_state(root, "g.de.json", "g.hi" => { "source_hash" => "stale000" }) + + violations = described_class.new(config: config_for(root)).validate + outdated = violations.find { |v| v[:type] == :outdated } + expect(outdated[:suggestion]).to include("translate --locale de --force-key g.hi") + end + end + + it "suggests a key-scoped accept-edits for hand-edited values" do + with_app( + config: { "target_locales" => %w[de], "validators" => { "manual_edits" => true } }, + locales: { + "en" => { "g" => { "hi" => "Hello" } }, + "de" => { "g" => { "hi" => "Hallo edited" } } + } + ) do |root| + write_state(root, "g.de.json", + "g.hi" => { + "source_hash" => Locallingo::StateStore.hash("Hello"), + "target_hash" => "00000000" + }) + + violations = described_class.new(config: config_for(root)).validate + manual_edit = violations.find { |v| v[:type] == :manual_edit } + expect(manual_edit[:suggestion]).to include("accept-edits --locale de --key g.hi") + end + end + end + describe "#sync_state! then outdated detection" do it "flags a key as outdated after its source changes" do with_app( diff --git a/spec/locallingo/state_store_spec.rb b/spec/locallingo/state_store_spec.rb new file mode 100644 index 0000000..06efe75 --- /dev/null +++ b/spec/locallingo/state_store_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Locallingo::StateStore do + describe "#save" do + it "does not rewrite a namespace file whose content is unchanged" do + Dir.mktmpdir("locallingo-state") do |dir| + store = described_class.new(dir) + store.save("de", { "a.x" => { "source_hash" => "11111111" } }) + + file = File.join(dir, "a.de.json") + File.utime(Time.at(0), Time.at(0), file) + + store.save("de", { "a.x" => { "source_hash" => "11111111" } }) + + expect(File.mtime(file)).to eq(Time.at(0)) + end + end + + it "rewrites when content changed" do + Dir.mktmpdir("locallingo-state") do |dir| + store = described_class.new(dir) + store.save("de", { "a.x" => { "source_hash" => "11111111" } }) + + store.save("de", { "a.x" => { "source_hash" => "22222222" } }) + + expect(store.load("de").dig("a.x", "source_hash")).to eq("22222222") + end + end + + it "deletes namespace files whose keys are all gone" do + Dir.mktmpdir("locallingo-state") do |dir| + store = described_class.new(dir) + store.save("de", { "a.x" => { "source_hash" => "11111111" }, "b.y" => { "source_hash" => "22222222" } }) + + store.save("de", { "b.y" => { "source_hash" => "22222222" } }) + + expect(File).not_to exist(File.join(dir, "a.de.json")) + expect(File).to exist(File.join(dir, "b.de.json")) + end + end + end +end diff --git a/spec/support/locale_fixtures.rb b/spec/support/locale_fixtures.rb index 920911e..a4d3353 100644 --- a/spec/support/locale_fixtures.rb +++ b/spec/support/locale_fixtures.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "yaml" +require "json" require "fileutils" # Builds a throwaway app directory (config/locales + .locallingo.yml) in a tmp @@ -45,4 +46,15 @@ def write_config(root, config, raw_config) def config_for(root, package: nil) Locallingo::Configuration.load(root_path: root, package:) end + + # Seed a `.i18n-state/..json` file with raw entries. + def write_state(root, filename, entries) + dir = File.join(root, ".i18n-state") + FileUtils.mkdir_p(dir) + File.write(File.join(dir, filename), JSON.pretty_generate(entries)) + end + + def read_state(root, filename) + JSON.parse(File.read(File.join(root, ".i18n-state", filename))) + end end From 2820b53f8fdab05ced737492db32a2c06f7251df Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 16 Jul 2026 18:18:17 +0200 Subject: [PATCH 2/2] docs: non-destructive sync, scoped accept-edits, manual-flag permanence --- README.md | 6 +++-- docs/app/views/docs/pages/commands.rb | 24 +++++++++++++++----- docs/app/views/docs/pages/drift_state.rb | 28 +++++++++++++++++++----- docs/app/views/docs/pages/validators.rb | 9 ++++++-- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 05f13dd..3fe08f6 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,10 @@ lingo validate --strict # CI gate (exit 1 on strict-tier issues) lingo validate --strict-all # stricter CI gate lingo quality --ai # quality linting (+ optional AI pass) lingo fix-quality --locale en # auto-fix fixable issues -lingo accept-edits --locale de # mark current translations as intentional -lingo sync # rebuild drift state from current files +lingo accept-edits --locale de # protect hand-edited translations (drifted keys only) +lingo accept-edits --key a.b.c # protect a specific key +lingo accept-edits --all # protect everything (initial adoption) +lingo sync # refresh drift state (preserves manual flags) lingo hash # source-translation fingerprint ``` diff --git a/docs/app/views/docs/pages/commands.rb b/docs/app/views/docs/pages/commands.rb index 50856ac..490fd57 100644 --- a/docs/app/views/docs/pages/commands.rb +++ b/docs/app/views/docs/pages/commands.rb @@ -38,7 +38,8 @@ def translate - `--locale de` limits to one locale. - `--force` re-translates everything (respecting protected manual edits). - - `--force-key a.b.c` re-translates specific keys. + - `--force-key a.b.c` re-translates specific keys — including + manual-flagged ones, whose `manual` flag survives the rewrite. - `--dry-run` shows the plan without writing. On success it runs the configured `after_translate` hooks. Requires @@ -73,9 +74,17 @@ def quality_cmds def accept_edits DocsUI::Section("accept-edits") do md <<~'MD' - When the `manual_edits` validator is enabled, `accept-edits` records the - current target values as intentional so they are protected from being + When the `manual_edits` validator is enabled, `accept-edits` records + hand-edited target values as intentional so they are protected from being overwritten by the next `translate` and no longer flagged as hand-edited. + + - Unscoped, it accepts exactly the keys the `manual_edits` validator + flags — nothing else changes. + - `--key a.b.c` (repeatable) accepts specific keys, drifted or not. + - `--all` marks every translated key as manual — the blanket form, for + adopting locallingo on an app whose translations were all hand-made. + - `--locale de` limits any of the above to one locale. + See [Drift & state](/docs/drift-state). MD end @@ -84,9 +93,12 @@ def accept_edits def sync DocsUI::Section("sync") do md <<~'MD' - Rebuilds the `.i18n-state/` drift state from the current locale files — - for initial setup on an existing app, or after manual edits, so `validate` - doesn't report spurious "outdated" keys. + Refreshes the `.i18n-state/` drift state from the current locale files — + for initial setup on an existing app, or after editing source strings, so + `validate` doesn't report spurious "outdated" keys. It updates each key's + `source_hash` and prunes entries whose keys were removed; `target_hash` + and `manual` flags are always preserved, so hand-edit protection never + depends on when sync last ran. MD end end diff --git a/docs/app/views/docs/pages/drift_state.rb b/docs/app/views/docs/pages/drift_state.rb index 933ddec..c5ebd7a 100644 --- a/docs/app/views/docs/pages/drift_state.rb +++ b/docs/app/views/docs/pages/drift_state.rb @@ -12,6 +12,7 @@ def content the_files outdated manual_edits + flag_permanence syncing end @@ -66,8 +67,21 @@ def manual_edits With `validators.manual_edits` enabled, state entries also carry a `target_hash` and a `manual` flag. If someone hand-tunes a translation, its target hash no longer matches and `validate` flags a `manual_edit`. - Running `lingo accept-edits` stamps the current values as intentional - (setting `manual: true`), and `translate --force` then leaves them alone. + Running `lingo accept-edits` stamps the flagged values as intentional + (setting `manual: true`) — add `--key a.b.c` to accept one key surgically. + Protected keys are skipped by `translate` and `--force`; only an explicit + `--force-key` rewrites one, and even then the `manual` flag survives. + MD + end + end + + def flag_permanence + DocsUI::Section("The flag is permanent") do + md <<~'MD' + No command removes `manual: true` — not `sync`, not `translate`, not + `--force-key`. The only way to unprotect a key is to delete the flag from + its `.i18n-state/` entry by hand. That makes state diffs boring: a PR only + touches the entries for keys it actually changed. MD end end @@ -75,10 +89,12 @@ def manual_edits def syncing DocsUI::Section("Rebuilding state") do md <<~'MD' - Adopting locallingo on an existing app, or making a batch of manual edits, - can leave the state out of step with the files. `lingo sync` rewrites the - state from the current translations so nothing reads as spuriously - outdated. + Adopting locallingo on an existing app, or editing a batch of source + strings, can leave the state out of step with the files. `lingo sync` + refreshes each key's `source_hash` from the current translations and + prunes entries for removed keys, so nothing reads as spuriously outdated. + It never touches `target_hash` or `manual` — hand-edit drift stays + visible until you resolve it with `accept-edits`. MD DocsUI::Callout(:note) do plain "Run " diff --git a/docs/app/views/docs/pages/validators.rb b/docs/app/views/docs/pages/validators.rb index b47a9e7..7ce7546 100644 --- a/docs/app/views/docs/pages/validators.rb +++ b/docs/app/views/docs/pages/validators.rb @@ -51,7 +51,10 @@ def outdated current English value — i.e. the source text changed after the translation was made, so the translation may no longer be accurate. See [Drift & state](/docs/drift-state) for how this is tracked. The fix is - `lingo translate` (or `--force-key` for a single key). + `lingo translate` (or `--force-key` for a single key). For keys flagged + `manual` the suggestion differs: update the hand-curated value yourself, + then `accept-edits --key` it — machine translation is never pushed onto + protected keys. MD end end @@ -76,7 +79,9 @@ def manual_edits source hash. If a target value's current hash no longer matches — someone hand-edited it — this validator surfaces it so the next `translate` doesn't silently overwrite the edit. Confirm the edit with - `lingo accept-edits` to protect it. See [Drift & state](/docs/drift-state). + `lingo accept-edits --locale --key ` to protect that key (or run + it unscoped to accept every flagged edit). See + [Drift & state](/docs/drift-state). MD end end