i18n-context-generator 0.4.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 +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 +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 +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 +218 -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
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ # A line in a Git diff, including the revision side needed by review clients.
5
+ #
6
+ # `fallback_line` identifies a corresponding line in the head revision. It is
7
+ # useful when a removed translator comment needs a right-side suggestion anchor.
8
+ ChangedLocation = Data.define(:file, :line, :side, :fallback_line) do
9
+ def initialize(file:, line:, side: :right, fallback_line: nil)
10
+ normalized_side = side.to_s.downcase.to_sym
11
+ raise ArgumentError, "Unsupported diff side: #{side}" unless %i[left right].include?(normalized_side)
12
+
13
+ super(
14
+ file: file.to_s,
15
+ line: Integer(line),
16
+ side: normalized_side,
17
+ fallback_line: fallback_line && Integer(fallback_line)
18
+ )
19
+ end
20
+
21
+ def self.parse(value)
22
+ return value if value.is_a?(self)
23
+
24
+ match = /\A(.+):(\d+)\z/.match(value.to_s)
25
+ raise ArgumentError, "Invalid changed location: #{value.inspect}" unless match
26
+
27
+ new(file: match[1], line: match[2], side: :right)
28
+ end
29
+
30
+ def left?
31
+ side == :left
32
+ end
33
+
34
+ def right?
35
+ side == :right
36
+ end
37
+
38
+ def review_side
39
+ side.to_s.upcase
40
+ end
41
+
42
+ def to_s
43
+ "#{file}:#{line}"
44
+ end
45
+
46
+ def to_h
47
+ {
48
+ file: file,
49
+ line: line,
50
+ side: side,
51
+ fallback_line: fallback_line
52
+ }
53
+ end
54
+ end
55
+ end
@@ -1,10 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'thor'
4
+ require_relative 'config'
4
5
 
5
6
  module I18nContextGenerator
7
+ # Configuration inspection commands kept separate from extraction options.
8
+ class ConfigCommand < Thor
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ desc 'validate [PATH]', 'Validate a client configuration file'
14
+ def validate(path = '.i18n-context-generator.yml')
15
+ Config.from_file(path).validate!
16
+ say "Configuration is valid (schema version #{Config::Schema::VERSION}): #{path}"
17
+ rescue I18nContextGenerator::Error => e
18
+ warn "Error: #{e.message}"
19
+ exit 1
20
+ end
21
+ end
22
+
6
23
  # Thor-based CLI entry point for the i18n-context-generator command.
7
24
  class CLI < Thor
25
+ register ConfigCommand, 'config', 'config SUBCOMMAND', 'Inspect or validate configuration'
26
+
27
+ def self.extraction_options
28
+ Config::Schema.cli_definitions.each do |definition|
29
+ option definition.cli_name, **definition.thor_options
30
+ end
31
+ option :translations, type: :string, hide: true,
32
+ desc: 'Deprecated: comma-separated translation files'
33
+ option :keys, type: :string, hide: true,
34
+ desc: 'Deprecated: comma-separated key filters'
35
+ end
36
+
8
37
  def self.exit_on_failure?
9
38
  true
10
39
  end
@@ -28,51 +57,35 @@ module I18nContextGenerator
28
57
  # Use config file
29
58
  i18n-context-generator extract --config .i18n-context-generator.yml
30
59
  DESC
31
- option :config, aliases: '-c', desc: 'Path to config file (.i18n-context-generator.yml)'
32
- option :translations, aliases: '-t', desc: 'Translation file(s), comma-separated'
33
- option :source, aliases: '-s', desc: 'Source directory(ies) to search, comma-separated'
34
- option :output, aliases: '-o', desc: 'Output file path (CSV written only if specified)'
35
- option :format, aliases: '-f', enum: %w[csv json], desc: 'Output format (default: csv)'
36
- option :provider, aliases: '-p', enum: %w[anthropic openai], desc: 'LLM provider (default: anthropic)'
37
- option :model, aliases: '-m', desc: 'LLM model to use'
38
- option :keys, aliases: '-k', desc: 'Filter keys (comma-separated patterns, supports * wildcard)'
39
- option :discovery_mode, type: :string, enum: %w[auto translations source],
40
- desc: 'How to discover entries: auto, translations, or source (default: auto)'
41
- option :concurrency, type: :numeric, desc: 'Number of concurrent requests (default: 5)'
42
- option :dry_run, type: :boolean, desc: 'Show what would be processed without calling LLM'
43
- option :cache, type: :boolean, desc: 'Enable caching of LLM results'
44
- option :write_back, type: :boolean,
45
- desc: 'Write context back to source translation files (.strings, strings.xml)'
46
- option :write_back_to_code, type: :boolean,
47
- desc: 'Write context back to Swift source code comment: parameters'
48
- option :diff_base, type: :string, desc: 'Only process keys changed since this git ref (e.g., main, origin/main)'
49
- option :context_prefix, type: :string,
50
- desc: 'Prefix for context comments (default: "Context: ", use empty string for none)'
51
- option :context_mode, type: :string, enum: %w[replace append],
52
- desc: 'How to handle existing comments: replace or append (default: replace)'
53
- option :start_key, type: :string, desc: 'Start processing from this key (inclusive)'
54
- option :end_key, type: :string, desc: 'Stop processing at this key (inclusive)'
55
- option :include_file_paths, type: :boolean,
56
- desc: 'Include full source file paths in LLM prompts (default: false)'
57
- option :include_translation_comments, type: :boolean,
58
- desc: 'Include translation file comments in LLM prompts (default: true)'
59
- option :redact_prompts, type: :boolean,
60
- desc: 'Redact likely secrets and PII from LLM prompts (default: true)'
60
+ extraction_options
61
61
 
