i18n-context-generator 0.4.0 → 0.5.1

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 +163 -76
  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 +293 -102
  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 +45 -6
  17. data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
  18. data/lib/i18n_context_generator/context_extractor/source_filters.rb +51 -5
  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 +207 -216
  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 +224 -95
  26. data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
  27. data/lib/i18n_context_generator/llm/client.rb +304 -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 +109 -64
  44. data/lib/i18n_context_generator/searcher.rb +61 -205
  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 +248 -0
  59. data/lib/i18n_context_generator/xml_scanner.rb +38 -0
  60. data/lib/i18n_context_generator.rb +20 -4
  61. metadata +59 -12
@@ -1,125 +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
13
+ include Validation
14
+ include Serialization
15
+ extend Defaults
16
+ extend CliValues
17
+
6
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, :discovery_mode
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 = fetch_defaulting_value(attrs, :translations, [])
19
- @source_paths = fetch_defaulting_value(attrs, :source_paths, ['.'])
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, {})
20
46
  @source_line_filter = fetch_config_value(attrs, :source_line_filter, nil)
21
- @ignore_patterns = fetch_defaulting_value(attrs, :ignore_patterns, [])
22
- @provider = fetch_defaulting_value(attrs, :provider, 'anthropic')
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)))
23
50
  @model = fetch_config_value(attrs, :model, nil)
24
- @concurrency = fetch_defaulting_value(attrs, :concurrency, 5)
25
- @context_lines = fetch_defaulting_value(attrs, :context_lines, 15)
26
- @max_matches_per_key = fetch_defaulting_value(attrs, :max_matches_per_key, 3)
27
- @output_path = fetch_config_value(attrs, :output_path, nil)
28
- @output_format = fetch_defaulting_value(attrs, :output_format, 'csv')
29
- @no_cache = fetch_boolean_value(attrs, :no_cache, true)
30
- @dry_run = fetch_boolean_value(attrs, :dry_run, false)
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
+ )
31
70
  @key_filter = fetch_config_value(attrs, :key_filter, nil)
32
- @write_back = fetch_boolean_value(attrs, :write_back, false)
33
- @write_back_to_code = fetch_boolean_value(attrs, :write_back_to_code, false)
34
- @swift_functions = fetch_defaulting_value(attrs, :swift_functions, default_swift_functions)
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
+ )
35
76
  @diff_base = fetch_config_value(attrs, :diff_base, nil)
36
- @context_prefix = fetch_defaulting_value(attrs, :context_prefix, DEFAULT_CONTEXT_PREFIX)
37
- @context_mode = fetch_defaulting_value(attrs, :context_mode, DEFAULT_CONTEXT_MODE)
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)))
38
80
  @start_key = fetch_config_value(attrs, :start_key, nil)
39
81
  @end_key = fetch_config_value(attrs, :end_key, nil)
40
- @include_file_paths = fetch_boolean_value(attrs, :include_file_paths, false)
41
- @include_translation_comments = fetch_boolean_value(attrs, :include_translation_comments, true)
42
- @redact_prompts = fetch_boolean_value(attrs, :redact_prompts, true)
43
- @discovery_mode = fetch_defaulting_value(attrs, :discovery_mode, 'auto')
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))
44
87
  end
45
88
 
46
89
  def default_swift_functions
47
- %w[NSLocalizedString String(localized: Text(]
90
+ Schema.default(:swift_functions)
48
91
  end
49
92
 
50
93
  def self.load(options)
51
- if options[:config] && File.exist?(options[:config])
52
- from_file(options[:config]).merge_cli(options)
53
- else
54
- from_cli(options)
55
- 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)
56
99
  end
57
100
 
58
101
  def self.from_file(path)
59
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)
60
112
 
61
113
  attrs = {
62
- translations: parse_translations(yaml['translations']),
63
- source_paths: yaml.dig('source', 'paths') || ['.'],
64
- ignore_patterns: yaml.dig('source', 'ignore') || default_ignore_patterns,
65
- provider: yaml.dig('llm', 'provider') || 'anthropic',
66
- model: yaml.dig('llm', 'model'),
67
- concurrency: yaml.dig('processing', 'concurrency') || 5,
68
- context_lines: yaml.dig('processing', 'context_lines') || 15,
69
- max_matches_per_key: yaml.dig('processing', 'max_matches_per_key') || 3,
70
- discovery_mode: yaml.dig('processing', 'discovery_mode') || 'auto',
71
- output_path: yaml.dig('output', 'path'),
72
- output_format: yaml.dig('output', 'format') || 'csv',
73
- write_back: yaml.dig('output', 'write_back') || false,
74
- write_back_to_code: yaml.dig('output', 'write_back_to_code') || false,
75
- context_mode: yaml.dig('output', 'context_mode'),
76
- 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)
77
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)
78
140
 
79
141
  # Only pass context_prefix when explicitly set in YAML, so initialize default applies
80
- prefix = yaml.dig('output', 'context_prefix')
81
- attrs[:context_prefix] = prefix unless prefix.nil?
82
- attrs[:include_file_paths] = yaml.dig('privacy', 'include_file_paths') unless yaml.dig('privacy', 'include_file_paths').nil?
83
- attrs[:include_translation_comments] = yaml.dig('privacy', 'include_translation_comments') unless yaml.dig('privacy', 'include_translation_comments').nil?
84
- 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)
85
146
 
86
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}"
87
154
  end
88
155
 
89
156
  def self.from_cli(options)
