locallingo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Locallingo
4
+ module Quality
5
+ # Regex-based, provider-free translation-quality rules. Ported from the
6
+ # original TranslationQualityChecker STATIC_RULES / UNIVERSAL_FIXES.
7
+ module StaticRules
8
+ # Locale-agnostic auto-fixable corrections.
9
+ UNIVERSAL_FIXES = {
10
+ "can not" => "cannot",
11
+ "Can not" => "Cannot"
12
+ }.freeze
13
+
14
+ RULES = {
15
+ terminology: {
16
+ /\bcan not\b/i => "Use 'cannot' (one word)",
17
+ /\blogin\s+(to|regularly|now|here|again|first|using|with)\b/i =>
18
+ "Use 'log in' (verb) not 'login' here",
19
+ /\bclick\s+here\b/i => "Avoid 'click here' - use descriptive link text",
20
+ /\bplease\b.*\bplease\b/i => "Multiple 'please' in same text - remove redundancy"
21
+ },
22
+ placeholders: {
23
+ /%\{[^}]*\s[^}]*\}/ => "Placeholder contains spaces - may cause issues",
24
+ /%\{[A-Z]/ => "Placeholder starts with uppercase - convention is lowercase"
25
+ },
26
+ clarity: {
27
+ /\betc\.?\b/i => "Avoid 'etc.' - be specific or use 'and more'",
28
+ /\bstuff\b/i => "Vague word 'stuff' - be more specific",
29
+ /\bthings\b/i => "Vague word 'things' - be more specific",
30
+ /\basap\b/i => "Avoid abbreviation 'ASAP' - use 'as soon as possible'",
31
+ /\bfyi\b/i => "Avoid abbreviation 'FYI' - rephrase",
32
+ /\s{2,}/ => "Multiple consecutive spaces"
33
+ },
34
+ business: {
35
+ /\bsorry\b/i => "Consider 'We apologize' for formal business tone",
36
+ /\boops\b/i => "Informal - use professional error messaging",
37
+ /\buh\s*oh\b/i => "Informal - use professional error messaging",
38
+ /\bawesome\b/i => "Consider more professional alternatives",
39
+ /\bcool\b(?!\s*down)/i => "Informal - consider 'great' or 'excellent'"
40
+ },
41
+ accessibility: {
42
+ /\bsee\s+below\b/i => "Screen reader unfriendly - describe the content",
43
+ /\babove\b/i => "Positional reference - may not work for all users",
44
+ /\bred\b.*\berror\b|\berror\b.*\bred\b/i => "Don't rely on color alone for meaning"
45
+ }
46
+ }.freeze
47
+
48
+ module_function
49
+
50
+ # Suggestions from the regex RULES for one key/text pair.
51
+ def check(key, text, locale)
52
+ suggestions = []
53
+
54
+ RULES.each do |category, rules|
55
+ rules.each do |pattern, message|
56
+ next unless text.match?(pattern)
57
+
58
+ suggestions << {
59
+ key:, text:, locale:, category:,
60
+ issue: message,
61
+ match: text.match(pattern).to_s,
62
+ severity: severity_for_category(category),
63
+ source: :static
64
+ }
65
+ end
66
+ end
67
+
68
+ suggestions
69
+ end
70
+
71
+ # Auto-fixable universal fixes for one key/text pair.
72
+ def universal_fixes(key, text, locale)
73
+ UNIVERSAL_FIXES.filter_map do |wrong, correct|
74
+ next unless text.include?(wrong)
75
+
76
+ {
77
+ key:, text:, locale:,
78
+ category: :grammar,
79
+ issue: "Use '#{correct}' instead of '#{wrong}'",
80
+ match: wrong,
81
+ fix: { from: wrong, to: correct },
82
+ severity: :warning,
83
+ source: :static
84
+ }
85
+ end
86
+ end
87
+
88
+ def severity_for_category(category)
89
+ case category
90
+ when :placeholders then :error
91
+ when :terminology, :accessibility then :warning
92
+ else :info # :clarity, :business, :length, etc.
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Locallingo
6
+ module Quality
7
+ # Business/domain terminology checks. A term maps to a suggestion string, or
8
+ # to nil when the term is acceptable (present only so it is documented as
9
+ # reviewed). Selected by `quality.terminology`: a built-in name ("business",
10
+ # "banking", "none") or a path to a custom YAML file.
11
+ class Terminology
12
+ # Generic business terminology (the cosmos-2 default).
13
+ BUSINESS = {
14
+ "wire transfer" => "Consider 'bank transfer' for broader understanding",
15
+ "checking account" => "Use 'current account' for non-US markets",
16
+ "savings account" => nil,
17
+ "routing number" => "Use 'sort code' (UK) or 'branch code' for non-US",
18
+ "zip code" => "Use 'postal code' for international audiences",
19
+ "transaction" => nil,
20
+ "company" => nil,
21
+ "business" => nil,
22
+ "customer" => nil,
23
+ "client" => nil,
24
+ "beneficiary" => nil,
25
+ "payee" => nil,
26
+ "kyc" => nil,
27
+ "kyb" => nil
28
+ }.freeze
29
+
30
+ # Banking terminology (the zazu/app default) — a superset of BUSINESS with
31
+ # extra always-acceptable regulatory terms.
32
+ BANKING = BUSINESS.merge(
33
+ "know your customer" => nil,
34
+ "know your business" => nil,
35
+ "aml" => nil,
36
+ "cdd" => nil
37
+ ).freeze
38
+
39
+ BUILTINS = { "business" => BUSINESS, "banking" => BANKING, "none" => {} }.freeze
40
+
41
+ attr_reader :terms
42
+
43
+ # +setting+ is a built-in name, a path to a YAML file, or nil (=> business).
44
+ def initialize(setting, base_path: Dir.pwd)
45
+ @terms = resolve(setting, base_path)
46
+ end
47
+
48
+ # Suggestions for flagged (non-nil) terms found in +text+.
49
+ def check(key, text, locale)
50
+ terms.filter_map do |term, suggestion|
51
+ next unless suggestion
52
+ next unless text.downcase.include?(term.downcase)
53
+
54
+ {
55
+ key:, text:, locale:,
56
+ category: :terminology,
57
+ issue: suggestion,
58
+ match: term,
59
+ severity: :info,
60
+ source: :static
61
+ }
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def resolve(setting, base_path)
68
+ return BUSINESS if setting.nil?
69
+ return BUILTINS.fetch(setting) if BUILTINS.key?(setting)
70
+
71
+ path = File.expand_path(setting, base_path)
72
+ raise Error, "Unknown terminology #{setting.inspect} (not a builtin or a file)" unless File.exist?(path)
73
+
74
+ YAML.safe_load_file(path) || {}
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ require_relative "configuration"
6
+ require_relative "key_flattener"
7
+ require_relative "providers/ruby_llm"
8
+ require_relative "quality/static_rules"
9
+ require_relative "quality/british_spellings"
10
+ require_relative "quality/terminology"
11
+
12
+ module Locallingo
13
+ # Checks translation quality and suggests improvements.
14
+ #
15
+ # Static (provider-free) checks: regex rules, universal fixes, terminology,
16
+ # optional British-spelling drift, and a very-long-text heuristic. An optional
17
+ # AI pass (RubyLLM) reviews a sample for clarity/professionalism.
18
+ #
19
+ # `fix!` rewrites the auto-fixable suggestions (universal fixes + British
20
+ # spellings) back into the locale files, preserving case.
21
+ class QualityChecker
22
+ LONG_TEXT_THRESHOLD = 200
23
+
24
+ attr_reader :config, :verbose, :logger
25
+
26
+ def initialize(config: nil, root_path: nil, package: nil, verbose: false, logger: nil)
27
+ @config = config || Configuration.load(root_path: root_path || Dir.pwd, package:)
28
+ @verbose = verbose
29
+ @logger = logger
30
+ @provider = Providers::RubyLLM.new(provider: @config.provider)
31
+ @terminology = Quality::Terminology.new(@config.terminology_setting, base_path: @config.base_path)
32
+ end
33
+
34
+ # Check all translations for a locale.
35
+ def check(locale: nil, use_ai: false)
36
+ locale ||= config.source_locale
37
+ translations = load_locale_translations(locale)
38
+
39
+ suggestions = translations.flat_map { |key, text| check_text(key, text, locale) }
40
+ suggestions.concat(ai_sample(translations, locale)) if use_ai
41
+ suggestions
42
+ end
43
+
44
+ # Check a single key.
45
+ def check_key(key, locale, use_ai: false)
46
+ translations = load_locale_translations(locale)
47
+ text = translations[key]
48
+ return [{ key:, error: "Key not found" }] unless text
49
+
50
+ suggestions = check_text(key, text, locale)
51
+ suggestions.concat(suggest_improvements({ key => text }, locale)) if use_ai
52
+ suggestions
53
+ end
54
+
55
+ # Auto-fix the fixable suggestions in the locale files. Returns
56
+ # { fixed: <file count>, skipped: <non-fixable count> }.
57
+ def fix!(locale: nil, dry_run: false)
58
+ locale ||= config.source_locale
59
+ suggestions = check(locale:)
60
+ fixable = suggestions.select { |s| s[:fix] }
61
+ return { fixed: 0, skipped: suggestions.size - fixable.size } if fixable.empty?
62
+
63
+ changed = apply_fixes(locale, fixable)
64
+
65
+ changed.each { |file, content| File.write(file, content) } unless dry_run
66
+ changed.each_key { |file| log("#{dry_run ? "Would fix" : "Fixed"}: #{file}") }
67
+
68
+ { fixed: changed.size, skipped: suggestions.size - fixable.size }
69
+ end
70
+
71
+ # AI suggestions for a batch of key=>text pairs.
72
+ def suggest_improvements(keys_with_text, locale)
73
+ unless @provider.credentials?
74
+ warn "⚠️ No LLM credentials — skipping AI suggestions"
75
+ return []
76
+ end
77
+
78
+ result = @provider.chat(
79
+ model: config.quality_model,
80
+ instructions: quality_prompt(locale),
81
+ payload: keys_with_text
82
+ )
83
+
84
+ result.map do |key, suggestion|
85
+ symbolized = suggestion.transform_keys(&:to_sym)
86
+ symbolized[:severity] = symbolized[:severity]&.to_sym || :info
87
+ { key:, text: keys_with_text[key], locale:, source: :ai, **symbolized }
88
+ end
89
+ rescue StandardError => e
90
+ warn "⚠️ AI suggestion failed: #{e.message}"
91
+ []
92
+ end
93
+
94
+ private
95
+
96
+ def check_text(key, text, locale)
97
+ suggestions = []
98
+ suggestions.concat(Quality::StaticRules.universal_fixes(key, text, locale))
99
+ suggestions.concat(Quality::StaticRules.check(key, text, locale))
100
+ suggestions.concat(@terminology.check(key, text, locale))
101
+ suggestions.concat(Quality::BritishSpellings.check(key, text, locale)) if british_spellings_for?(locale)
102
+ suggestions << long_text_suggestion(key, text, locale) if text.length > LONG_TEXT_THRESHOLD
103
+ suggestions.compact
104
+ end
105
+
106
+ def british_spellings_for?(locale)
107
+ config.british_spellings? && locale.to_s == config.source_locale.to_s
108
+ end
109
+
110
+ def long_text_suggestion(key, text, locale)
111
+ {
112
+ key:, text: truncate(text, 100), locale:,
113
+ category: :length,
114
+ issue: "Text is very long (#{text.length} chars) - consider splitting",
115
+ severity: :info, source: :static
116
+ }
117
+ end
118
+
119
+ def ai_sample(translations, locale)
120
+ sample_size = [translations.size, 100].min
121
+ sample = translations.to_a.sample(sample_size).to_h
122
+ suggest_improvements(sample, locale)
123
+ end
124
+
125
+ def apply_fixes(locale, fixable)
126
+ changed = {}
127
+ Dir.glob(File.join(config.locales_dir, "**", "*.#{locale}.yml")).each do |file|
128
+ original = File.read(file)
129
+ content = original.dup
130
+
131
+ fixable.each do |suggestion|
132
+ next unless content.include?(suggestion[:text])
133
+
134
+ fixed_text = apply_case_preserving_fix(suggestion[:text], suggestion[:fix])
135
+ content = content.gsub(suggestion[:text], fixed_text)
136
+ end
137
+
138
+ changed[file] = content if content != original
139
+ end
140
+ changed
141
+ end
142
+
143
+ def apply_case_preserving_fix(text, fix)
144
+ text.gsub(/\b#{Regexp.escape(fix[:from])}\b/i) do |match|
145
+ if match == match.upcase then fix[:to].upcase
146
+ elsif match[0] == match[0].upcase then fix[:to].capitalize
147
+ else fix[:to]
148
+ end
149
+ end
150
+ end
151
+
152
+ def quality_prompt(locale)
153
+ <<~PROMPT
154
+ Review these UI translations for #{config.context}.
155
+ Suggest improvements for clarity, professionalism, and user-friendliness.
156
+ #{glossary_section}
157
+ For each translation that needs improvement, provide:
158
+ 1. The issue (brief)
159
+ 2. Suggested improvement
160
+ 3. Severity: "error" (must fix), "warning" (should fix), "info" (nice to have)
161
+
162
+ Only include translations that actually need changes.
163
+ Return ONLY a raw JSON object, with no surrounding prose and no markdown
164
+ code fences: {"key": {"issue": "...", "suggestion": "...", "severity": "..."}}
165
+
166
+ Locale: #{locale}
167
+ PROMPT
168
+ end
169
+
170
+ def glossary_section
171
+ return "" if config.glossary.empty?
172
+
173
+ lines = config.glossary.map { |term, meaning| " - \"#{term}\" = #{meaning}" }
174
+ "\n#{config.context} terminology:\n#{lines.join("\n")}\n"
175
+ end
176
+
177
+ def load_locale_translations(locale)
178
+ translations = {}
179
+ patterns = [
180
+ File.join(config.locales_dir, "**", "*.#{locale}.yml"),
181
+ File.join(config.locales_dir, "#{locale}.yml")
182
+ ]
183
+
184
+ patterns.each do |pattern|
185
+ Dir.glob(pattern).each do |file|
186
+ content = YAML.load_file(file)
187
+ next unless content.is_a?(Hash) && content[locale]
188
+
189
+ KeyFlattener.flatten(content[locale]).each do |key, value|
190
+ translations[key] = value if value.is_a?(String)
191
+ end
192
+ end
193
+ end
194
+
195
+ translations
196
+ end
197
+
198
+ def truncate(text, length)
199
+ text.length > length ? "#{text[0, length]}..." : text
200
+ end
201
+
202
+ def log(message)
203
+ logger&.info(message)
204
+ warn(message) if verbose
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Locallingo
6
+ # Renders status/violation/quality output for the CLI (text + JSON) and
7
+ # computes strict exit codes. Extracted from the original bin/translate
8
+ # printers so the CLI stays thin.
9
+ class Reporter
10
+ SEVERITY_ICONS = { error: "🔴", warning: "🟡", info: "🔵" }.freeze
11
+ TYPE_ICONS = {
12
+ missing: "❌", outdated: "🔄", duplicate_value: "🔁", manual_edit: "✏️"
13
+ }.freeze
14
+
15
+ def initialize(config:, format: :text, io: $stdout, cli_name: "lingo")
16
+ @config = config
17
+ @format = format
18
+ @io = io
19
+ @cli_name = cli_name
20
+ end
21
+
22
+ def status(status)
23
+ return json(status) if json?
24
+
25
+ puts "\n📊 Translation Status"
26
+ puts "=" * 60
27
+ status.each { |locale, info| print_locale_status(locale, info) }
28
+ puts ""
29
+ end
30
+
31
+ # Prints violations and returns the strict exit code.
32
+ def violations(violations, strict: false, strict_all: false)
33
+ if json?
34
+ json(violations)
35
+ return exit_code(violations, strict:, strict_all:)
36
+ end
37
+
38
+ if violations.empty?
39
+ puts "\n✅ All translations valid!"
40
+ return 0
41
+ end
42
+
43
+ puts "\n❌ Translation Issues Found"
44
+ puts "=" * 60
45
+ violations.group_by { |v| v[:type] }.each { |type, items| print_violation_group(type, items) }
46
+ puts "\nTotal: #{violations.size} issues"
47
+
48
+ exit_code(violations, strict:, strict_all:)
49
+ end
50
+
51
+ def quality(suggestions, locale:)
52
+ return json(suggestions) if json?
53
+
54
+ if suggestions.empty?
55
+ puts "\n✅ No quality issues found!"
56
+ return
57
+ end
58
+
59
+ puts "\n📝 Translation Quality Suggestions"
60
+ puts "=" * 60
61
+ print_quality_by_severity(suggestions)
62
+ print_quality_summary(suggestions, locale)
63
+ end
64
+
65
+ # Exit code for a strict tier, per the configured strict_types.
66
+ def exit_code(violations, strict:, strict_all:)
67
+ return 0 unless strict || strict_all
68
+
69
+ tier = strict_all ? :strict_all : :strict
70
+ error_types = @config.strict_types(tier)
71
+ violations.any? { |v| error_types.include?(v[:type]) } ? 1 : 0
72
+ end
73
+
74
+ private
75
+
76
+ def json? = @format == :json
77
+ def puts(str = "") = @io.puts(str)
78
+
79
+ def json(data)
80
+ @io.puts(JSON.pretty_generate(data))
81
+ end
82
+
83
+ def print_locale_status(locale, info)
84
+ icon = info[:missing].zero? && info[:outdated].zero? ? "✅" : "⚠️"
85
+ puts "\n#{icon} #{locale}"
86
+ puts " Total keys: #{info[:total_keys]}"
87
+ puts " Translated: #{info[:translated]}"
88
+ puts " Missing: #{info[:missing]}"
89
+ puts " Outdated: #{info[:outdated]}"
90
+ print_key_list("Missing (first 10):", info[:missing_keys])
91
+ print_key_list("Outdated (first 10):", info[:outdated_keys])
92
+ end
93
+
94
+ def print_key_list(label, keys)
95
+ return unless keys&.any?
96
+
97
+ puts " #{label}"
98
+ keys.each { |k| puts " - #{k}" }
99
+ end
100
+
101
+ def print_violation_group(type, items)
102
+ puts "\n#{TYPE_ICONS.fetch(type, "❓")} #{type.to_s.tr("_", " ").capitalize} (#{items.size})"
103
+ items.first(10).each do |v|
104
+ puts " #{v[:locale]}: #{v[:key]}"
105
+ puts " → #{v[:suggestion]}"
106
+ end
107
+ puts " ... and #{items.size - 10} more" if items.size > 10
108
+ end
109
+
110
+ def print_quality_by_severity(suggestions)
111
+ by_severity = suggestions.group_by { |s| s[:severity] }
112
+ %i[error warning info].each do |severity|
113
+ items = by_severity[severity] || []
114
+ next if items.empty?
115
+
116
+ fixable = items.count { |s| s[:fix] }
117
+ note = fixable.positive? ? " (#{fixable} auto-fixable)" : ""
118
+ puts "\n#{SEVERITY_ICONS[severity]} #{severity.to_s.capitalize} (#{items.size})#{note}"
119
+ items.first(20).each { |s| print_quality_item(s) }
120
+ puts " ... and #{items.size - 20} more" if items.size > 20
121
+ end
122
+ end
123
+
124
+ def print_quality_item(suggestion)
125
+ puts " #{suggestion[:key]}"
126
+ puts " Text: #{truncate(suggestion[:text].to_s, 60)}"
127
+ puts " Issue: #{suggestion[:issue]}"
128
+ puts " Fix: Replace '#{suggestion[:fix][:from]}' → '#{suggestion[:fix][:to]}'" if suggestion[:fix]
129
+ puts ""
130
+ end
131
+
132
+ def print_quality_summary(suggestions, locale)
133
+ fixable = suggestions.count { |s| s[:fix] }
134
+ puts "\n#{"-" * 60}"
135
+ puts "Summary: #{suggestions.size} issues found"
136
+ if fixable.positive?
137
+ puts "\nTo auto-fix #{fixable} issues, run:"
138
+ puts " #{@cli_name} fix-quality --locale #{locale}"
139
+ end
140
+ puts ""
141
+ end
142
+
143
+ def truncate(text, length)
144
+ text.length > length ? "#{text[0, length]}..." : text
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loads Locallingo's RuboCop cops. `require`d only from a host app's
4
+ # `.rubocop.yml` (`require: locallingo/rubocop`), so `rubocop` is never pulled
5
+ # into the app's runtime — it is a development-time dependency of both the host
6
+ # app and this gem.
7
+ #
8
+ # The shipped defaults live in config/default.yml; a host app enables/scopes the
9
+ # cops there via `inherit_gem`.
10
+
11
+ require "rubocop"
12
+
13
+ require_relative "../rubocop/cop/locallingo/relative_i18n_key"
14
+ require_relative "../rubocop/cop/locallingo/strftime_in_view"
15
+
16
+ module Locallingo
17
+ # Absolute path to the cop defaults, for `inherit_gem: locallingo: <path>`.
18
+ module RuboCop
19
+ CONFIG_DEFAULT = File.expand_path("../../config/default.yml", __dir__)
20
+ end
21
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zlib"
5
+ require "fileutils"
6
+
7
+ module Locallingo
8
+ # Reads and writes the source-hash drift state under `state_dir`.
9
+ #
10
+ # State is split into one JSON file per top-level namespace and locale
11
+ # (`accounts.de.json`, ...) so diffs stay small and reviewable. Each entry
12
+ # records the source hash a key was translated from, so a later source change
13
+ # marks the translation outdated. Optionally a `target_hash` and `manual` flag
14
+ # are tracked so hand-edited target values can be protected from overwrites.
15
+ class StateStore
16
+ attr_reader :state_dir
17
+
18
+ def initialize(state_dir)
19
+ @state_dir = state_dir
20
+ FileUtils.mkdir_p(@state_dir)
21
+ end
22
+
23
+ # CRC32 hash — fast and compact (8 hex chars).
24
+ def self.hash(text)
25
+ format("%08x", Zlib.crc32(text.to_s))
26
+ end
27
+
28
+ def hash(text) = self.class.hash(text)
29
+
30
+ # Load the combined state for a locale (merged across its namespace files).
31
+ def load(locale)
32
+ combined = {}
33
+ Dir.glob(File.join(state_dir, "*.#{locale}.json")).each do |file|
34
+ combined.merge!(JSON.parse(File.read(file)))
35
+ end
36
+ combined
37
+ rescue JSON::ParserError => e
38
+ raise Error,
39
+ "Corrupted state file: #{e.message}\n" \
40
+ "This would cause state loss. Fix the JSON manually or restore from git."
41
+ end
42
+
43
+ # Save a locale's state, split back into per-namespace files. Namespace files
44
+ # that no longer have keys are removed.
45
+ def save(locale, locale_state)
46
+ by_namespace = locale_state.each_with_object({}) do |(key, value), groups|
47
+ namespace = key.split(".").first
48
+ (groups[namespace] ||= {})[key] = value
49
+ end
50
+
51
+ by_namespace.each do |namespace, keys|
52
+ state_file = File.join(state_dir, "#{namespace}.#{locale}.json")
53
+ File.write(state_file, JSON.pretty_generate(keys.sort.to_h))
54
+ end
55
+
56
+ Dir.glob(File.join(state_dir, "*.#{locale}.json")).each do |file|
57
+ namespace = File.basename(file).delete_suffix(".#{locale}.json")
58
+ File.delete(file) unless by_namespace.key?(namespace)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Locallingo
4
+ module Validators
5
+ # Detects source-locale keys whose value matches an
6
+ # `activerecord.attributes.*` value. The AR key wins; the non-AR key is the
7
+ # "duplicate" and should reuse the AR key instead.
8
+ #
9
+ # Operates on the flat source hash only, to avoid false positives from
10
+ # grammatical-form differences in non-English locales.
11
+ class DuplicateValues
12
+ AR_ATTRIBUTES_PREFIX = "activerecord.attributes."
13
+ AR_PREFIX = "activerecord."
14
+
15
+ # +source+ is the flat source (en) hash. Returns :duplicate_value
16
+ # violations naming both keys.
17
+ def call(source:)
18
+ ar_keys_by_value = source
19
+ .select { |key, _| key.start_with?(AR_ATTRIBUTES_PREFIX) }
20
+ .group_by { |_key, value| value }
21
+ .transform_values { |pairs| pairs.map(&:first) }
22
+
23
+ source.filter_map do |key, value|
24
+ # Skip ALL activerecord.* keys (models, attributes, etc.). Rails
25
+ # reserves this namespace for AR-generated translations, and model
26
+ # names colliding with attribute labels is intentional, not a dup.
27
+ next if key.start_with?(AR_PREFIX)
28
+ next unless ar_keys_by_value.key?(value)
29
+
30
+ ar_dupes = ar_keys_by_value[value]
31
+ {
32
+ type: :duplicate_value,
33
+ locale: "en",
34
+ key:,
35
+ suggestion: "Value '#{value}' duplicates #{ar_dupes.join(", ")}. " \
36
+ "Use #{ar_dupes.first} instead."
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../state_store"
4
+
5
+ module Locallingo
6
+ module Validators
7
+ # Detects target values that were hand-edited after Locallingo wrote them.
8
+ #
9
+ # When enabled, the Manager records a `target_hash` alongside `source_hash`
10
+ # for each translated key. If the current target value's hash differs from
11
+ # the recorded `target_hash` (and the key is not already flagged `manual`),
12
+ # it was edited by a human — surface it so the operator can protect it with
13
+ # `accept-edits` before the next translate run overwrites it.
14
+ class ManualEdits
15
+ def initialize(cli_name: "lingo")
16
+ @cli_name = cli_name
17
+ end
18
+
19
+ # +target+ is the flat target hash; +locale_state+ its loaded state.
20
+ def call(target:, locale_state:, locale:)
21
+ target.filter_map do |key, value|
22
+ entry = locale_state[key]
23
+ next unless entry.is_a?(Hash)
24
+ next if entry["manual"]
25
+
26
+ stored = entry["target_hash"]
27
+ next unless stored && stored != StateStore.hash(value)
28
+
29
+ {
30
+ type: :manual_edit,
31
+ locale:,
32
+ key:,
33
+ suggestion: "Value was hand-edited. Protect it: #{@cli_name} accept-edits --locale #{locale}"
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end