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