90
- translations = if options[:translations]
91
- options[:translations].split(',').map(&:strip)
92
- else
93
- []
94
- end
95
-
96
- source_paths = if options[:source]
97
- options[:source].split(',').map(&:strip)
98
- else
99
- ['.']
100
- 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])
101
162
 
102
163
  attrs = {
164
+ schema_version: Schema::VERSION,
103
165
  translations: translations,
104
166
  source_paths: source_paths,
105
- ignore_patterns: default_ignore_patterns,
106
- provider: options[:provider] || 'anthropic',
167
+ context_files: context_files,
168
+ ignore_patterns: [],
169
+ provider: options[:provider] || Schema.default(:provider),
107
170
  model: options[:model],
108
- concurrency: options[:concurrency] || 5,
109
- context_lines: 15,
110
- max_matches_per_key: 3,
111
- discovery_mode: options[:discovery_mode] || 'auto',
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],
112
177
  output_path: options[:output],
113
- output_format: options[:format] || 'csv',
114
- no_cache: options[:cache].nil? || !options[:cache],
115
- dry_run: options[:dry_run] || false,
116
- key_filter: options[:keys],
117
- write_back: options[:write_back] || false,
118
- 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),
119
185
  diff_base: options[:diff_base],
186
+ diff_head: options[:diff_head],
120
187
  start_key: options[:start_key],
121
188
  end_key: options[:end_key]
122
189
  }
190
+ attrs.merge!(cache_cli_attributes(options))
191
+ attrs[:output_format] = options[:format] if options[:format]
123
192
 
124
193
  # Only include if explicitly provided, so Config.new can apply its defaults
125
194
  attrs[:context_prefix] = options[:context_prefix] unless options[:context_prefix].nil?
@@ -136,42 +205,92 @@ module I18nContextGenerator
136
205
  # Thor options without defaults are nil when not passed, so this
137
206
  # correctly preserves config-file values for unspecified flags.
138
207
  def merge_cli(options)
139
- @translations = options[:translations].split(',').map(&:strip) if options[:translations]
140
- @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)
141
221
  merge_cli_scalar_options(options)
142
222
  merge_cli_boolean_options(options)
143
223
  self
144
224
  end
145
225
 
146
226
  def self.parse_translations(translations)
147
- 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?
232
+
233
+ unless translations.is_a?(Array)
234
+ location = path ? " in #{path}" : ''
235
+ raise Error, "Invalid translations#{location}: expected an array"
236
+ end
237
+
238
+ paths = []
239
+ locales = {}
148
240
 
149
- translations.map do |t|
150
- t.is_a?(Hash) ? t['path'] : t
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
151
261
  end
262
+
263
+ { paths: paths.uniq, locales: locales }
152
264
  end
153
265
 
154
- def self.default_ignore_patterns
155
- [
156
- '**/node_modules/**',
157
- '**/vendor/**',
158
- '**/.git/**',
159
- '**/build/**',
160
- '**/dist/**',
161
- '**/*.min.js',
162
- '**/*.test.*',
163
- '**/*.spec.*',
164
- '**/Pods/**',
165
- '**/Carthage/**',
166
- '**/.build/**',
167
- '**/DerivedData/**',
168
- '**/*Tests.swift',
169
- '**/*Tests.kt',
170
- '**/*Test.java',
171
- '**/*Test.kt'
172
- ]
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
281
+ end
282
+
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
+ }
173
289
  end
174
290
 
291
+ private_class_method :valid_nonempty_string?, :conflicting_locale?, :config_section,
292
+ :cache_cli_attributes
293
+
175
294
  private
176
295
 
177
296
  def fetch_config_value(attrs, key, default)
@@ -188,16 +307,23 @@ module I18nContextGenerator
188
307
  attrs[key].nil? ? default : attrs[key]
189
308
  end
190
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
+
191
316
  def merge_cli_scalar_options(options)
192
317
  scalar_mappings = {
193
- key_filter: :keys,
194
- output_path: :output,
195
- output_format: :format,
196
- provider: :provider,
197
- model: :model,
198
318
  concurrency: :concurrency,
319
+ max_prompt_chars: :max_prompt_chars,
320
+ cache_dir: :cache_dir,
321
+ endpoint: :endpoint,
322
+ workflow_stage: :workflow_stage,
199
323
  discovery_mode: :discovery_mode,
324
+ platform: :platform,
200
325
  diff_base: :diff_base,
326
+ diff_head: :diff_head,
201
327
  context_prefix: :context_prefix,
202
328
  context_mode: :context_mode,
203
329
  start_key: :start_key,
@@ -206,13 +332,44 @@ module I18nContextGenerator
206
332
 
207
333
  scalar_mappings.each do |attr_name, option_name|
208
334
  value = options[option_name]
335
+ value = normalize_enum_value(value) if %i[discovery_mode platform context_mode workflow_stage].include?(attr_name)
209
336
  instance_variable_set(:"@#{attr_name}", value) unless value.nil?
210
337
  end
211
338
  end
212
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
+
213
369
  def merge_cli_boolean_options(options)
214
370
  boolean_mappings = {
215
371
  dry_run: :dry_run,
372
+ print_config: :print_config,
216
373
  write_back: :write_back,
217
374
  write_back_to_code: :write_back_to_code,
218
375
  include_file_paths: :include_file_paths,
@@ -226,5 +383,39 @@ module I18nContextGenerator
226
383
  instance_variable_set(:"@#{attr_name}", value) unless value.nil?
227
384
  end
228
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
229
420
  end
230
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