i18n-context-generator 0.3.0 → 0.5.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +163 -26
  3. data/lib/i18n_context_generator/android_resource.rb +209 -0
  4. data/lib/i18n_context_generator/apple_string_literal.rb +28 -0
  5. data/lib/i18n_context_generator/cache.rb +40 -8
  6. data/lib/i18n_context_generator/changed_location.rb +55 -0
  7. data/lib/i18n_context_generator/cli.rb +165 -73
  8. data/lib/i18n_context_generator/config/cli_values.rb +33 -0
  9. data/lib/i18n_context_generator/config/defaults.rb +35 -0
  10. data/lib/i18n_context_generator/config/schema.rb +225 -0
  11. data/lib/i18n_context_generator/config/serialization.rb +64 -0
  12. data/lib/i18n_context_generator/config/validation.rb +249 -0
  13. data/lib/i18n_context_generator/config.rb +315 -105
  14. data/lib/i18n_context_generator/context_extractor/cache_identity.rb +65 -0
  15. data/lib/i18n_context_generator/context_extractor/extraction_result.rb +46 -0
  16. data/lib/i18n_context_generator/context_extractor/run_logging.rb +65 -0
  17. data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
  18. data/lib/i18n_context_generator/context_extractor/source_filters.rb +104 -0
  19. data/lib/i18n_context_generator/context_extractor/translation_filters.rb +91 -0
  20. data/lib/i18n_context_generator/context_extractor/workflow.rb +118 -0
  21. data/lib/i18n_context_generator/context_extractor.rb +218 -164
  22. data/lib/i18n_context_generator/file_classifier.rb +72 -0
  23. data/lib/i18n_context_generator/generated_comment.rb +32 -0
  24. data/lib/i18n_context_generator/git_diff/xml_changes.rb +235 -0
  25. data/lib/i18n_context_generator/git_diff.rb +280 -88
  26. data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
  27. data/lib/i18n_context_generator/llm/client.rb +294 -92
  28. data/lib/i18n_context_generator/llm/openai.rb +92 -51
  29. data/lib/i18n_context_generator/llm/openai_compatible.rb +20 -0
  30. data/lib/i18n_context_generator/llm/prompt_evidence.rb +47 -0
  31. data/lib/i18n_context_generator/llm/request_policy.rb +147 -0
  32. data/lib/i18n_context_generator/localization_syntax.rb +246 -0
  33. data/lib/i18n_context_generator/parsers/android_xml_parser.rb +47 -8
  34. data/lib/i18n_context_generator/parsers/base.rb +7 -4
  35. data/lib/i18n_context_generator/parsers/json_parser.rb +4 -0
  36. data/lib/i18n_context_generator/parsers/xcstrings_parser.rb +79 -0
  37. data/lib/i18n_context_generator/parsers/yaml_parser.rb +20 -7
  38. data/lib/i18n_context_generator/path_policy.rb +107 -0
  39. data/lib/i18n_context_generator/platform_validator.rb +24 -50
  40. data/lib/i18n_context_generator/run_metrics.rb +47 -0
  41. data/lib/i18n_context_generator/searcher/comment_masking.rb +115 -0
  42. data/lib/i18n_context_generator/searcher/match_filtering.rb +52 -0
  43. data/lib/i18n_context_generator/searcher/source_discovery.rb +232 -0
  44. data/lib/i18n_context_generator/searcher.rb +69 -201
  45. data/lib/i18n_context_generator/supplemental_context.rb +77 -0
  46. data/lib/i18n_context_generator/translation_comment_index.rb +121 -0
  47. data/lib/i18n_context_generator/version.rb +1 -1
  48. data/lib/i18n_context_generator/writers/android_xml_writer.rb +48 -18
  49. data/lib/i18n_context_generator/writers/atomic_file.rb +37 -0
  50. data/lib/i18n_context_generator/writers/csv_writer.rb +43 -18
  51. data/lib/i18n_context_generator/writers/helpers.rb +7 -29
  52. data/lib/i18n_context_generator/writers/json_writer.rb +31 -6
  53. data/lib/i18n_context_generator/writers/preview.rb +80 -0
  54. data/lib/i18n_context_generator/writers/result_serialization.rb +30 -0
  55. data/lib/i18n_context_generator/writers/strings_writer.rb +23 -12
  56. data/lib/i18n_context_generator/writers/swift_writer.rb +16 -54
  57. data/lib/i18n_context_generator/writers/xcstrings_writer.rb +57 -0
  58. data/lib/i18n_context_generator/xcstrings_document.rb +218 -0
  59. data/lib/i18n_context_generator/xml_scanner.rb +38 -0
  60. data/lib/i18n_context_generator.rb +20 -4
  61. metadata +62 -12
