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.
- checksums.yaml +4 -4
- data/README.md +163 -26
- data/lib/i18n_context_generator/android_resource.rb +209 -0
- data/lib/i18n_context_generator/apple_string_literal.rb +28 -0
- data/lib/i18n_context_generator/cache.rb +40 -8
- data/lib/i18n_context_generator/changed_location.rb +55 -0
- data/lib/i18n_context_generator/cli.rb +163 -76
- data/lib/i18n_context_generator/config/cli_values.rb +33 -0
- data/lib/i18n_context_generator/config/defaults.rb +35 -0
- data/lib/i18n_context_generator/config/schema.rb +225 -0
- data/lib/i18n_context_generator/config/serialization.rb +64 -0
- data/lib/i18n_context_generator/config/validation.rb +249 -0
- data/lib/i18n_context_generator/config.rb +293 -102
- data/lib/i18n_context_generator/context_extractor/cache_identity.rb +65 -0
- data/lib/i18n_context_generator/context_extractor/extraction_result.rb +46 -0
- data/lib/i18n_context_generator/context_extractor/run_logging.rb +45 -6
- data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
- data/lib/i18n_context_generator/context_extractor/source_filters.rb +51 -5
- data/lib/i18n_context_generator/context_extractor/translation_filters.rb +91 -0
- data/lib/i18n_context_generator/context_extractor/workflow.rb +118 -0
- data/lib/i18n_context_generator/context_extractor.rb +207 -216
- data/lib/i18n_context_generator/file_classifier.rb +72 -0
- data/lib/i18n_context_generator/generated_comment.rb +32 -0
- data/lib/i18n_context_generator/git_diff/xml_changes.rb +235 -0
- data/lib/i18n_context_generator/git_diff.rb +224 -95
- data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
- data/lib/i18n_context_generator/llm/client.rb +304 -92
- data/lib/i18n_context_generator/llm/openai.rb +92 -51
- data/lib/i18n_context_generator/llm/openai_compatible.rb +20 -0
- data/lib/i18n_context_generator/llm/prompt_evidence.rb +47 -0
- data/lib/i18n_context_generator/llm/request_policy.rb +147 -0
- data/lib/i18n_context_generator/localization_syntax.rb +246 -0
- data/lib/i18n_context_generator/parsers/android_xml_parser.rb +47 -8
- data/lib/i18n_context_generator/parsers/base.rb +7 -4
- data/lib/i18n_context_generator/parsers/json_parser.rb +4 -0
- data/lib/i18n_context_generator/parsers/xcstrings_parser.rb +79 -0
- data/lib/i18n_context_generator/parsers/yaml_parser.rb +20 -7
- data/lib/i18n_context_generator/path_policy.rb +107 -0
- data/lib/i18n_context_generator/platform_validator.rb +24 -50
- data/lib/i18n_context_generator/run_metrics.rb +47 -0
- data/lib/i18n_context_generator/searcher/comment_masking.rb +115 -0
- data/lib/i18n_context_generator/searcher/match_filtering.rb +52 -0
- data/lib/i18n_context_generator/searcher/source_discovery.rb +109 -64
- data/lib/i18n_context_generator/searcher.rb +61 -205
- data/lib/i18n_context_generator/supplemental_context.rb +77 -0
- data/lib/i18n_context_generator/translation_comment_index.rb +121 -0
- data/lib/i18n_context_generator/version.rb +1 -1
- data/lib/i18n_context_generator/writers/android_xml_writer.rb +48 -18
- data/lib/i18n_context_generator/writers/atomic_file.rb +37 -0
- data/lib/i18n_context_generator/writers/csv_writer.rb +43 -18
- data/lib/i18n_context_generator/writers/helpers.rb +7 -29
- data/lib/i18n_context_generator/writers/json_writer.rb +31 -6
- data/lib/i18n_context_generator/writers/preview.rb +80 -0
- data/lib/i18n_context_generator/writers/result_serialization.rb +30 -0
- data/lib/i18n_context_generator/writers/strings_writer.rb +23 -12
- data/lib/i18n_context_generator/writers/swift_writer.rb +16 -54
- data/lib/i18n_context_generator/writers/xcstrings_writer.rb +57 -0
- data/lib/i18n_context_generator/xcstrings_document.rb +248 -0
- data/lib/i18n_context_generator/xml_scanner.rb +38 -0
- data/lib/i18n_context_generator.rb +20 -4
- metadata +59 -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
|