ukiryu 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ukiryu/cache.rb +6 -0
  3. data/lib/ukiryu/cache_registry.rb +64 -0
  4. data/lib/ukiryu/cli_commands/base_command.rb +6 -5
  5. data/lib/ukiryu/cli_commands/config_command.rb +7 -10
  6. data/lib/ukiryu/cli_commands/register_command.rb +27 -18
  7. data/lib/ukiryu/cli_commands/validate_command.rb +2 -2
  8. data/lib/ukiryu/command_builder.rb +83 -50
  9. data/lib/ukiryu/config.rb +13 -2
  10. data/lib/ukiryu/debug.rb +20 -9
  11. data/lib/ukiryu/definition/loader.rb +3 -3
  12. data/lib/ukiryu/errors.rb +37 -37
  13. data/lib/ukiryu/executable_locator.rb +40 -16
  14. data/lib/ukiryu/extractors/base_extractor.rb +2 -1
  15. data/lib/ukiryu/extractors/help_parser.rb +3 -0
  16. data/lib/ukiryu/logger.rb +51 -0
  17. data/lib/ukiryu/models/implementation_index.rb +2 -1
  18. data/lib/ukiryu/models/implementation_version.rb +18 -1
  19. data/lib/ukiryu/models/interface.rb +2 -1
  20. data/lib/ukiryu/models/run_environment.rb +0 -2
  21. data/lib/ukiryu/models/semantic_version.rb +174 -0
  22. data/lib/ukiryu/models/stage_metrics.rb +0 -1
  23. data/lib/ukiryu/register.rb +473 -232
  24. data/lib/ukiryu/shell/powershell.rb +209 -89
  25. data/lib/ukiryu/shell/sh.rb +4 -1
  26. data/lib/ukiryu/shell.rb +60 -2
  27. data/lib/ukiryu/tool/command_resolution.rb +2 -1
  28. data/lib/ukiryu/tool/executable_discovery.rb +14 -15
  29. data/lib/ukiryu/tool/loader.rb +543 -0
  30. data/lib/ukiryu/tool/version_detection.rb +1 -3
  31. data/lib/ukiryu/tool.rb +79 -87
  32. data/lib/ukiryu/tool_index.rb +127 -62
  33. data/lib/ukiryu/tools/base.rb +4 -2
  34. data/lib/ukiryu/type.rb +26 -15
  35. data/lib/ukiryu/version.rb +1 -1
  36. data/lib/ukiryu.rb +1 -1
  37. data/spec/fixtures/profiles/ghostscript_10.0.yaml +50 -0
  38. data/spec/fixtures/register/tools/ghostscript/default/10.0.yaml +6 -0
  39. data/spec/spec_helper.rb +10 -6
  40. data/spec/support/tool_helper.rb +2 -0
  41. data/spec/ukiryu/definition/loader_spec.rb +2 -2
  42. data/spec/ukiryu/executor_spec.rb +6 -3
  43. data/spec/ukiryu/models/execution_report_spec.rb +3 -2
  44. data/spec/ukiryu/models/semantic_version_spec.rb +284 -0
  45. data/spec/ukiryu/shell/powershell_integration_spec.rb +165 -0
  46. data/spec/ukiryu/shell/powershell_real_command_spec.rb +143 -0
  47. data/spec/ukiryu/shell/powershell_spec.rb +286 -51
  48. data/spec/ukiryu/tool/loader_spec.rb +148 -0
  49. data/spec/ukiryu/tool_index_spec.rb +110 -18
  50. data/spec/ukiryu/tools/ghostscript_spec.rb +242 -0
  51. data/spec/ukiryu/tools/imagemagick_spec.rb +2 -1
  52. data/spec/ukiryu/tools/inkscape_spec.rb +4 -2
  53. metadata +14 -2
  54. data/lib/ukiryu/register_auto_manager.rb +0 -342
data/lib/ukiryu/tool.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'tool_cache'
4
4
  require_relative 'tool_finder'
5
+ require_relative 'tool/loader'
5
6
 
6
7
  module Ukiryu
7
8
  # Tool wrapper class for external command-line tools
@@ -44,48 +45,13 @@ module Ukiryu
44
45
  end
45
46
 
46
47
  # Try loading a tool using the new ImplementationIndex architecture
47
- # Returns nil if the tool doesn't use the new architecture
48
+ # Delegates to Tool::Loader module
48
49
  #