@@ -1,121 +1,194 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set' # rubocop:disable Lint/RedundantRequireStatement -- required when Config is loaded directly
4
+ require_relative 'config/schema'
5
+ require_relative 'config/validation'
6
+ require_relative 'config/serialization'
7
+ require_relative 'config/defaults'
8
+ require_relative 'config/cli_values'
9
+
3
10
  module I18nContextGenerator
4
11
  # Holds all configuration for an extraction run, loaded from YAML config files and/or CLI options.
5
12
  class Config
6
- attr_reader :translations, :source_paths, :ignore_patterns,
13
+ include Validation
14
+ include Serialization
15
+ extend Defaults
16
+ extend CliValues
17
+
18
+ attr_reader :translations, :source_paths, :source_line_filter, :ignore_patterns,
7
19
  :provider, :model, :concurrency, :context_lines,
8
20
  :max_matches_per_key, :output_path, :output_format,
9
21
  :no_cache, :dry_run, :key_filter, :write_back,
10
- :swift_functions, :write_back_to_code, :diff_base, :context_prefix,
22
+ :swift_functions, :write_back_to_code, :diff_base, :diff_head, :context_prefix,
11
23
  :context_mode, :start_key, :end_key, :include_file_paths,
12
- :include_translation_comments, :redact_prompts
24
+ :include_translation_comments, :redact_prompts, :discovery_mode,
25
+ :platform, :translation_locales, :max_prompt_chars, :cache_dir, :endpoint,
26
+ :schema_version, :output_stdout, :print_config, :workflow_stage,
27
+ :context_files, :supplemental_context
13
28
 
14
- DEFAULT_CONTEXT_PREFIX = 'Context: '
15
- DEFAULT_CONTEXT_MODE = 'replace' # "replace" or "append"
29
+ DEFAULT_CONTEXT_PREFIX = Schema.default(:context_prefix).freeze
30
+ DEFAULT_CONTEXT_MODE = Schema.default(:context_mode).freeze
31
+ DEFAULT_MAX_PROMPT_CHARS = Schema.default(:max_prompt_chars)
32
+ DEFAULT_CACHE_DIR = Schema.default(:cache_dir).freeze
33
+ VALID_PROVIDERS = Schema.values(:provider)
34
+ VALID_OUTPUT_FORMATS = Schema.values(:output_format)
35
+ VALID_CONTEXT_MODES = Schema.values(:context_mode)
36
+ VALID_DISCOVERY_MODES = Schema.values(:discovery_mode)
37
+ VALID_PLATFORMS = Schema.values(:platform)
38
+ VALID_OUTPUT_EXTENSIONS = { '.csv' => 'csv', '.json' => 'json' }.freeze
16
39
 
17
40
  def initialize(**attrs)
