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