62
62
  def extract
63
- validate_options!
64
- config = Config.load(options)
65
- validate_api_key!(provider: config.provider, dry_run: config.dry_run)
66
- validate_diff_base!(base_ref: config.diff_base) if config.diff_base
67
- extractor = ContextExtractor.new(config)
68
- extractor.run
69
- fail_if_extraction_errors!(extractor)
70
- rescue I18nContextGenerator::Error => e
71
- say_error "Error: #{e.message}"
72
- exit 1
73
- rescue Interrupt
74
- say "\nInterrupted"
75
- exit 130
63
+ run_workflow(nil)
64
+ end
65
+
66
+ desc 'check', 'Validate and check source usage without calling the LLM'
67
+ extraction_options
68
+ def check
69
+ run_workflow('check')
70
+ end
71
+
72
+ desc 'plan', 'Show selected keys and destinations without calling the LLM'
73
+ extraction_options
74
+ def plan
75
+ run_workflow('plan')
76
+ end
77
+
78
+ map 'preview-diff' => :preview_diff
79
+ desc 'preview-diff', 'Generate and preview write-back patches without modifying files'
80
+ extraction_options
81
+ def preview_diff
82
+ run_workflow('preview_diff')
83
+ end
84
+
85
+ desc 'apply', 'Generate context and apply the configured destinations'
86
+ extraction_options
87
+ def apply
88
+ run_workflow('apply')
76
89
  end
77
90
 
78
91
  desc 'init', 'Create a sample config file'
@@ -99,20 +112,52 @@ module I18nContextGenerator
99
112
 
100
113
  private
101
114
 
115
+ def run_workflow(stage)
116
+ validate_options!
117
+ warn_deprecated_list_options!
118
+ config = Config.load(options)
119
+ config.merge_cli(workflow_stage: stage) if stage
120
+ config.validate!
121
+ if config.print_config
122
+ puts YAML.dump(config.to_h)
123
+ return
124
+ end
125
+ validate_destination!(config)
126
+ validate_api_key!(provider: config.provider, dry_run: config.dry_run) unless %w[check plan].include?(config.workflow_stage)
127
+ validate_diff_range!(base_ref: config.diff_base, head_ref: config.diff_head) if config.diff_base
128
+ extractor = ContextExtractor.new(config)
129
+ extractor.run
130
+ fail_if_extraction_errors!(extractor)
131
+ rescue I18nContextGenerator::Error => e
132
+ say_error "Error: #{e.message}"
133
+ exit 1
134
+ rescue Interrupt
135
+ say "\nInterrupted"
136
+ exit 130
137
+ end
138
+
102
139
  def validate_options!
103
- return if options[:config] && File.exist?(options[:config])
140
+ return if options[:print_config]
141
+ return if options[:config]
104
142
 
105
- return if options[:translations]
143
+ return if options[:translation] || options[:translations]
106
144
  return if options[:discovery_mode] == 'source' && options[:source]
107
145
 
108
- say_error 'Error: --translations (-t) is required unless using a config file or --discovery-mode source with --source'
146
+ say_error 'Error: --translation (-t) is required unless using a config file or --discovery-mode source with --source'
109
147
  exit 1
110
148
  end
111
149
 
150
+ def warn_deprecated_list_options!
151
+ say_error 'Warning: --translations is deprecated; repeat --translation instead.' if options[:translations]
152
+ say_error 'Warning: --keys is deprecated; repeat --key instead.' if options[:keys]
153
+ end
154
+
112
155
  def validate_api_key!(provider: nil, dry_run: nil)
113
156
  return if dry_run.nil? ? options[:dry_run] : dry_run
114
157
 
115
158
  provider ||= options[:provider] || 'anthropic'
159
+ return if provider == 'openai_compatible'
160
+
116
161
  env_var = case provider