18
- @translations = attrs[:translations] || []
19
- @source_paths = attrs[:source_paths] || ['.']
20
- @ignore_patterns = attrs[:ignore_patterns] || []
21
- @provider = attrs[:provider] || 'anthropic'
22
- @model = attrs[:model]
23
- @concurrency = attrs[:concurrency] || 5
24
- @context_lines = attrs[:context_lines] || 15
25
- @max_matches_per_key = attrs[:max_matches_per_key] || 3
26
- @output_path = attrs.key?(:output_path) ? attrs[:output_path] : nil
27
- @output_format = attrs[:output_format] || 'csv'
28
- @no_cache = attrs.key?(:no_cache) ? attrs[:no_cache] : true
29
- @dry_run = attrs[:dry_run] || false
30
- @key_filter = attrs[:key_filter]
31
- @write_back = attrs[:write_back] || false
32
- @write_back_to_code = attrs[:write_back_to_code] || false
33
- @swift_functions = attrs[:swift_functions] || default_swift_functions
34
- @diff_base = attrs[:diff_base]
35
- @context_prefix = attrs.key?(:context_prefix) ? attrs[:context_prefix] : DEFAULT_CONTEXT_PREFIX
36
- @context_mode = attrs[:context_mode] || DEFAULT_CONTEXT_MODE
37
- @start_key = attrs[:start_key]
38
- @end_key = attrs[:end_key]
39
- @include_file_paths = attrs.key?(:include_file_paths) ? attrs[:include_file_paths] : false
40
- @include_translation_comments = attrs.key?(:include_translation_comments) ? attrs[:include_translation_comments] : true
41
- @redact_prompts = attrs.key?(:redact_prompts) ? attrs[:redact_prompts] : true
41
+ @schema_version = fetch_defaulting_value(attrs, :schema_version, Schema.default(:schema_version))
42
+ @translations = deduplicate_paths(fetch_defaulting_value(attrs, :translations, Schema.default(:translations)))
43
+ @source_paths = deduplicate_source_paths(fetch_defaulting_value(attrs, :source_paths, Schema.default(:source_paths)))
44
+ @context_files = deduplicate_paths(fetch_defaulting_value(attrs, :context_files, Schema.default(:context_files)))
45
+ @supplemental_context = fetch_defaulting_value(attrs, :supplemental_context, {})
46
+ @source_line_filter = fetch_config_value(attrs, :source_line_filter, nil)
47
+ @translation_locales = fetch_defaulting_value(attrs, :translation_locales, {})
48
+ @ignore_patterns = self.class.merge_ignore_patterns(fetch_defaulting_value(attrs, :ignore_patterns, Schema.default(:ignore_patterns)))
49
+ @provider = normalize_enum_value(fetch_defaulting_value(attrs, :provider, Schema.default(:provider)))
50
+ @model = fetch_config_value(attrs, :model, nil)
51
+ @endpoint = fetch_config_value(attrs, :endpoint, nil)
52
+ @concurrency = fetch_defaulting_value(attrs, :concurrency, Schema.default(:concurrency))
53
+ @context_lines = fetch_defaulting_value(attrs, :context_lines, Schema.default(:context_lines))
54
+ @max_matches_per_key = fetch_defaulting_value(attrs, :max_matches_per_key, Schema.default(:max_matches_per_key))
55
+ @max_prompt_chars = fetch_defaulting_value(attrs, :max_prompt_chars, Schema.default(:max_prompt_chars))
56
+ configured_output_path = fetch_config_value(attrs, :output_path, nil)
57
+ explicit_stdout = fetch_boolean_value(attrs, :output_stdout, Schema.default(:output_stdout))
58
+ @output_stdout = explicit_stdout || configured_output_path == '-'
59
+ @output_destination_conflict = explicit_stdout && !configured_output_path.nil? && configured_output_path != '-'
60
+ @output_path = @output_stdout ? '-' : configured_output_path
61
+ @output_format_explicit = attrs.key?(:output_format) && !attrs[:output_format].nil?
62
+ @output_format = normalize_enum_value(resolve_output_format(attrs[:output_format], @output_path))
63
+ @no_cache = fetch_boolean_value(attrs, :no_cache, !Schema.default(:cache_enabled))
64
+ @cache_dir = fetch_defaulting_value(attrs, :cache_dir, Schema.default(:cache_dir))
65
+ @dry_run = fetch_boolean_value(attrs, :dry_run, Schema.default(:dry_run))
66
+ @print_config = fetch_boolean_value(attrs, :print_config, Schema.default(:print_config))
67
+ @workflow_stage = normalize_enum_value(
68
+ fetch_defaulting_value(attrs, :workflow_stage, Schema.default(:workflow_stage))
69
+ )
70
+ @key_filter = fetch_config_value(attrs, :key_filter, nil)
71
+ @write_back = fetch_boolean_value(attrs, :write_back, Schema.default(:write_back))
72
+ @write_back_to_code = fetch_boolean_value(attrs, :write_back_to_code, Schema.default(:write_back_to_code))
73
+ @swift_functions = merge_swift_functions(
74
+ fetch_defaulting_value(attrs, :swift_functions, default_swift_functions)
75
+ )
76
+ @diff_base = fetch_config_value(attrs, :diff_base, nil)
77
+ @diff_head = fetch_defaulting_value(attrs, :diff_head, Schema.default(:diff_head))
78
+ @context_prefix = fetch_defaulting_value(attrs, :context_prefix, Schema.default(:context_prefix))
79
+ @context_mode = normalize_enum_value(fetch_defaulting_value(attrs, :context_mode, Schema.default(:context_mode)))
80
+ @start_key = fetch_config_value(attrs, :start_key, nil)
81
+ @end_key = fetch_config_value(attrs, :end_key, nil)
82
+ @include_file_paths = fetch_boolean_value(attrs, :include_file_paths, Schema.default(:include_file_paths))
83
+ @include_translation_comments = fetch_boolean_value(attrs, :include_translation_comments, Schema.default(:include_translation_comments))
84
+ @redact_prompts = fetch_boolean_value(attrs, :redact_prompts, Schema.default(:redact_prompts))
85
+ @discovery_mode = normalize_enum_value(fetch_defaulting_value(attrs, :discovery_mode, Schema.default(:discovery_mode)))
86
+ @platform = normalize_enum_value(fetch_config_value(attrs, :platform, nil))
42
87
  end