49
50
  # @param name [String, Symbol] the tool name
50
51
  # @param options [Hash] loading options
51
52
  # @return [Tool, nil] the tool instance or nil if not using new architecture
52
53
  def load_with_implementation_index(name, options = {})
53
- require_relative 'version_scheme_resolver'
54
-
55
- # Try to load ImplementationIndex
56
- index = Register.load_implementation_index(name, options)
57
- return nil unless index
58
-
59
- # Load Interface
60
- interface = Register.load_interface(index.interface, options)
61
- return nil unless interface
62
-
63
- # Detect implementation and version
64
- impl_spec = detect_implementation_and_version(index, name, options)
65
- return nil unless impl_spec
66
-
67
- # Load ImplementationVersion
68
- impl_version = Register.load_implementation_version(
69
- name,
70
- impl_spec[:implementation_name],
71
- impl_spec[:file],
72
- options
73
- )
74
- return nil unless impl_version
75
-
76
- # Convert to old ToolDefinition format for compatibility
77
- profile = convert_to_tool_definition(
78
- name,
79
- interface,
80
- impl_version,
81
- impl_spec[:implementation_name],
82
- impl_spec[:version], # Pass detected version
83
- options
84
- )
85
- return nil unless profile
86
-
87
- # Create tool instance
88
- new(profile, options)
54
+ Loader.load_with_implementation_index(name, options)
89
55
  end
90
56
 
91
57
  # Detect implementation and version from ImplementationIndex
@@ -129,11 +95,13 @@ module Ukiryu
129
95
  # Prefer implementation-level default, then version-level default, then last version
130
96
  impl_default = impl[:default] || impl['default']
131
97
  version_spec = if impl_default
132
- # Find version spec matching the implementation default
133
- versions.find { |v| v[:file] == impl_default || v['file'] == impl_default } || versions.last
98
+ # Find version spec matching the implementation default
99
+ versions.find do |v|
100
+ v[:file] == impl_default || v['file'] == impl_default
101
+ end || versions.last
134
102
  else