117
162
  when 'anthropic' then 'ANTHROPIC_API_KEY'
118
163
  when 'openai' then 'OPENAI_API_KEY'
@@ -126,17 +171,30 @@ module I18nContextGenerator
126
171
  exit 1
127
172
  end
128
173
 
129
- def validate_diff_base!(base_ref: options[:diff_base])
174
+ def validate_destination!(config)
175
+ return if %w[check plan].include?(config.workflow_stage)
176
+ return if config.dry_run
177
+ return if config.output_path || config.write_back || config.write_back_to_code
178
+
179
+ raise Error, 'A non-dry extraction requires --output, --write-back, or --write-back-to-code'
180
+ end
181
+
182
+ def validate_diff_range!(base_ref: options[:diff_base], head_ref: options[:diff_head] || 'HEAD')
130
183
  unless GitDiff.available?
131
184
  say_error 'Error: --diff-base requires a git repository'
132
185
  exit 1
133
186
  end
134
187
 
135
- git_diff = GitDiff.new(base_ref: base_ref)
136
- return if git_diff.base_ref_exists?
188
+ git_diff = GitDiff.new(base_ref: base_ref, head_ref: head_ref)
189
+ unless git_diff.base_ref_exists?
190
+ say_error "Error: git ref '#{base_ref}' not found"
191
+ say_error 'Try: origin/main, main, or a specific commit SHA'
192
+ exit 1
193
+ end
194
+ return if git_diff.head_ref_exists?
137
195
 
138
- say_error "Error: git ref '#{base_ref}' not found"
139
- say_error 'Try: origin/main, main, or a specific commit SHA'
196
+ say_error "Error: git ref '#{head_ref}' not found"
197
+ say_error 'Try: HEAD, a branch name, or a specific commit SHA'
140
198
  exit 1
141
199
  end
142
200
 
@@ -152,12 +210,16 @@ module I18nContextGenerator
152
210
  end
153
211
 
154
212
  def sample_config
213
+ schema = Config::Schema
214
+ swift_functions = schema.default(:swift_functions).map { |function| " - #{function.inspect}" }.join("\n")
215
+
155
216
  <<~YAML
156
217
  # i18n-context-generator configuration
157
218
  # Extract translation context from mobile app source code
219
+ schema_version: #{schema.default(:schema_version)}
158
220
 
159
221
  # Translation files to process
160
- # Supported formats: .strings (iOS), strings.xml (Android), .json, .yml
222
+ # Supported formats: .strings/.xcstrings (iOS), strings.xml (Android), .json, .yml
161
223
  translations:
162
224
  # iOS example
163
225
  - path: ios/MyApp/Resources/Localizable.strings
@@ -165,63 +227,88 @@ module I18nContextGenerator
165
227
  # Android example
166
228
  # - path: android/app/src/main/res/values/strings.xml
167
229
 
230
+ # YAML locale roots are stripped only when configured explicitly
231
+ # - path: config/translations.yml
232
+ # locale: en
233
+
168
234
  # Source code directories to search
169
235
  source:
170
236
  paths:
171
237
  - ios/MyApp/
172
238
  # - android/app/src/main/java/
239
+ # These entries extend the built-in dependency, build, and test ignores.
173
240
  ignore:
174
- - "**/Pods/**"
175
- - "**/build/**"
176
241
  - "**/*.generated.*"
177
- - "**/*Tests*"
242
+
243
+ # Optional free-form reference material included in every LLM request.
244
+ context:
245
+ files: []
246
+ # - GLOSSARY.md
247
+ # - docs/localization-style.md
178
248
 
179
249
  # LLM configuration
180
250
  llm:
181
- provider: anthropic
182
- model: claude-sonnet-4-6
251
+ provider: #{schema.default(:provider)}
252
+ # model: provider-specific default
253
+ # For provider: openai_compatible, set an explicit model and endpoint.
254
+ # endpoint: http://127.0.0.1:11434/v1/responses
183
255
  # API key is read from the matching provider env var
184
256
  # (ANTHROPIC_API_KEY or OPENAI_API_KEY)
185
257
 
186
258
  # Processing options
187
259
  processing:
260
+ # Optional explicit platform override: ios or android
261
+ # platform: ios
188
262
  # Discovery mode: auto, translations, or source
189
- discovery_mode: auto
190
- concurrency: 5
191
- context_lines: 15
192
- max_matches_per_key: 3
263
+ discovery_mode: #{schema.default(:discovery_mode)}
264
+ concurrency: #{schema.default(:concurrency)}
265
+ context_lines: #{schema.default(:context_lines)}
266
+ max_matches_per_key: #{schema.default(:max_matches_per_key)}
267
+ # Hard character limit for each prompt; oversized context is truncated
268
+ max_prompt_chars: #{schema.default(:max_prompt_chars)}
269
+
270
+ # Optional local cache. Only successful results are cached.
271
+ cache:
272
+ enabled: #{schema.default(:cache_enabled)}
273
+ directory: #{schema.default(:cache_dir)}
274
+
275
+ # Default workflow for programmatic/config-file runs.
276
+ workflow:
277
+ stage: #{schema.default(:workflow_stage)}
193
278
 
