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
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../localization_syntax'
4
+
5
+ module I18nContextGenerator
6
+ class Config
7
+ # Typed registry for client-facing configuration. CLI metadata and sample
8
+ # configuration defaults are derived from these definitions.
9
+ module Schema
10
+ VERSION = 1
11
+ DOCUMENT_KEYS = %w[
12
+ schema_version translations source context llm processing cache output swift privacy workflow
13
+ ].freeze
14
+ SECTION_KEYS = {
15
+ 'source' => %w[paths ignore],
16
+ 'context' => %w[files],
17
+ 'llm' => %w[provider model endpoint],
18
+ 'processing' => %w[
19
+ concurrency context_lines max_matches_per_key max_prompt_chars discovery_mode platform
20
+ ],
21
+ 'cache' => %w[enabled directory],
22
+ 'output' => %w[path format stdout write_back write_back_to_code context_prefix context_mode],
23
+ 'swift' => %w[functions],
24
+ 'privacy' => %w[include_file_paths include_translation_comments redact_prompts],
25
+ 'workflow' => %w[stage]
26
+ }.freeze
27
+
28
+ Definition = Data.define(:name, :type, :default, :values, :yaml_path, :cli) do
29
+ def initialize(name:, type:, default:, values:, yaml_path:, cli:)
30
+ default.freeze if default.respond_to?(:freeze)
31
+ super(
32
+ name: name,
33
+ type: type,
34
+ default: default,
35
+ values: values&.freeze,
36
+ yaml_path: yaml_path&.freeze,
37
+ cli: cli&.freeze
38
+ )
39
+ end
40
+
41
+ def cli?
42
+ !cli.nil?
43
+ end
44
+
45
+ def cli_name
46
+ cli.fetch(:name, name)
47
+ end
48
+
49
+ def thor_options
50
+ options = { desc: help_description }
51
+ options[:aliases] = cli[:aliases] if cli[:aliases]
52
+ options[:type] = cli.fetch(:type) { Schema.thor_type(type) }
53
+ options[:enum] = values if values
54
+ options[:repeatable] = true if cli[:repeatable]
55
+ options
56
+ end
57
+
58
+ private
59
+
60
+ def help_description
61
+ return cli[:description] unless cli[:show_default]
62
+
63
+ label = cli[:default_note]&.then { |note| format(note, default) }
64
+ label ||= cli[:quote_default] ? default.inspect : default.to_s
65
+ "#{cli[:description]} (default: #{label})"
66
+ end
67
+ end
68
+
69
+ DEFINITIONS = [
70
+ Definition.new(name: :config, type: :string, default: nil, values: nil, yaml_path: nil,
71
+ cli: { aliases: '-c', description: 'Path to config file (.i18n-context-generator.yml)' }),
72
+ Definition.new(name: :schema_version, type: :integer, default: VERSION, values: [VERSION],
73
+ yaml_path: ['schema_version'], cli: nil),
74
+ Definition.new(name: :translations, type: :array, default: [], values: nil, yaml_path: ['translations'],
75
+ cli: { name: :translation, type: :string, repeatable: true, aliases: '-t',
76
+ description: 'Translation file (repeat for multiple files)' }),
77
+ Definition.new(name: :source_paths, type: :array, default: ['.'], values: nil, yaml_path: %w[source paths],
78
+ cli: { name: :source, type: :string, repeatable: true, aliases: '-s',
79
+ description: 'Source file or directory (repeat for multiple paths)' }),
80
+ Definition.new(name: :ignore_patterns, type: :array, default: [], values: nil, yaml_path: %w[source ignore], cli: nil),
81
+ Definition.new(name: :context_files, type: :array, default: [], values: nil, yaml_path: %w[context files],
82
+ cli: { name: :context_file, type: :string, repeatable: true,
83
+ description: 'Supplemental context file (repeat for multiple files)' }),
84
+ Definition.new(name: :provider, type: :string, default: 'anthropic',
85
+ values: %w[anthropic openai openai_compatible], yaml_path: %w[llm provider],
86
+ cli: { aliases: '-p', description: 'LLM provider', show_default: true }),
87
+ Definition.new(name: :model, type: :string, default: nil, values: nil, yaml_path: %w[llm model],
88
+ cli: { aliases: '-m', description: 'LLM model to use (provider default when omitted)' }),
89
+ Definition.new(name: :endpoint, type: :string, default: nil, values: nil, yaml_path: %w[llm endpoint],
90
+ cli: { description: 'Explicit OpenAI-compatible Responses API endpoint' }),
91
+ Definition.new(name: :concurrency, type: :integer, default: 5, values: nil, yaml_path: %w[processing concurrency],
92
+ cli: { description: 'Number of concurrent requests', show_default: true }),
93
+ Definition.new(name: :context_lines, type: :integer, default: 15, values: nil, yaml_path: %w[processing context_lines], cli: nil),
94
+ Definition.new(name: :max_matches_per_key, type: :integer, default: 3, values: nil,
95
+ yaml_path: %w[processing max_matches_per_key], cli: nil),
96
+ Definition.new(name: :max_prompt_chars, type: :integer, default: 50_000, values: nil,
97
+ yaml_path: %w[processing max_prompt_chars],
98
+ cli: { description: 'Maximum characters sent per LLM prompt', show_default: true }),
99
+ Definition.new(name: :discovery_mode, type: :string, default: 'auto', values: %w[auto translations source],
100
+ yaml_path: %w[processing discovery_mode],
101
+ cli: { description: 'How to discover entries: auto, translations, or source', show_default: true }),
102
+ Definition.new(name: :platform, type: :string, default: nil, values: %w[ios android], yaml_path: %w[processing platform],
103
+ cli: { description: 'Explicit platform override: ios or android' }),
104
+ Definition.new(name: :output_path, type: :string, default: nil, values: nil, yaml_path: %w[output path],
105
+ cli: { name: :output, aliases: '-o', description: 'Output file path (.csv or .json; format inferred when omitted)' }),
106
+ Definition.new(name: :output_format, type: :string, default: 'csv', values: %w[csv json], yaml_path: %w[output format],
107
+ cli: { name: :format, aliases: '-f', description: 'Output format', show_default: true,
108
+ default_note: 'inferred from output path; fallback %s' }),
109
+ Definition.new(name: :output_stdout, type: :boolean, default: false, values: nil,
110
+ yaml_path: %w[output stdout],
111
+ cli: { name: :stdout, description: 'Write structured CSV or JSON to stdout' }),
112
+ Definition.new(name: :write_back, type: :boolean, default: false, values: nil, yaml_path: %w[output write_back],
113
+ cli: { description: 'Write context back to translation files (.strings, .xcstrings, strings.xml)',
114
+ show_default: true }),
115
+ Definition.new(name: :write_back_to_code, type: :boolean, default: false, values: nil,
116
+ yaml_path: %w[output write_back_to_code],
117
+ cli: { description: 'Write context back to Swift source code comment: parameters', show_default: true }),
118
+ Definition.new(name: :context_prefix, type: :string, default: 'Context: ', values: nil, yaml_path: %w[output context_prefix],
119
+ cli: { description: 'Prefix for generated context comments', show_default: true, quote_default: true }),
120
+ Definition.new(name: :context_mode, type: :string, default: 'replace', values: %w[replace append], yaml_path: %w[output context_mode],
121
+ cli: { description: 'How to handle existing comments: replace or append', show_default: true }),
122
+ Definition.new(name: :swift_functions, type: :array, default: LocalizationSyntax::DEFAULT_SWIFT_FUNCTIONS,
123
+ values: nil, yaml_path: %w[swift functions], cli: nil),
124
+ Definition.new(name: :cache_enabled, type: :boolean, default: false, values: nil, yaml_path: %w[cache enabled],
125
+ cli: { name: :cache, description: 'Enable caching of successful LLM results', show_default: true }),
126
+ Definition.new(name: :cache_dir, type: :string, default: '.i18n-context-generator-cache', values: nil,
127
+ yaml_path: %w[cache directory],
128
+ cli: { description: 'Cache directory', show_default: true }),
129
+ Definition.new(name: :dry_run, type: :boolean, default: false, values: nil, yaml_path: nil,
130
+ cli: { description: 'Show what would be processed without calling the LLM' }),
131
+ Definition.new(name: :key_filter, type: :string, default: nil, values: nil, yaml_path: nil,
132
+ cli: { name: :key, aliases: '-k', type: :string, repeatable: true,
133
+ description: 'Key filter pattern (repeatable, supports * wildcard)' }),
134
+ Definition.new(name: :print_config, type: :boolean, default: false, values: nil, yaml_path: nil,
135
+ cli: { description: 'Print the resolved configuration and exit' }),
136
+ Definition.new(name: :workflow_stage, type: :string, default: 'apply',
137
+ values: %w[check plan preview_diff apply], yaml_path: %w[workflow stage], cli: nil),
138
+ Definition.new(name: :diff_base, type: :string, default: nil, values: nil, yaml_path: nil,
139
+ cli: { description: 'Only process keys changed since this git ref (e.g., main, origin/main)' }),
140
+ Definition.new(name: :diff_head, type: :string, default: 'HEAD', values: nil, yaml_path: nil,
141
+ cli: { description: 'Compare --diff-base to this git ref', show_default: true }),
142
+ Definition.new(name: :start_key, type: :string, default: nil, values: nil, yaml_path: nil,
143
+ cli: { description: 'Start processing from this key (inclusive)' }),
144
+ Definition.new(name: :end_key, type: :string, default: nil, values: nil, yaml_path: nil,
145
+ cli: { description: 'Stop processing at this key (inclusive)' }),
146
+ Definition.new(name: :include_file_paths, type: :boolean, default: false, values: nil,
147
+ yaml_path: %w[privacy include_file_paths],
148
+ cli: { description: 'Include full source file paths in LLM prompts', show_default: true }),
149
+ Definition.new(name: :include_translation_comments, type: :boolean, default: true, values: nil,
150
+ yaml_path: %w[privacy include_translation_comments],
151
+ cli: { description: 'Include translation file comments in LLM prompts', show_default: true }),
152
+ Definition.new(name: :redact_prompts, type: :boolean, default: true, values: nil, yaml_path: %w[privacy redact_prompts],
153
+ cli: { description: 'Best-effort redact likely secrets and PII from LLM prompts', show_default: true })
154
+ ].freeze
155
+
156
+ BY_NAME = DEFINITIONS.to_h { |definition| [definition.name, definition] }.freeze
157
+
158
+ module_function
159
+
160
+ def definition(name)
161
+ BY_NAME.fetch(name)
162
+ end
163
+
164
+ def thor_type(type)
165
+ { string: :string, integer: :numeric, boolean: :boolean }.fetch(type)
166
+ end
167
+
168
+ def default(name)
169
+ value = definition(name).default
170
+ value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(String) ? value.dup : value
171
+ end
172
+
173
+ def values(name)
174
+ definition(name).values
175
+ end
176
+
177
+ def cli_definitions
178
+ DEFINITIONS.select(&:cli?)
179
+ end
180
+
181
+ def configured?(yaml, name)
182
+ path = definition(name).yaml_path
183
+ return false unless path
184
+
185
+ parent = path[0...-1].reduce(yaml) { |value, key| value.is_a?(Hash) ? value[key] : nil }
186
+ parent.is_a?(Hash) && parent.key?(path.last)
187
+ end
188
+
189
+ def value(yaml, name)
190
+ return default(name) unless configured?(yaml, name)
191
+
192
+ definition(name).yaml_path.reduce(yaml) { |value, key| value.fetch(key) }
193
+ end
194
+
195
+ def validate_document!(yaml, path:)
196
+ explicit_version = yaml.key?('schema_version')
197
+ version = yaml.fetch('schema_version', VERSION)
198
+ raise Error, "Invalid config #{path}: unsupported schema_version #{version.inspect}; expected #{VERSION}" unless version == VERSION
199
+
200
+ unknown_messages = []
201
+ unknown_top_level = yaml.keys - DOCUMENT_KEYS
202
+ unknown_messages << "unknown top-level keys: #{unknown_top_level.join(', ')}" if unknown_top_level.any?
203
+
204
+ SECTION_KEYS.each do |section, allowed_keys|
205
+ value = yaml[section]
206
+ next unless value.is_a?(Hash)
207
+
208
+ unknown = value.keys - allowed_keys
209
+ next if unknown.empty?
210
+
211
+ unknown_messages << "unknown #{section} keys: #{unknown.join(', ')}"
212
+ end
213
+
214
+ if unknown_messages.any?
215
+ details = unknown_messages.join('; ')
216
+ raise Error, "Invalid config #{path}: #{details}" if explicit_version
217
+
218
+ warn "Warning: #{details} ignored in unversioned config #{path}; add schema_version: #{VERSION} to enable strict validation"
219
+ end
220
+
221
+ VERSION
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class Config
5
+ # Secret-free, versioned representation used by --print-config and clients.
6
+ module Serialization
7
+ def to_h
8
+ {
9
+ 'schema_version' => @schema_version,
10
+ 'translations' => serialized_translations,
11
+ 'source' => {
12
+ 'paths' => @source_paths,
13
+ 'ignore' => @ignore_patterns
14
+ },
15
+ 'context' => {
16
+ 'files' => @context_files
17
+ },
18
+ 'llm' => {
19
+ 'provider' => @provider,
20
+ 'model' => @model,
21
+ 'endpoint' => @endpoint
22
+ }.compact,
23
+ 'processing' => {
24
+ 'discovery_mode' => @discovery_mode,
25
+ 'platform' => @platform,
26
+ 'concurrency' => @concurrency,
27
+ 'context_lines' => @context_lines,
28
+ 'max_matches_per_key' => @max_matches_per_key,
29
+ 'max_prompt_chars' => @max_prompt_chars
30
+ }.compact,
31
+ 'cache' => {
32
+ 'enabled' => !@no_cache,
33
+ 'directory' => @cache_dir
34
+ },
35
+ 'output' => {
36
+ 'format' => @output_format,
37
+ 'path' => (@output_path unless @output_stdout),
38
+ 'stdout' => @output_stdout,
39
+ 'write_back' => @write_back,
40
+ 'write_back_to_code' => @write_back_to_code,
41
+ 'context_prefix' => @context_prefix,
42
+ 'context_mode' => @context_mode
43
+ }.compact,
44
+ 'swift' => { 'functions' => @swift_functions },
45
+ 'privacy' => {
46
+ 'include_file_paths' => @include_file_paths,
47
+ 'include_translation_comments' => @include_translation_comments,
48
+ 'redact_prompts' => @redact_prompts
49
+ },
50
+ 'workflow' => { 'stage' => @workflow_stage }
51
+ }
52
+ end
53
+
54
+ private
55
+
56
+ def serialized_translations
57
+ @translations.map do |path|
58
+ locale = @translation_locales[path]
59
+ locale ? { 'path' => path, 'locale' => locale } : path
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative '../file_classifier'
5
+
6
+ module I18nContextGenerator
7
+ class Config
8
+ # Validates the fully resolved configuration before extraction starts.
9
+ module Validation
10
+ BOOLEAN_OPTIONS = %i[
11
+ no_cache dry_run write_back write_back_to_code include_file_paths
12
+ include_translation_comments redact_prompts output_stdout print_config
13
+ ].freeze
14
+ OPTIONAL_STRING_OPTIONS = %i[
15
+ model endpoint output_path diff_base diff_head start_key end_key platform
16
+ ].freeze
17
+ TRANSLATION_DIFF_EXTENSIONS = %w[.strings .xcstrings .xml].freeze
18
+
19
+ def validate!
20
+ errors = []
21
+ validate_collections(errors)
22
+ validate_scalars(errors)
23
+ validate_domains(errors)
24
+ validate_provider_endpoint(errors)
25
+ validate_configured_paths(errors)
26
+ validate_output(errors)
27
+ validate_cache(errors)
28
+ validate_diff_support(errors)
29
+ validate_write_back(errors)
30
+
31
+ raise Error, "Invalid configuration: #{errors.join('; ')}" if errors.any?
32
+
33
+ self
34
+ end
35
+
36
+ private
37
+
38
+ def validate_collections(errors)
39
+ validate_string_array(errors, :translations, @translations)
40
+ validate_string_array(errors, :source_paths, @source_paths, allow_empty: false)
41
+ validate_string_array(errors, :ignore_patterns, @ignore_patterns)
42
+ validate_string_array(errors, :context_files, @context_files)
43
+ validate_string_array(errors, :swift_functions, @swift_functions)
44
+ errors << 'supplemental_context must map non-empty string names to non-empty string values' unless
45
+ valid_supplemental_context?
46
+ errors << 'key_filter must be a non-empty string or an array of non-empty strings' unless
47
+ @key_filter.nil? || valid_key_filter?(@key_filter)
48
+
49
+ errors << 'source_line_filter must be a mapping' if @source_line_filter && !@source_line_filter.is_a?(Hash)
50
+ return if @translation_locales.is_a?(Hash) && @translation_locales.all? do |path, locale|
51
+ path.is_a?(String) && locale.is_a?(String) && !locale.strip.empty?
52
+ end
53
+
54
+ errors << 'translation locales must map file paths to non-empty locale names'
55
+ end
56
+
57
+ def validate_scalars(errors)
58
+ validate_integer(errors, :concurrency, @concurrency, minimum: 1)
59
+ validate_integer(errors, :context_lines, @context_lines, minimum: 0)
60
+ validate_integer(errors, :max_matches_per_key, @max_matches_per_key, minimum: 1)
61
+ validate_integer(errors, :max_prompt_chars, @max_prompt_chars, minimum: 2_000)
62
+
63
+ BOOLEAN_OPTIONS.each do |name|
64
+ value = instance_variable_get(:"@#{name}")
65
+ errors << "#{name} must be true or false" unless [true, false].include?(value)
66
+ end
67
+
68
+ OPTIONAL_STRING_OPTIONS.each do |name|
69
+ value = instance_variable_get(:"@#{name}")
70
+ next if value.nil? || (value.is_a?(String) && !value.strip.empty?)
71
+
72
+ errors << "#{name} must be a non-empty string"
73
+ end
74
+ errors << 'context_prefix must be a string' unless @context_prefix.is_a?(String)
75
+ errors << 'cache_dir must be a non-empty string' unless @cache_dir.is_a?(String) && !@cache_dir.strip.empty?
76
+ end
77
+
78
+ def validate_domains(errors)
79
+ errors << "schema_version must be #{Schema::VERSION}" unless @schema_version == Schema::VERSION
80
+ validate_inclusion(errors, :provider, @provider, VALID_PROVIDERS)
81
+ validate_inclusion(errors, :output_format, @output_format, VALID_OUTPUT_FORMATS)
82
+ validate_inclusion(errors, :context_mode, @context_mode, VALID_CONTEXT_MODES)
83
+ validate_inclusion(errors, :discovery_mode, @discovery_mode, VALID_DISCOVERY_MODES)
84
+ validate_inclusion(errors, :workflow_stage, @workflow_stage, Schema.values(:workflow_stage))
85
+ validate_inclusion(errors, :platform, @platform, VALID_PLATFORMS) unless @platform.nil?
86
+ errors << 'preview_diff requires write_back or write_back_to_code' if @workflow_stage == 'preview_diff' && !@write_back && !@write_back_to_code
87
+ errors << 'preview_diff cannot write structured output' if @workflow_stage == 'preview_diff' && @output_path
88
+ end
89
+
90
+ def validate_provider_endpoint(errors)
91
+ if @provider == 'openai_compatible'
92
+ errors << 'openai_compatible provider requires an explicit model' if @model.nil?
93
+ if @endpoint.nil?
94
+ errors << 'openai_compatible provider requires llm.endpoint'
95
+ else
96
+ validate_endpoint_uri(errors)
97
+ end
98
+ elsif @endpoint
99
+ errors << 'llm.endpoint is supported only with the openai_compatible provider'
100
+ end
101
+ end
102
+
103
+ def validate_endpoint_uri(errors)
104
+ uri = URI.parse(@endpoint)
105
+ valid_scheme = %w[http https].include?(uri.scheme)
106
+ errors << 'llm.endpoint must be an absolute HTTP(S) URL' unless valid_scheme && uri.host
107
+ errors << 'llm.endpoint must not contain credentials, a query, or a fragment' if uri.userinfo || uri.query || uri.fragment
108
+
109
+ loopback_hosts = %w[localhost 127.0.0.1 ::1]
110
+ normalized_host = uri.hostname&.downcase
111
+ errors << 'plain HTTP llm.endpoint is allowed only for a loopback host' if uri.scheme == 'http' && !loopback_hosts.include?(normalized_host)
112
+ rescue URI::InvalidURIError
113
+ errors << 'llm.endpoint must be an absolute HTTP(S) URL'
114
+ end
115
+
116
+ def validate_configured_paths(errors)
117
+ if string_array?(@translations)
118
+ @translations.each do |path|
119
+ errors << "translation file not found: #{path}" unless File.file?(path)
120
+ end
121
+ validate_translation_locales(errors)
122
+ end
123
+
124
+ return unless string_array?(@source_paths)
125
+
126
+ @source_paths.each do |path|
127
+ errors << "source path not found: #{path}" unless File.exist?(path)
128
+ end
129
+
130
+ return unless string_array?(@context_files)
131
+
132
+ @context_files.each do |path|
133
+ if !File.file?(path)
134
+ errors << "context file not found: #{path}"
135
+ elsif !File.readable?(path)
136
+ errors << "context file is not readable: #{path}"
137
+ end
138
+ end
139
+ end
140
+
141
+ def validate_translation_locales(errors)
142
+ return unless @translation_locales.is_a?(Hash)
143
+
144
+ @translation_locales.each_key do |path|
145
+ errors << "translation locale references an unconfigured file: #{path}" unless @translations.include?(path)
146
+ next if %w[.yml .yaml].include?(File.extname(path).downcase)
147
+
148
+ errors << "translation locale is supported only for YAML files: #{path}"
149
+ end
150
+ end
151
+
152
+ def validate_output(errors)
153
+ errors << 'output.path and output.stdout cannot both be configured' if @output_destination_conflict
154
+ return if @output_stdout
155
+ return unless @output_path.is_a?(String)
156
+
157
+ extension = File.extname(@output_path).downcase
158
+ if !extension.empty? && !VALID_OUTPUT_EXTENSIONS.key?(extension)
159
+ errors << "output path extension must be .csv or .json: #{@output_path}"
160
+ elsif VALID_OUTPUT_EXTENSIONS[extension] && VALID_OUTPUT_EXTENSIONS[extension] != @output_format
161
+ errors << "output format #{@output_format} does not match #{extension} path"
162
+ end
163
+
164
+ parent = File.expand_path(File.dirname(@output_path))
165
+ if File.directory?(@output_path)
166
+ errors << "output path is a directory: #{@output_path}"
167
+ elsif !File.directory?(parent)
168
+ errors << "output directory not found: #{File.dirname(@output_path)}"
169
+ elsif !File.writable?(parent)
170
+ errors << "output directory is not writable: #{File.dirname(@output_path)}"
171
+ elsif File.exist?(@output_path) && !File.writable?(@output_path)
172
+ errors << "output file is not writable: #{@output_path}"
173
+ end
174
+ end
175
+
176
+ def validate_cache(errors)
177
+ return if @no_cache || !@cache_dir.is_a?(String) || @cache_dir.strip.empty?
178
+ return unless File.exist?(@cache_dir) && !File.directory?(@cache_dir)
179
+
180
+ errors << "cache directory path is not a directory: #{@cache_dir}"
181
+ end
182
+
183
+ def validate_diff_support(errors)
184
+ return unless @diff_base.is_a?(String) && translation_backed_diff?
185
+ return unless string_array?(@translations)
186
+
187
+ unsupported = @translations.reject do |path|
188
+ TRANSLATION_DIFF_EXTENSIONS.include?(File.extname(path).downcase)
189
+ end
190
+ return if unsupported.empty?
191
+
192
+ errors << "diff_base is not supported for translation formats: #{unsupported.join(', ')}"
193
+ end
194
+
195
+ def validate_write_back(errors)
196
+ return unless @write_back
197
+
198
+ if !string_array?(@translations) || @translations.empty?
199
+ errors << 'write_back requires at least one translation file'
200
+ else
201
+ unsupported = @translations.reject { |path| supported_translation_write_back?(path) }
202
+ errors << "write_back is not supported for: #{unsupported.join(', ')}" if unsupported.any?
203
+ end
204
+ end
205
+
206
+ def validate_string_array(errors, name, value, allow_empty: true)
207
+ return if string_array?(value) && (allow_empty || value.any?)
208
+
209
+ requirement = allow_empty ? 'an array of non-empty strings' : 'a non-empty array of strings'
210
+ errors << "#{name} must be #{requirement}"
211
+ end
212
+
213
+ def string_array?(value)
214
+ value.is_a?(Array) && value.all? { |item| item.is_a?(String) && !item.strip.empty? }
215
+ end
216
+
217
+ def valid_key_filter?(value)
218
+ (value.is_a?(String) && !value.strip.empty?) || (string_array?(value) && value.any?)
219
+ end
220
+
221
+ def valid_supplemental_context?
222
+ @supplemental_context.is_a?(Hash) && @supplemental_context.all? do |name, value|
223
+ name.is_a?(String) && !name.strip.empty? &&
224
+ value.is_a?(String) && !value.strip.empty?
225
+ end
226
+ end
227
+
228
+ def validate_integer(errors, name, value, minimum:)
229
+ return if value.is_a?(Integer) && value >= minimum
230
+
231
+ errors << "#{name} must be an integer greater than or equal to #{minimum}"
232
+ end
233
+
234
+ def validate_inclusion(errors, name, value, allowed)
235
+ return if allowed.include?(value)
236
+
237
+ errors << "#{name} must be one of: #{allowed.join(', ')}"
238
+ end
239
+
240
+ def translation_backed_diff?
241
+ @discovery_mode == 'translations' || (@discovery_mode == 'auto' && @translations&.any?)
242
+ end
243
+
244
+ def supported_translation_write_back?(path)
245
+ !FileClassifier.translation_platform(path).nil?
246
+ end
247
+ end
248
+ end
249
+ end