135
- versions.find { |v| v[:default] || v['default'] } || versions.last
136
- end
103
+ versions.find { |v| v[:default] || v['default'] } || versions.last
104
+ end
137
105
  return {
138
106
  implementation_name: impl[:name] || impl['name'],
139
107
  version: nil,
@@ -162,11 +130,11 @@ module Ukiryu
162
130
  # Prefer implementation-level default, then version-level default, then last version
163
131
  impl_default = impl[:default] || impl['default']
164
132
  default_spec = if impl_default
165
- # Find version spec matching the implementation default
166
- versions.find { |v| v[:file] == impl_default || v['file'] == impl_default } || versions.last
133
+ # Find version spec matching the implementation default
134
+ versions.find { |v| v[:file] == impl_default || v['file'] == impl_default } || versions.last
167
135
  else
168
- versions.find { |v| v[:default] || v['default'] } || versions.last
169
- end
136
+ versions.find { |v| v[:default] || v['default'] } || versions.last
137
+ end
170
138
  {
171
139
  implementation_name: impl[:name] || impl['name'],
172
140
  version: nil,
@@ -260,14 +228,14 @@ module Ukiryu
260
228
 
261
229
  versions.each do |version_spec|
262
230
  range_type = if version_spec[:equals]
263
- :equals
231
+ :equals
264
232
  elsif version_spec[:before]
265
- :before
233
+ :before
266
234
  elsif version_spec[:after]
267
- :after
235
+ :after
268
236
  else
269
- version_spec[:between] ? :between : nil
270
- end
237
+ version_spec[:between] ? :between : nil
238
+ end
271
239
 
272
240
  next unless range_type
273
241
 
@@ -301,7 +269,8 @@ end
301
269
  # @param implementation_name [String] implementation name
302
270
  # @param options [Hash] options
303
271
  # @return [ToolDefinition] converted tool definition
304
- def convert_to_tool_definition(tool_name, interface, impl_version, implementation_name, detected_version, options = {})
272
+ def convert_to_tool_definition(tool_name, interface, impl_version, implementation_name, detected_version,
273
+ options = {})
305
274
  require_relative 'models/tool_definition'
306
275
  require_relative 'models/platform_profile'
307
276
 
@@ -316,13 +285,13 @@ end
316
285
  # Build ToolDefinition from execution profile
317
286
  # Note: implements must be an array for the v2 model
318
287
  # Only append implementation name for non-default implementations
319
- specific_tool_name = if implementation_name && implementation_name != 'default'
320
- "#{tool_name}_#{implementation_name}"
321
- else
322
- tool_name
323
- end
288
+ if implementation_name && implementation_name != 'default'
289
+ "#{tool_name}_#{implementation_name}"
290
+ else
291
+ tool_name
292
+ end
324
293
  # Use detected version if available, otherwise fall back to YAML version
325
- version = detected_version || impl_version.version
294
+ detected_version || impl_version.version
326
295
  # Build ToolDefinition from execution profile
327
296
  # Note: implements must be an array for the v2 model
328
297
  # Only append implementation name for non-default implementations
@@ -377,11 +346,11 @@ end
377
346
  # @return [Hash] profile hash
378
347
  def convert_profile_to_hash(profile, actions)
379
348
  # Handle both Hash and ExecutionProfile objects
349
+ actions_hash = actions || {}
350
+ commands_array = convert_actions_to_array(actions_hash)
380
351
  if profile.is_a?(Hash)
381
352
  # Use the actions parameter (interface.actions), not profile[:actions]
382
- actions_hash = actions || {}
383
353
  # Convert actions hash to array format expected by ToolDefinition
384
- commands_array = convert_actions_to_array(actions_hash)
385
354
  {
386
355
  'name' => profile[:name] || profile['name'],
387
356
  'display_name' => profile[:display_name] || profile['display_name'],
@@ -392,8 +361,6 @@ end
392
361
  'commands' => commands_array
393
362
  }
394
363
  else
395
- actions_hash = actions || {}
396
- commands_array = convert_actions_to_array(actions_hash)
397
364
  {
398
365
  'name' => profile.name,
399
366
  'display_name' => profile.display_name,
@@ -441,25 +408,25 @@ end
441
408
  # If profile has commands, merge them with interface actions
442
409
  # If profile has no commands, use interface actions directly
443
410
  command_definitions = if profile_commands.nil? || profile_commands.empty?
444
- # No profile commands - use interface actions directly
445
- interface_commands_hash.map do |_cmd_name, cmd_hash|
446
- convert_hash_to_command_definition(cmd_hash)
447
- end
411
+ # No profile commands - use interface actions directly
412
+ interface_commands_hash.map do |_cmd_name, cmd_hash|
413
+ convert_hash_to_command_definition(cmd_hash)
414
+ end
448
415
  else
449
- # Profile has commands - merge with interface actions
450
- profile_commands.map do |cmd_hash|
451
- # Command name may be specified as 'name' or 'subcommand' field
452
- cmd_name = cmd_hash[:name] || cmd_hash['name'] || cmd_hash[:subcommand] || cmd_hash['subcommand']
453
- # Merge profile command data with interface action data
454
- interface_cmd = interface_commands_hash[cmd_name]
455
- merged_cmd_hash = if interface_cmd
456
- # Deep merge: profile data takes precedence
457
- deep_merge_hashes(interface_cmd, cmd_hash)
458
- else
459
- cmd_hash
460
- end
461
- convert_hash_to_command_definition(merged_cmd_hash)
462
- end
416
+ # Profile has commands - merge with interface actions
417
+ profile_commands.map do |cmd_hash|
418
+ # Command name may be specified as 'name' or 'subcommand' field
419
+ cmd_name = cmd_hash[:name] || cmd_hash['name'] || cmd_hash[:subcommand] || cmd_hash['subcommand']
420
+ # Merge profile command data with interface action data
421
+ interface_cmd = interface_commands_hash[cmd_name]
422
+ merged_cmd_hash = if interface_cmd
423
+ # Deep merge: profile data takes precedence
424
+ deep_merge_hashes(interface_cmd, cmd_hash)
425
+ else
426
+ cmd_hash
427
+ end
428
+ convert_hash_to_command_definition(merged_cmd_hash)
429
+ end
463
430
  end
464
431
 
465
432
  # Create PlatformProfile
@@ -501,7 +468,7 @@ end
501
468
  warn "[UKIRYU DEBUG build_command_definition] cmd.name: #{cmd_hash['name'] || cmd_hash[:name]}"
502
469
  warn "[UKIRYU DEBUG build_command_definition] post_options_data: #{post_options_data.inspect}"
503
470
  warn "[UKIRYU DEBUG build_command_definition] post_options_data.class: #{post_options_data.class}" if post_options_data
504
- if post_options_data && post_options_data.is_a?(Array)
471
+ if post_options_data.is_a?(Array)
505
472
  post_options_data.first(2).each do |opt|
506
473
  warn "[UKIRYU DEBUG build_command_definition] post_option: #{opt.inspect}"
507
474
  end
@@ -563,7 +530,8 @@ end
563
530
  when 'inputs' then 'arguments'
564
531
  else nested_key.to_s
565
532
  end
566
- command_def[target_key.to_sym] = nested_value unless [:signature, 'signature'].include?(nested_key)
533
+ command_def[target_key.to_sym] = nested_value unless [:signature,
534
+ 'signature'].include?(nested_key)
567
535
  end
568
536
  else
569
537
  command_def[key] = value
@@ -587,6 +555,7 @@ end
587
555
  # @option options [String] :register_path path to tool profiles
588
556
  # @option options [Symbol] :platform platform to use
589
557
  # @option options [Symbol] :shell shell to use
558
+ # @raise [Ukiryu::Errors::ToolNotFoundError] if tool is not found
590
559
  # @return [Tool] the tool instance
591
560
  def get(name, options = {})
592
561
  # Check cache first
@@ -602,6 +571,31 @@ end
602
571
  tool
603
572
  end
604
573
 
574
+ # Find a tool by name, returning nil if not found.
575
+ #
576
+ # This is a non-raising alternative to {.get} for cases where
577
+ # tool absence is expected and should be handled gracefully.
578
+ #
579
+ # @param name [String, Symbol] the tool name
580
+ # @param options [Hash] initialization options
581
+ # @option options [String] :register_path path to tool profiles
582
+ # @option options [Symbol] :platform platform to use
583
+ # @option options [Symbol] :shell shell to use
584
+ # @return [Tool, nil] the tool instance or nil if not found
585
+ #
586
+ # @example
587
+ # tool = Ukiryu::Tool.find(:imagemagick)
588
+ # if tool
589
+ # tool.execute(:convert, inputs: ["image.png"])
590
+ # else
591
+ # puts "ImageMagick not available"
592
+ # end
593
+ def find(name, options = {})
594
+ get(name, options)
595
+ rescue Ukiryu::Errors::ToolNotFoundError
596
+ nil
597
+ end
598
+
605
599
  # Find a tool by name, alias, or interface
606
600
  #
607
601
  # Searches for a tool that matches the given identifier by:
@@ -1000,9 +994,7 @@ end
1000
994
  # Normalize params to hash with symbol keys
1001
995
  params = normalize_params(params)
1002
996
 
1003
- if ENV['UKIRYU_DEBUG_EXECUTABLE']
1004
- warn "[UKIRYU DEBUG execute_simple] params (after normalize): #{params.inspect}"
1005
- end
997
+ warn "[UKIRYU DEBUG execute_simple] params (after normalize): #{params.inspect}" if ENV['UKIRYU_DEBUG_EXECUTABLE']
1006
998
 
1007
999
  # Extract stdin parameter if present (special parameter, not passed to command)
1008
1000
  stdin = params.delete(:stdin)
@@ -1034,10 +1026,10 @@ end
1034
1026
  # Use command-specific executable if profile explicitly allows it
1035
1027
  # This is determined by checking if the command has standalone_executable: true
1036
1028
  allows_standalone = if command.respond_to?(:standalone_executable?)
1037
- command.standalone_executable?
1029
+ command.standalone_executable?
1038
1030
  else
1039
- false
1040
- end
1031
+ false
1032
+ end
1041
1033
 
1042
1034
  same_dir_as_exec = allows_standalone &&
1043
1035
  File.executable?(exe_path) &&
@@ -3,6 +3,8 @@
3
3
  # Lazy load ToolMetadata only when needed
4
4
  autoload :ToolMetadata, File.expand_path('models/tool_metadata', __dir__)
5
5
 
6
+ require_relative 'models/semantic_version'
7
+
6
8
  module Ukiryu
7
9
  # Index for fast tool lookup by interface and alias
8
10
  #
@@ -12,20 +14,27 @@ module Ukiryu
12
14
  # - Register change detection via mtime
13
15
  #
14
16
  # Built once and cached for the lifetime of the process.
17
+ # Thread-safe for concurrent access.
15
18
  #
16
19
  # @api private
17
20
  class ToolIndex
21
+ @mutex = Mutex.new
22
+
18
23
  class << self
19
- # Get the singleton instance
24
+ # Get the singleton instance (thread-safe)
20
25
  #
21
26
  # @return [ToolIndex] the index instance
22
27
  def instance
23
- @instance ||= new
28
+ @mutex.synchronize do
29
+ @instance ||= new
30
+ end
24
31
  end
25
32
 
26
33
  # Reset the index (mainly for testing)
27
34
  def reset
28
- @instance = nil
35
+ @mutex.synchronize do
36
+ @instance = nil
37
+ end
29
38
  end
30
39
 
31
40
  # Get all tools in the index (class method delegating to instance)
@@ -41,13 +50,13 @@ module Ukiryu
41
50
  # @param register_path [String, nil] the path to the tool register
42
51
  def initialize(register_path: nil)
43
52
  @register_path = register_path || Ukiryu::Register.default_register_path
53
+ @mutex = Mutex.new
44
54
 
45
- if ENV['UKIRYU_DEBUG_EXECUTABLE']
46
- warn '[UKIRYU DEBUG ToolIndex#initialize] called'
47
- warn "[UKIRYU DEBUG] param register_path = #{register_path.inspect}"
48
- warn "[UKIRYU_DEBUG] Ukiryu::Register.default_register_path = #{Ukiryu::Register.default_register_path.inspect}"
49
- warn "[UKIRYU DEBUG] @register_path = #{@register_path.inspect}"
50
- end
55
+ Logger.debug('ToolIndex#initialize called', category: :executable)
56
+ Logger.debug("param register_path = #{register_path.inspect}", category: :executable)
57
+ Logger.debug("Ukiryu::Register.default_register_path = #{Ukiryu::Register.default_register_path.inspect}",
58
+ category: :executable)
59
+ Logger.debug("@register_path = #{@register_path.inspect}", category: :executable)
51
60
 
52
61
  @interface_to_tools = {} # interface => [tool_names]
53
62
  @alias_to_tool = {} # alias => [tool_names] (multiple tools can share an alias)
@@ -64,16 +73,18 @@ module Ukiryu
64
73
  def find_by_interface(interface_name)
65
74
  build_index_if_needed
66
75
 
67
- tool_names = @interface_to_tools[interface_name]
68
- return nil unless tool_names
76
+ @mutex.synchronize do
77
+ tool_names = @interface_to_tools[interface_name]
78
+ return nil unless tool_names
69
79
 
70
- # Try each tool implementing this interface until we find one that loads
71
- tool_names.each do |tool_name|
72
- metadata = load_metadata_for_tool(tool_name)
73
- return metadata if metadata
74
- end
80
+ # Try each tool implementing this interface until we find one that loads
81
+ tool_names.each do |tool_name|
82
+ metadata = load_metadata_for_tool(tool_name)
83
+ return metadata if metadata
84
+ end
75
85
 
76
- nil
86
+ nil
87
+ end
77
88
  end
78
89
 
79
90
  # Find all tools that implement an interface
@@ -83,7 +94,9 @@ module Ukiryu
83
94
  def find_all_by_interface(interface_name)
84
95
  build_index_if_needed
85
96
 
86
- @interface_to_tools[interface_name] || []
97
+ @mutex.synchronize do
98
+ @interface_to_tools[interface_name] || []
99
+ end
87
100
  end
88
101
 
89
102
  # Find tool by alias
@@ -96,20 +109,22 @@ module Ukiryu
96
109
  def find_by_alias(alias_name)
97
110
  build_index_if_needed
98
111
 
99
- candidates = @alias_to_tool[alias_name.to_sym]
100
- return nil unless candidates
112
+ @mutex.synchronize do
113
+ candidates = @alias_to_tool[alias_name.to_sym]
114
+ return nil unless candidates
101
115
 
102
- # If only one tool has this alias, return it directly
103
- return candidates.first if candidates.one?
116
+ # If only one tool has this alias, return it directly
117
+ return candidates.first if candidates.one?
104
118
 
105
- # Multiple tools have this alias - select by platform compatibility
106
- runtime = Ukiryu::Runtime.instance
107
- platform = runtime.platform
108
- shell = runtime.shell
119
+ # Multiple tools have this alias - select by platform compatibility
120
+ runtime = Ukiryu::Runtime.instance
121
+ platform = runtime.platform
122
+ shell = runtime.shell
109
123
 
110
- candidates.find do |tool_name|
111
- tool_compatible?(tool_name, platform: platform, shell: shell)
112
- end || candidates.first
124
+ candidates.find do |tool_name|
125
+ tool_compatible?(tool_name, platform: platform, shell: shell)
126
+ end || candidates.first
127
+ end
113
128
  end
114
129
 
115
130
  # Check if a tool is compatible with the given platform and shell
@@ -165,31 +180,45 @@ module Ukiryu
165
180
  #
166
181
  # @return [Boolean] true if rebuild is needed
167
182
  def stale?
168
- return true unless @built
183
+ @mutex.synchronize do
184
+ return true unless @built
169
185
 
170
- current_cache_key = build_cache_key
171
- @cache_key != current_cache_key
186
+ current_cache_key = build_cache_key
187
+ @cache_key != current_cache_key
188
+ end
172
189
  end
173
190
 
174
191
  # Update the register path
175
192
  #
176
193
  # @param new_path [String] the new register path
177
194
  def register_path=(new_path)
178
- return if @register_path == new_path
179
-
180
- @register_path = new_path
181
- @built = false # Rebuild index with new path
182
- @cache_key = nil
183
- @interface_to_tools = {}
184
- @alias_to_tool = {}
185
- @compatibility_cache = {}
195
+ @mutex.synchronize do
196
+ return if @register_path == new_path
197
+
198
+ @register_path = new_path
199
+ @built = false # Rebuild index with new path
200
+ @cache_key = nil
201
+ @interface_to_tools = {}
202
+ @alias_to_tool = {}
203
+ @compatibility_cache = {}
204
+ end
186
205
  end
187
206
 
188
207
  private
189
208
 
190
- # Build index only if needed (lazy loading)
209
+ # Build index only if needed (lazy loading) - thread-safe
191
210
  def build_index_if_needed
192
- build_index if stale?
211
+ @mutex.synchronize do
212
+ build_index if stale_without_lock?
213
+ end
214
+ end
215
+
216
+ # Check if stale without acquiring lock (must be called within synchronized block)
217
+ def stale_without_lock?
218
+ return true unless @built
219
+
220
+ current_cache_key = build_cache_key
221
+ @cache_key != current_cache_key
193
222
  end
194
223
 
195
224
  # Build cache key for register change detection
@@ -216,12 +245,11 @@ module Ukiryu
216
245
  def register_path
217
246
  path = @register_path || Ukiryu::Register.default_register_path
218
247
 
219
- if ENV['UKIRYU_DEBUG_EXECUTABLE']
220
- warn '[UKIRYU DEBUG ToolIndex#register_path] called'
221
- warn "[UKIRYU DEBUG] @register_path = #{@register_path.inspect}"
222
- warn "[UKIRYU DEBUG] Ukiryu::Register.default_register_path = #{Ukiryu::Register.default_register_path.inspect}"
223
- warn "[UKIRYU DEBUG] returning = #{path.inspect}"
224
- end
248
+ Logger.debug('ToolIndex#register_path called', category: :executable)
249
+ Logger.debug("@register_path = #{@register_path.inspect}", category: :executable)
250
+ Logger.debug("Ukiryu::Register.default_register_path = #{Ukiryu::Register.default_register_path.inspect}",
251
+ category: :executable)
252
+ Logger.debug("returning = #{path.inspect}", category: :executable)
225
253
 
226
254
  path
227
255
  end
@@ -231,10 +259,8 @@ module Ukiryu
231
259
  def build_index
232
260
  current_path = register_path
233
261
 
234
- if ENV['UKIRYU_DEBUG_EXECUTABLE']
235
- warn '[UKIRYU DEBUG ToolIndex#build_index] called'
236
- warn "[UKIRYU DEBUG] current_path = #{current_path.inspect}"
237
- end
262
+ Logger.debug('ToolIndex#build_index called', category: :executable)
263
+ Logger.debug("current_path = #{current_path.inspect}", category: :executable)
238
264
 
239
265
  return unless current_path
240
266
 
@@ -328,15 +354,17 @@ module Ukiryu
328
354
 
329
355
  # Load YAML content for a specific tool
330
356
  #
357
+ # Version selection is based on the `version:` field inside YAML content,
358
+ # NOT the filename. This ensures the content is the source of truth.
359
+ #
331
360
  # @param tool_name [Symbol, String] the tool name
332
361
  # @return [String, nil] the YAML content
333
362
  def load_yaml_for_tool(tool_name)
334
363
  current_path = register_path
335
364
 
336
- if ENV['UKIRYU_DEBUG_EXECUTABLE']
337
- warn "[UKIRYU DEBUG ToolIndex#load_yaml_for_tool] tool_name=#{tool_name}"
338
- warn "[UKIRYU DEBUG] current_path = #{current_path.inspect}"
339
- end
365
+ Logger.debug("ToolIndex#load_yaml_for_tool tool_name=#{tool_name}",
366
+ category: :executable)
367
+ Logger.debug("current_path = #{current_path.inspect}", category: :executable)
340
368
 
341
369
  return nil unless current_path
342
370
 
@@ -354,19 +382,56 @@ module Ukiryu
354
382
  # Prefer 'default' variant if present
355
383
  next unless File.basename(variant_dir) == 'default'
356
384
 
357
- files = Dir.glob(File.join(variant_dir, '*.yaml')).sort
358
- return File.read(files.last) if files.any?
385
+ files = Dir.glob(File.join(variant_dir, '*.yaml'))
386
+ return select_latest_version_by_content(files) if files.any?
359
387
 
360
388
  # Fall back to any variant directory
361
- files = Dir.glob(File.join(variant_dir, '*.yaml')).sort
362
- return File.read(files.last) if files.any?
389
+ files = Dir.glob(File.join(variant_dir, '*.yaml'))
390
+ return select_latest_version_by_content(files) if files.any?
363
391
  end
364
392
 
365
393
  # Fallback: direct YAML files in tool directory (legacy structure)
366
- files = Dir.glob(File.join(tool_dir, '*.yaml')).sort
367
- files.last ? File.read(files.last) : nil
394
+ files = Dir.glob(File.join(tool_dir, '*.yaml'))
395
+ files.any? ? select_latest_version_by_content(files) : nil
368
396
  rescue StandardError
369
397
  nil
370
398
  end
399
+
400
+ # Select the latest version file by reading version from YAML CONTENT.
401
+ #
402
+ # This is the correct approach: version comes from the `version:` field
403
+ # inside the YAML file, NOT from the filename. The content is the
404
+ # source of truth.
405
+ #
406
+ # @param files [Array<String>] list of YAML file paths
407
+ # @return [String, nil] content of the file with the highest version
408
+ def select_latest_version_by_content(files)
409
+ return nil if files.empty?
410
+ return File.read(files.first) if files.size == 1
411
+
412
+ # Build a map of version (from content) => file content
413
+ versions_to_content = {}
414
+
415
+ files.each do |file|
416
+ content = File.read(file)
417
+ hash = YAML.safe_load(content, permitted_classes: [Symbol], aliases: true)
418
+ next unless hash
419
+
420
+ version_string = hash['version']
421
+ next unless version_string
422
+
423
+ version = Models::SemanticVersion.new(version_string)
424
+ versions_to_content[version] = content
425
+ rescue StandardError => e
426
+ # Skip files that can't be parsed
427
+ Logger.warn("Failed to parse version from #{file}: #{e.message}")
428
+ end
429
+
430
+ # Return content of the highest version
431
+ return nil if versions_to_content.empty?
432
+
433
+ latest_version = versions_to_content.keys.max
434
+ versions_to_content[latest_version]
435
+ end
371
436
  end
372
437
  end
@@ -259,7 +259,8 @@ module Ukiryu
259
259
  args = build_args(command_def, params)
260
260
 
261
261
  # Find executable using ExecutableFinder
262
- executable = Ukiryu::ExecutableFinder.find_executable(self.class.tool_definition.name.to_s, self.class.tool_definition)
262
+ executable = Ukiryu::ExecutableFinder.find_executable(self.class.tool_definition.name.to_s,
263
+ self.class.tool_definition)
263
264
 
264
265
  # Get shell
265
266
  shell_sym = Ukiryu::Runtime.instance.shell
@@ -425,7 +426,8 @@ module Ukiryu
425
426
 
426
427
  cmd = vd.command
427
428
 
428
- executable = Ukiryu::ExecutableFinder.find_executable(self.class.tool_definition.name.to_s, self.class.tool_definition)
429
+ executable = Ukiryu::ExecutableFinder.find_executable(self.class.tool_definition.name.to_s,
430
+ self.class.tool_definition)
429
431
  return nil unless executable
430
432
 
431
433
  shell_sym = Ukiryu::Runtime.instance.shell