194
279
  # Output configuration
195
280
  output:
196
- format: csv
281
+ format: #{schema.default(:output_format)}
197
282
  path: translation-context.csv
198
- # Set to true to write context comments back to translation files (.strings, strings.xml)
199
- write_back: false
283
+ # Write the selected CSV/JSON format to stdout instead of a file.
284
+ stdout: #{schema.default(:output_stdout)}
285
+ # Set to true to write context comments back to translation files
286
+ # (.strings, .xcstrings, strings.xml)
287
+ write_back: #{schema.default(:write_back)}
200
288
  # Set to true to write context back to Swift source code comment: parameters
201
- write_back_to_code: false
289
+ write_back_to_code: #{schema.default(:write_back_to_code)}
202
290
  # Prefix for context comments (use empty string for no prefix)
203
- # context_prefix: "Context: "
291
+ # context_prefix: #{schema.default(:context_prefix).inspect}
204
292
  # How to handle existing comments: "replace" or "append"
205
- # context_mode: replace
293
+ # context_mode: #{schema.default(:context_mode)}
206
294
 
207
- # Swift-specific configuration for write_back_to_code
295
+ # Swift-specific extraction and write-back configuration
208
296
  swift:
209
- # Localization functions to update (default shown)
297
+ # Custom entries extend the built-in localization functions (defaults shown)
210
298
  functions:
211
- - NSLocalizedString
212
- - "String(localized:"
213
- - "Text("
299
+ #{swift_functions}
214
300
  # Add custom functions like:
215
301
  # - "MyLocalizedString("
216
302
 
217
303
  # Prompt privacy controls
218
304
  privacy:
219
305
  # Include full source paths in prompts sent to the LLM (default: false)
220
- include_file_paths: false
306
+ include_file_paths: #{schema.default(:include_file_paths)}
221
307
  # Include translation file comments in prompts (default: true)
222
- include_translation_comments: true
223
- # Redact likely secrets, URLs, and emails before sending prompts (default: true)
224
- redact_prompts: true
308
+ include_translation_comments: #{schema.default(:include_translation_comments)}
309
+ # Best-effort redact likely secrets, URLs, and emails before sending prompts.
310
+ # Source snippets still leave the machine when using a remote provider.
311
+ redact_prompts: #{schema.default(:redact_prompts)}
225
312
  YAML
226
313
  end
227
314
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class Config
5
+ # Normalizes repeatable singular CLI values while retaining explicit legacy
6
+ # comma-list support.
7
+ module CliValues
8
+ def cli_path_list(values, legacy: nil)
9
+ repeatable = Array(values).filter_map do |value|
10
+ normalized = value.to_s.strip
11
+ normalized unless normalized.empty?
12
+ end
13
+ (repeatable + split_legacy_list(legacy)).uniq
14
+ end
15
+
16
+ def cli_key_filters(values, legacy: nil)
17
+ repeatable = Array(values).filter_map do |value|
18
+ normalized = value.to_s.strip
19
+ normalized unless normalized.empty?
20
+ end
21
+ repeatable + split_legacy_list(legacy)
22
+ end
23
+
24
+ private
25
+
26
+ def split_legacy_list(value)
27
+ Array(value).flat_map { |item| item.to_s.split(',') }
28
+ .map(&:strip)
29
+ .reject(&:empty?)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class Config
5
+ # Built-in source exclusions and their additive merge policy.
6
+ module Defaults
7
+ def default_ignore_patterns
8
+ [
9
+ '**/node_modules/**',
10
+ '**/vendor/**',
11
+ '**/.git/**',
12
+ '**/build/**',
13
+ '**/dist/**',
14
+ '**/*.min.js',
15
+ '**/*.test.*',
16
+ '**/*.spec.*',
17
+ '**/Pods/**',
18
+ '**/Carthage/**',
19
+ '**/.build/**',
20
+ '**/DerivedData/**',
21
+ '**/*Tests.swift',
22
+ '**/*Tests.kt',
23
+ '**/*Test.java',
24
+ '**/*Test.kt'
25
+ ]
26
+ end
27
+
28
+ def merge_ignore_patterns(patterns)
29
+ return patterns unless patterns.is_a?(Array)
30
+
31
+ (default_ignore_patterns + patterns).compact.uniq
32
+ end
33
+ end
34
+ end
35
+ end