43
88
 
44
89
  def default_swift_functions
45
- %w[NSLocalizedString String(localized: Text(]
90
+ Schema.default(:swift_functions)
46
91
  end
47
92
 
48
93
  def self.load(options)
49
- if options[:config] && File.exist?(options[:config])
50
- from_file(options[:config]).merge_cli(options)
51
- else
52
- from_cli(options)
53
- end
94
+ return from_cli(options) unless options[:config]
95
+
96
+ raise Error, "Config file not found: #{options[:config]}" unless File.file?(options[:config])
97
+
98
+ from_file(options[:config]).merge_cli(options)
54
99
  end
55
100
 
56
101
  def self.from_file(path)
57
102
  yaml = YAML.safe_load_file(path, permitted_classes: []) || {}
103
+ raise Error, "Invalid config #{path}: root must be a mapping" unless yaml.is_a?(Hash)
104
+
105
+ schema_version = Schema.validate_document!(yaml, path: path)
106
+ %w[source context llm processing output swift privacy workflow].each { |section| config_section(yaml, section, path) }
107
+ cache = config_section(yaml, 'cache', path)
108
+ invalid_cache_enabled = cache.key?('enabled') && ![true, false].include?(cache['enabled'])
109
+ raise Error, "Invalid config #{path}: cache.enabled must be true or false" if invalid_cache_enabled
110
+
111
+ translation_settings = parse_translation_settings(yaml['translations'], path: path)
58
112
 
59
113
  attrs = {
60
- translations: parse_translations(yaml['translations']),
61
- source_paths: yaml.dig('source', 'paths') || ['.'],
62
- ignore_patterns: yaml.dig('source', 'ignore') || default_ignore_patterns,
63
- provider: yaml.dig('llm', 'provider') || 'anthropic',
64
- model: yaml.dig('llm', 'model'),
65
- concurrency: yaml.dig('processing', 'concurrency') || 5,
66
- context_lines: yaml.dig('processing', 'context_lines') || 15,
67
- max_matches_per_key: yaml.dig('processing', 'max_matches_per_key') || 3,
68
- output_path: yaml.dig('output', 'path'),
69
- output_format: yaml.dig('output', 'format') || 'csv',
70
- write_back: yaml.dig('output', 'write_back') || false,
71
- write_back_to_code: yaml.dig('output', 'write_back_to_code') || false,
72
- context_mode: yaml.dig('output', 'context_mode'),
73
- swift_functions: yaml.dig('swift', 'functions')
114
+ schema_version: schema_version,
115
+ translations: translation_settings[:paths],
116
+ translation_locales: translation_settings[:locales],
117
+ source_paths: Schema.value(yaml, :source_paths),
118
+ ignore_patterns: Schema.value(yaml, :ignore_patterns),
119
+ context_files: Schema.value(yaml, :context_files),
120
+ provider: Schema.value(yaml, :provider),
121
+ model: Schema.value(yaml, :model),
122
+ endpoint: Schema.value(yaml, :endpoint),
123
+ concurrency: Schema.value(yaml, :concurrency),
124
+ context_lines: Schema.value(yaml, :context_lines),
125
+ max_matches_per_key: Schema.value(yaml, :max_matches_per_key),
126
+ max_prompt_chars: Schema.value(yaml, :max_prompt_chars),
127
+ discovery_mode: Schema.value(yaml, :discovery_mode),
128
+ platform: Schema.value(yaml, :platform),
129
+ output_path: Schema.value(yaml, :output_path),
130
+ output_stdout: Schema.value(yaml, :output_stdout),
131
+ write_back: Schema.value(yaml, :write_back),
132
+ write_back_to_code: Schema.value(yaml, :write_back_to_code),
133
+ swift_functions: Schema.value(yaml, :swift_functions),
134
+ no_cache: !Schema.value(yaml, :cache_enabled),
135
+ cache_dir: Schema.value(yaml, :cache_dir),
136
+ workflow_stage: Schema.value(yaml, :workflow_stage)
74
137
  }
138
+ attrs[:output_format] = Schema.value(yaml, :output_format) if Schema.configured?(yaml, :output_format)
139
+ attrs[:context_mode] = Schema.value(yaml, :context_mode) if Schema.configured?(yaml, :context_mode)
75
140
 
76
141
  # Only pass context_prefix when explicitly set in YAML, so initialize default applies
77
- prefix = yaml.dig('output', 'context_prefix')
78
- attrs[:context_prefix] = prefix unless prefix.nil?
79
- attrs[:include_file_paths] = yaml.dig('privacy', 'include_file_paths') unless yaml.dig('privacy', 'include_file_paths').nil?
80
- attrs[:include_translation_comments] = yaml.dig('privacy', 'include_translation_comments') unless yaml.dig('privacy', 'include_translation_comments').nil?
81
- attrs[:redact_prompts] = yaml.dig('privacy', 'redact_prompts') unless yaml.dig('privacy', 'redact_prompts').nil?
142
+ attrs[:context_prefix] = Schema.value(yaml, :context_prefix) if Schema.configured?(yaml, :context_prefix)
143
+ attrs[:include_file_paths] = Schema.value(yaml, :include_file_paths) if Schema.configured?(yaml, :include_file_paths)
144
+ attrs[:include_translation_comments] = Schema.value(yaml, :include_translation_comments) if Schema.configured?(yaml, :include_translation_comments)
145
+ attrs[:redact_prompts] = Schema.value(yaml, :redact_prompts) if Schema.configured?(yaml, :redact_prompts)
82
146
 
83
147
  new(**attrs)
148
+ rescue Psych::SyntaxError => e
149
+ raise Error, "Invalid config YAML #{path}: #{e.problem} at line #{e.line}, column #{e.column}"
150
+ rescue Psych::Exception => e
151
+ raise Error, "Invalid config YAML #{path}: #{e.message}"
152
+ rescue SystemCallError => e
153
+ raise Error, "Unable to read config #{path}: #{e.message}"
84
154
  end
85
155
 
86
156
  def self.from_cli(options)
87
- translations = if options[:translations]
88
- options[:translations].split(',').map(&:strip)
89
- else
90
- []
91
- end
92
-
93
- source_paths = if options[:source]
94
- options[:source].split(',').map(&:strip)
95
- else
96
- ['.']
97
- end
157
+ translations = cli_path_list(options[:translation], legacy: options[:translations])
158
+ source_paths = cli_path_list(options[:source])
159
+ source_paths = Schema.default(:source_paths) if source_paths.empty?
160
+ context_files = cli_path_list(options[:context_file])
161
+ key_filters = cli_key_filters(options[:key], legacy: options[:keys])
98
162
 
99
163
  attrs = {
164
+ schema_version: Schema::VERSION,
100
165
  translations: translations,
101
166
  source_paths: source_paths,
102
- ignore_patterns: default_ignore_patterns,
103
- provider: options[:provider] || 'anthropic',
167
+ context_files: context_files,
168
+ ignore_patterns: [],
169
+ provider: options[:provider] || Schema.default(:provider),
104
170
  model: options[:model],
105
- concurrency: options[:concurrency] || 5,
106
- context_lines: 15,
107
- max_matches_per_key: 3,
171
+ endpoint: options[:endpoint],
172
+ concurrency: options[:concurrency] || Schema.default(:concurrency),
173
+ context_lines: Schema.default(:context_lines),
174
+ max_matches_per_key: Schema.default(:max_matches_per_key),
175
+ discovery_mode: options[:discovery_mode] || Schema.default(:discovery_mode),
176
+ platform: options[:platform],
108
177
  output_path: options[:output],
109
- output_format: options[:format] || 'csv',
110
- no_cache: options[:cache].nil? || !options[:cache],
111
- dry_run: options[:dry_run] || false,
112
- key_filter: options[:keys],
113
- write_back: options[:write_back] || false,
114
- write_back_to_code: options[:write_back_to_code] || false,
178
+ output_stdout: options[:stdout],
179
+ dry_run: options[:dry_run] || Schema.default(:dry_run),
180
+ print_config: options[:print_config],
181
+ workflow_stage: options[:workflow_stage] || Schema.default(:workflow_stage),
182
+ key_filter: key_filters.empty? ? nil : key_filters,
183
+ write_back: options[:write_back] || Schema.default(:write_back),
184
+ write_back_to_code: options[:write_back_to_code] || Schema.default(:write_back_to_code),
115
185
  diff_base: options[:diff_base],
186
+ diff_head: options[:diff_head],
116
187
  start_key: options[:start_key],
117
188
  end_key: options[:end_key]
118
189
  }
190
+ attrs.merge!(cache_cli_attributes(options))
191
+ attrs[:output_format] = options[:format] if options[:format]
119
192
 
120
193
  # Only include if explicitly provided, so Config.new can apply its defaults
121
194
  attrs[:context_prefix] = options[:context_prefix] unless options[:context_prefix].nil?
@@ -132,53 +205,125 @@ module I18nContextGenerator
132
205
  # Thor options without defaults are nil when not passed, so this
133
206
  # correctly preserves config-file values for unspecified flags.
134
207
  def merge_cli(options)
135
- @translations = options[:translations].split(',').map(&:strip) if options[:translations]
136
- @source_paths = options[:source].split(',').map(&:strip) if options[:source]
208
+ translations = self.class.cli_path_list(options[:translation], legacy: options[:translations])
209
+ if translations.any?
210
+ @translations = deduplicate_paths(translations)
211
+ @translation_locales = {}
212
+ end
213
+ source_paths = self.class.cli_path_list(options[:source])
214
+ @source_paths = deduplicate_source_paths(source_paths) if source_paths.any?
215
+ context_files = self.class.cli_path_list(options[:context_file])
216
+ @context_files = deduplicate_paths(context_files) if context_files.any?
217
+ key_filters = self.class.cli_key_filters(options[:key], legacy: options[:keys])
218
+ @key_filter = key_filters if key_filters.any?
219
+ merge_cli_provider_and_model(options)
220
+ merge_cli_output(options)
137
221
  merge_cli_scalar_options(options)
138
222
  merge_cli_boolean_options(options)
139
223
  self
140
224
  end
141
225
 
142
226
  def self.parse_translations(translations)
143
- return [] unless translations
227
+ parse_translation_settings(translations)[:paths]
228
+ end
229
+
230
+ def self.parse_translation_settings(translations, path: nil)
231
+ return { paths: [], locales: {} } if translations.nil?
144
232
 
145
- translations.map do |t|
146
- t.is_a?(Hash) ? t['path'] : t
233
+ unless translations.is_a?(Array)
234
+ location = path ? " in #{path}" : ''
235
+ raise Error, "Invalid translations#{location}: expected an array"
147
236
  end
237
+
238
+ paths = []
239
+ locales = {}
240
+
241
+ translations.each do |translation|
242
+ case translation
243
+ when String
244
+ paths << translation
245
+ when Hash
246
+ unknown_keys = translation.keys - %w[path locale]
247
+ raise Error, "Invalid translation entry: unknown keys: #{unknown_keys.join(', ')}" if unknown_keys.any?
248
+
249
+ translation_path = translation['path']
250
+ raise Error, 'Invalid translation entry: path must be a non-empty string' unless valid_nonempty_string?(translation_path)
251
+
252
+ locale = translation['locale']
253
+ raise Error, "Invalid translation locale for #{translation_path}: expected a non-empty string" unless locale.nil? || valid_nonempty_string?(locale)
254
+ raise Error, "Conflicting translation locales for #{translation_path}" if conflicting_locale?(locales, translation_path, locale)
255
+
256
+ paths << translation_path
257
+ locales[translation_path] = locale if locale
258
+ else
259
+ raise Error, 'Invalid translation entry: expected a path string or mapping'
260
+ end
261
+ end
262
+
263
+ { paths: paths.uniq, locales: locales }
264
+ end
265
+
266
+ def self.valid_nonempty_string?(value)
267
+ value.is_a?(String) && !value.strip.empty?
268
+ end
269
+
270
+ def self.conflicting_locale?(locales, path, locale)
271
+ locale && locales.key?(path) && locales[path] != locale
272
+ end
273
+
274
+ def self.config_section(yaml, name, path)
275
+ value = yaml[name]
276
+ return {} if value.nil?
277
+
278
+ raise Error, "Invalid config #{path}: #{name} must be a mapping" unless value.is_a?(Hash)
279
+
280
+ value
148
281
  end
149
282
 
150
- def self.default_ignore_patterns
151
- [
152
- '**/node_modules/**',
153
- '**/vendor/**',
154
- '**/.git/**',
155
- '**/build/**',
156
- '**/dist/**',
157
- '**/*.min.js',
158
- '**/*.test.*',
159
- '**/*.spec.*',
160
- '**/Pods/**',
161
- '**/Carthage/**',
162
- '**/.build/**',
163
- '**/DerivedData/**',
164
- '**/*Tests.swift',
165
- '**/*Tests.kt',
166
- '**/*Test.java',
167
- '**/*Test.kt'
168
- ]
283
+ def self.cache_cli_attributes(options)
284
+ {
285
+ no_cache: options[:cache].nil? ? !Schema.default(:cache_enabled) : !options[:cache],
286
+ cache_dir: options[:cache_dir] || Schema.default(:cache_dir),
287
+ max_prompt_chars: options[:max_prompt_chars] || Schema.default(:max_prompt_chars)
288
+ }
169
289
  end
170
290
 
291
+ private_class_method :valid_nonempty_string?, :conflicting_locale?, :config_section,
292
+ :cache_cli_attributes
293
+
171
294
  private
172
295
 
296
+ def fetch_config_value(attrs, key, default)
297
+ attrs.key?(key) ? attrs[key] : default
298
+ end
299
+
300
+ def fetch_defaulting_value(attrs, key, default)
301
+ attrs.key?(key) ? (attrs[key] || default) : default
302
+ end
303
+
304
+ def fetch_boolean_value(attrs, key, default)
305
+ return default unless attrs.key?(key)
306
+
307
+ attrs[key].nil? ? default : attrs[key]
308
+ end
309
+
310
+ def merge_swift_functions(functions)
311
+ return functions unless functions.is_a?(Array) && functions.all?(String)
312
+
313
+ LocalizationSyntax.functions_with_defaults(functions)
314
+ end
315
+
173
316
  def merge_cli_scalar_options(options)
174
317
  scalar_mappings = {
175
- key_filter: :keys,
176
- output_path: :output,
177
- output_format: :format,
178
- provider: :provider,
179
- model: :model,
180
318
  concurrency: :concurrency,
319
+ max_prompt_chars: :max_prompt_chars,
320
+ cache_dir: :cache_dir,
321
+ endpoint: :endpoint,
322
+ workflow_stage: :workflow_stage,
323
+ discovery_mode: :discovery_mode,
324
+ platform: :platform,
181
325
  diff_base: :diff_base,
326
+ diff_head: :diff_head,
182
327
  context_prefix: :context_prefix,
183
328
  context_mode: :context_mode,
184
329
  start_key: :start_key,
@@ -187,13 +332,44 @@ module I18nContextGenerator
187
332
 
188
333
  scalar_mappings.each do |attr_name, option_name|
189
334
  value = options[option_name]
335
+ value = normalize_enum_value(value) if %i[discovery_mode platform context_mode workflow_stage].include?(attr_name)
190
336
  instance_variable_set(:"@#{attr_name}", value) unless value.nil?
191
337
  end
192
338
  end
193
339
 
340
+ def merge_cli_provider_and_model(options)
341
+ provider = normalize_enum_value(options[:provider])
342
+ if provider && provider != @provider
343
+ @provider = provider
344
+ @model = nil unless options[:model]
345
+ @endpoint = nil unless options[:endpoint]
346
+ end
347
+ @model = options[:model] if options[:model]
348
+ end
349
+
350
+ def merge_cli_output(options)
351
+ if options[:output]
352
+ output_is_stdout = options[:output] == '-'
353
+ @output_destination_conflict = options[:stdout] == true && !output_is_stdout
354
+ @output_path = options[:output]
355
+ @output_stdout = output_is_stdout
356
+ elsif !options[:stdout].nil?
357
+ @output_stdout = options[:stdout]
358
+ @output_path = options[:stdout] ? '-' : nil
359
+ @output_destination_conflict = false
360
+ end
361
+ if options[:format]
362
+ @output_format = normalize_enum_value(options[:format])
363
+ @output_format_explicit = true
364
+ elsif options[:output] && !@output_format_explicit
365
+ @output_format = resolve_output_format(nil, @output_path)
366
+ end
367
+ end
368
+
194
369
  def merge_cli_boolean_options(options)
195
370
  boolean_mappings = {
196
371
  dry_run: :dry_run,
372
+ print_config: :print_config,
197
373
  write_back: :write_back,
198
374
  write_back_to_code: :write_back_to_code,
199
375
  include_file_paths: :include_file_paths,
@@ -207,5 +383,39 @@ module I18nContextGenerator
207
383
  instance_variable_set(:"@#{attr_name}", value) unless value.nil?
208
384
  end
209
385
  end
386
+
387
+ def resolve_output_format(configured_format, output_path)
388
+ return configured_format unless configured_format.nil?
389
+
390
+ VALID_OUTPUT_EXTENSIONS.fetch(File.extname(output_path.to_s).downcase, 'csv')
391
+ end
392
+
393
+ def normalize_enum_value(value)
394
+ value&.to_s
395
+ end
396
+
397
+ def deduplicate_paths(paths)
398
+ paths.is_a?(Array) ? paths.uniq : paths
399
+ end
400
+
401
+ def deduplicate_source_paths(paths)
402
+ return paths unless paths.is_a?(Array) && paths.all?(String)
403
+
404
+ seen_expanded_paths = Set.new
405
+ unique_paths = paths.select { |path| seen_expanded_paths.add?(File.expand_path(path)) }
406
+ unique_paths.reject do |path|
407
+ expanded = File.expand_path(path)
408
+ unique_paths.any? do |other|
409
+ next false if other == path
410
+
411
+ expanded.start_with?(directory_prefix(other))
412
+ end
413
+ end
414
+ end
415
+
416
+ def directory_prefix(path)
417
+ expanded = File.expand_path(path)
418
+ expanded.end_with?(File::SEPARATOR) ? expanded : "#{expanded}#{File::SEPARATOR}"
419
+ end
210
420
  end
211
421
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module I18nContextGenerator
6
+ class ContextExtractor
7
+ # Builds a stable cache identity from every input that can shape the prompt.
8
+ module CacheIdentity
9
+ private
10
+
11
+ def cache_context(entry, matches, comment)
12
+ JSON.generate(
13
+ matches: sorted_cache_matches(matches),
14
+ comment: comment,
15
+ supplemental_context: cache_context_sources,
16
+ provider: @config.provider,
17
+ resolved_model: resolved_model,
18
+ endpoint: @config.endpoint,
19
+ prompt: cache_prompt_settings,
20
+ source_discovery: cache_source_discovery(entry)
21
+ )
22
+ end
23
+
24
+ def cache_context_sources
25
+ @cache_context_sources ||= supplemental_context.map do |source|
26
+ {
27
+ kind: source.kind,
28
+ name: source.name,
29
+ sha256: Digest::SHA256.hexdigest(source.content)
30
+ }.freeze
31
+ end.freeze
32
+ end
33
+
34
+ def sorted_cache_matches(matches)
35
+ cache_matches = matches.map do |match|
36
+ {
37
+ file: match.file,
38
+ line: match.line,
39
+ match_line: match.match_line,
40
+ enclosing_scope: match.enclosing_scope,
41
+ context: match.context
42
+ }
43
+ end
44
+ cache_matches.sort_by { |match| [match[:file].to_s, match[:line].to_i, match[:match_line].to_s] }
45
+ end
46
+
47
+ def cache_prompt_settings
48
+ {
49
+ include_file_paths: @config.include_file_paths,
50
+ redact_prompts: @config.redact_prompts,
51
+ max_prompt_chars: @config.max_prompt_chars
52
+ }
53
+ end
54
+
55
+ def cache_source_discovery(entry)
56
+ {
57
+ source_file: entry.source_file,
58
+ source_location: entry.metadata&.dig(:source_location),
59
+ source_locations: entry.metadata&.dig(:source_locations),
60
+ source_location_groups: entry.metadata&.dig(:source_location_groups)
61
+ }
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class ContextExtractor
5
+ # Result for a single translation key, including evidence, review state, and
6
+ # run-local provider/cache telemetry.
7
+ ExtractionResult = Data.define(
8
+ :key, :text, :description, :source_file, :ui_element, :tone, :max_length,
9
+ :locations, :changed_locations, :translation_key, :changed_location_groups,
10
+ :changed_translation_locations, :confidence, :ambiguity_reason, :cache_hit,
11
+ :request_count, :input_tokens, :output_tokens, :retries, :status, :error
12
+ ) do
13
+ def initialize(key:, text:, description:, **attributes)
14
+ defaults = {
15
+ source_file: nil,
16
+ ui_element: nil,
17
+ tone: nil,
18
+ max_length: nil,
19
+ locations: [],
20
+ changed_locations: [],
21
+ changed_location_groups: [],
22
+ translation_key: key,
23
+ changed_translation_locations: [],
24
+ confidence: nil,
25
+ ambiguity_reason: nil,
26
+ cache_hit: false,
27
+ request_count: 0,
28
+ input_tokens: 0,
29
+ output_tokens: 0,
30
+ retries: 0,
31
+ status: attributes[:error] ? :error : :success,
32
+ error: nil
33
+ }
34
+ values = defaults.merge(attributes)
35
+ values[:status] = values[:status].to_sym if values[:status].respond_to?(:to_sym)
36
+ super(key: key, text: text, description: description, **values)
37
+ end
38
+
39
+ def actionable? = status == :success && error.nil? && !description.to_s.strip.empty?
40
+
41
+ def to_h
42
+ members.to_h { |member| [member, public_send(member)] }
43
+ end
44
+ end
45
+ end
46
+ end