ukiryu 0.1.1 → 0.1.3
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/.github/workflows/release.yml +58 -14
- data/.gitignore +3 -0
- data/.rubocop_todo.yml +170 -79
- data/Gemfile +1 -1
- data/README.adoc +1603 -576
- data/docs/.gitignore +1 -0
- data/docs/Gemfile +10 -0
- data/docs/INDEX.adoc +261 -0
- data/docs/_config.yml +180 -0
- data/docs/advanced/custom-tool-classes.adoc +581 -0
- data/docs/advanced/index.adoc +20 -0
- data/docs/features/configuration.adoc +657 -0
- data/docs/features/index.adoc +31 -0
- data/docs/features/platform-support.adoc +488 -0
- data/docs/getting-started/core-concepts.adoc +666 -0
- data/docs/getting-started/index.adoc +36 -0
- data/docs/getting-started/installation.adoc +216 -0
- data/docs/getting-started/quick-start.adoc +258 -0
- data/docs/guides/env-var-sets.adoc +388 -0
- data/docs/guides/index.adoc +20 -0
- data/docs/interfaces/cli.adoc +609 -0
- data/docs/interfaces/index.adoc +153 -0
- data/docs/interfaces/ruby-api.adoc +538 -0
- data/docs/lychee.toml +49 -0
- data/docs/reference/configuration-options.adoc +720 -0
- data/docs/reference/error-codes.adoc +634 -0
- data/docs/reference/index.adoc +20 -0
- data/docs/reference/ruby-api.adoc +1217 -0
- data/docs/understanding/index.adoc +20 -0
- data/lib/ukiryu/cli.rb +43 -58
- data/lib/ukiryu/cli_commands/base_command.rb +16 -27
- data/lib/ukiryu/cli_commands/cache_command.rb +100 -0
- data/lib/ukiryu/cli_commands/commands_command.rb +8 -8
- data/lib/ukiryu/cli_commands/commands_command.rb.fixed +1 -1
- data/lib/ukiryu/cli_commands/config_command.rb +49 -7
- data/lib/ukiryu/cli_commands/definitions_command.rb +254 -0
- data/lib/ukiryu/cli_commands/describe_command.rb +13 -7
- data/lib/ukiryu/cli_commands/describe_command.rb.fixed +1 -1
- data/lib/ukiryu/cli_commands/docs_command.rb +148 -0
- data/lib/ukiryu/cli_commands/exec_inline_command.rb.fixed +1 -1
- data/lib/ukiryu/cli_commands/extract_command.rb +2 -2
- data/lib/ukiryu/cli_commands/info_command.rb +7 -7
- data/lib/ukiryu/cli_commands/lint_command.rb +167 -0
- data/lib/ukiryu/cli_commands/list_command.rb +6 -6
- data/lib/ukiryu/cli_commands/opts_command.rb +2 -2
- data/lib/ukiryu/cli_commands/opts_command.rb.fixed +1 -1
- data/lib/ukiryu/cli_commands/register_command.rb +144 -0
- data/lib/ukiryu/cli_commands/resolve_command.rb +124 -0
- data/lib/ukiryu/cli_commands/run_command.rb +38 -14
- data/lib/ukiryu/cli_commands/run_file_command.rb +2 -2
- data/lib/ukiryu/cli_commands/system_command.rb +50 -32
- data/lib/ukiryu/cli_commands/validate_command.rb +452 -51
- data/lib/ukiryu/cli_commands/which_command.rb +5 -5
- data/lib/ukiryu/command_builder.rb +81 -23
- data/lib/ukiryu/config/env_provider.rb +3 -3
- data/lib/ukiryu/config/env_schema.rb +6 -6
- data/lib/ukiryu/config.rb +11 -11
- data/lib/ukiryu/definition/definition_cache.rb +238 -0
- data/lib/ukiryu/definition/definition_composer.rb +257 -0
- data/lib/ukiryu/definition/definition_linter.rb +460 -0
- data/lib/ukiryu/definition/definition_validator.rb +320 -0
- data/lib/ukiryu/definition/discovery.rb +239 -0
- data/lib/ukiryu/definition/documentation_generator.rb +429 -0
- data/lib/ukiryu/definition/lint_issue.rb +168 -0
- data/lib/ukiryu/definition/loader.rb +139 -0
- data/lib/ukiryu/definition/metadata.rb +159 -0
- data/lib/ukiryu/definition/source.rb +87 -0
- data/lib/ukiryu/definition/sources/file.rb +138 -0
- data/lib/ukiryu/definition/sources/string.rb +88 -0
- data/lib/ukiryu/definition/validation_result.rb +158 -0
- data/lib/ukiryu/definition/version_resolver.rb +194 -0
- data/lib/ukiryu/definition.rb +40 -0
- data/lib/ukiryu/errors.rb +6 -0
- data/lib/ukiryu/execution_context.rb +11 -11
- data/lib/ukiryu/executor.rb +6 -0
- data/lib/ukiryu/extractors/extractor.rb +6 -5
- data/lib/ukiryu/extractors/help_parser.rb +13 -19
- data/lib/ukiryu/logger.rb +3 -1
- data/lib/ukiryu/models/command_definition.rb +3 -3
- data/lib/ukiryu/models/command_info.rb +1 -1
- data/lib/ukiryu/models/components.rb +1 -3
- data/lib/ukiryu/models/env_var_definition.rb +11 -3
- data/lib/ukiryu/models/flag_definition.rb +15 -0
- data/lib/ukiryu/models/option_definition.rb +7 -7
- data/lib/ukiryu/models/platform_profile.rb +6 -3
- data/lib/ukiryu/models/routing.rb +1 -1
- data/lib/ukiryu/models/tool_definition.rb +2 -4
- data/lib/ukiryu/models/tool_metadata.rb +6 -6
- data/lib/ukiryu/models/validation_result.rb +1 -1
- data/lib/ukiryu/models/version_compatibility.rb +6 -3
- data/lib/ukiryu/models/version_detection.rb +10 -1
- data/lib/ukiryu/{registry.rb → register.rb} +54 -38
- data/lib/ukiryu/register_auto_manager.rb +268 -0
- data/lib/ukiryu/schema_validator.rb +31 -10
- data/lib/ukiryu/shell/base.rb +18 -0
- data/lib/ukiryu/shell/bash.rb +19 -1
- data/lib/ukiryu/shell/cmd.rb +11 -1
- data/lib/ukiryu/shell/powershell.rb +11 -1
- data/lib/ukiryu/shell.rb +1 -1
- data/lib/ukiryu/tool.rb +107 -95
- data/lib/ukiryu/tool_index.rb +22 -22
- data/lib/ukiryu/tools/base.rb +12 -25
- data/lib/ukiryu/tools/generator.rb +7 -7
- data/lib/ukiryu/tools.rb +3 -3
- data/lib/ukiryu/type.rb +20 -5
- data/lib/ukiryu/version.rb +1 -1
- data/lib/ukiryu/version_detector.rb +21 -2
- data/lib/ukiryu.rb +6 -3
- data/ukiryu-proposal.md +41 -41
- data/ukiryu.gemspec +1 -0
- metadata +64 -8
- data/.gitmodules +0 -3
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'validation_result'
|
|
4
|
+
require_relative 'loader'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require 'open-uri'
|
|
7
|
+
require 'net/http'
|
|
8
|
+
|
|
9
|
+
module Ukiryu
|
|
10
|
+
module Definition
|
|
11
|
+
# Validate tool definitions against JSON Schema
|
|
12
|
+
#
|
|
13
|
+
# This class provides validation functionality for tool definitions
|
|
14
|
+
# using JSON Schema. The json-schema gem is optional; if not available,
|
|
15
|
+
# basic structural validation is performed instead.
|
|
16
|
+
class DefinitionValidator
|
|
17
|
+
# Default schema version
|
|
18
|
+
DEFAULT_SCHEMA_VERSION = '1.0'
|
|
19
|
+
|
|
20
|
+
# Remote schema URL
|
|
21
|
+
REMOTE_SCHEMA_URL = 'https://raw.githubusercontent.com/ukiryu/schemas/refs/heads/main/v1/tool.schema.yaml'
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Check if JSON Schema validation is available
|
|
25
|
+
#
|
|
26
|
+
# @return [Boolean] true if json-schema gem is available
|
|
27
|
+
def schema_validation_available?
|
|
28
|
+
return @schema_validation_available if defined?(@schema_validation_available)
|
|
29
|
+
|
|
30
|
+
@schema_validation_available = begin
|
|
31
|
+
require 'json-schema'
|
|
32
|
+
true
|
|
33
|
+
rescue LoadError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Find schema file
|
|
39
|
+
#
|
|
40
|
+
# @param version [String] schema version
|
|
41
|
+
# @return [String, nil] path to schema file from UKIRYU_SCHEMA_PATH env var, or nil
|
|
42
|
+
def find_schema(_version = DEFAULT_SCHEMA_VERSION)
|
|
43
|
+
# Only check environment variable for local schema path
|
|
44
|
+
schema_path = ENV['UKIRYU_SCHEMA_PATH']
|
|
45
|
+
return schema_path if schema_path && File.exist?(schema_path)
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Load schema
|
|
51
|
+
#
|
|
52
|
+
# @param schema_path [String] path to schema file
|
|
53
|
+
# @return [Hash, nil] parsed schema, or nil if not available
|
|
54
|
+
def load_schema(schema_path)
|
|
55
|
+
return nil unless schema_path && File.exist?(schema_path)
|
|
56
|
+
|
|
57
|
+
case File.extname(schema_path)
|
|
58
|
+
when '.json'
|
|
59
|
+
JSON.parse(File.read(schema_path))
|
|
60
|
+
when '.yaml', '.yml'
|
|
61
|
+
YAML.safe_load(File.read(schema_path), permitted_classes: [Symbol])
|
|
62
|
+
end
|
|
63
|
+
rescue JSON::ParserError, Psych::SyntaxError, Errno::ENOENT
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Validate a definition hash
|
|
68
|
+
#
|
|
69
|
+
# @param definition [Hash] the definition to validate
|
|
70
|
+
# @param schema_path [String, nil] optional schema path
|
|
71
|
+
# @return [ValidationResult] validation result
|
|
72
|
+
def validate(definition, schema_path: nil)
|
|
73
|
+
errors = []
|
|
74
|
+
warnings = []
|
|
75
|
+
|
|
76
|
+
# Debug: Check which tool is being validated
|
|
77
|
+
if ENV['DEBUG_SCHEMA_VALIDATION']
|
|
78
|
+
tool_name = definition['name'] || definition[:name] || 'unknown'
|
|
79
|
+
puts "DEBUG: Validating tool: #{tool_name}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Basic structural validation (always available)
|
|
83
|
+
structural_result = validate_structure(definition)
|
|
84
|
+
errors.concat(structural_result[:errors])
|
|
85
|
+
warnings.concat(structural_result[:warnings])
|
|
86
|
+
|
|
87
|
+
# JSON Schema validation (if available)
|
|
88
|
+
if schema_validation_available?
|
|
89
|
+
schema = schema_path ? load_schema(schema_path) : find_and_load_schema
|
|
90
|
+
if schema
|
|
91
|
+
if ENV['DEBUG_SCHEMA_VALIDATION']
|
|
92
|
+
puts "DEBUG: Schema loaded, proceeding with JSON Schema validation"
|
|
93
|
+
end
|
|
94
|
+
schema_result = validate_against_schema(definition, schema)
|
|
95
|
+
errors.concat(schema_result[:errors])
|
|
96
|
+
warnings.concat(schema_result[:warnings])
|
|
97
|
+
end
|
|
98
|
+
# If schema file not found, silently skip JSON schema validation
|
|
99
|
+
# Structural validation is sufficient
|
|
100
|
+
else
|
|
101
|
+
warnings << 'json-schema gem not available, only structural validation performed'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if errors.empty?
|
|
105
|
+
warnings.empty? ? ValidationResult.success : ValidationResult.with_warnings(warnings)
|
|
106
|
+
else
|
|
107
|
+
ValidationResult.failure(errors, warnings)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Validate a definition file
|
|
112
|
+
#
|
|
113
|
+
# @param file_path [String] path to definition file
|
|
114
|
+
# @param schema_path [String, nil] optional schema path
|
|
115
|
+
# @return [ValidationResult] validation result
|
|
116
|
+
def validate_file(file_path, schema_path: nil)
|
|
117
|
+
# Load raw YAML hash for validation
|
|
118
|
+
definition = YAML.safe_load(File.read(file_path), permitted_classes: [Symbol, Date, Time])
|
|
119
|
+
validate(definition, schema_path: schema_path)
|
|
120
|
+
rescue Ukiryu::DefinitionNotFoundError
|
|
121
|
+
ValidationResult.failure(["File not found: #{file_path}"])
|
|
122
|
+
rescue Ukiryu::DefinitionLoadError, Ukiryu::DefinitionValidationError => e
|
|
123
|
+
ValidationResult.failure([e.message])
|
|
124
|
+
rescue Errno::ENOENT
|
|
125
|
+
ValidationResult.failure(["File not found: #{file_path}"])
|
|
126
|
+
rescue Psych::SyntaxError => e
|
|
127
|
+
ValidationResult.failure(["Invalid YAML: #{e.message}"])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Validate a YAML string
|
|
131
|
+
#
|
|
132
|
+
# @param yaml_string [String] YAML content
|
|
133
|
+
# @param schema_path [String, nil] optional schema path
|
|
134
|
+
# @return [ValidationResult] validation result
|
|
135
|
+
def validate_string(yaml_string, schema_path: nil)
|
|
136
|
+
definition = YAML.safe_load(yaml_string, permitted_classes: [Symbol, Date, Time])
|
|
137
|
+
validate(definition, schema_path: schema_path)
|
|
138
|
+
rescue Psych::SyntaxError => e
|
|
139
|
+
ValidationResult.failure(["Invalid YAML: #{e.message}"])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Find and load schema
|
|
145
|
+
#
|
|
146
|
+
# @return [Hash, nil] schema hash or nil
|
|
147
|
+
def find_and_load_schema
|
|
148
|
+
# Try local schema first
|
|
149
|
+
schema_path = find_schema
|
|
150
|
+
schema = load_schema(schema_path) if schema_path
|
|
151
|
+
return schema if schema
|
|
152
|
+
|
|
153
|
+
# Fallback to remote schema
|
|
154
|
+
download_and_load_schema
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Download schema from remote URL
|
|
158
|
+
#
|
|
159
|
+
# @return [Hash, nil] schema hash or nil
|
|
160
|
+
def download_and_load_schema
|
|
161
|
+
YAML.safe_load(URI.open(REMOTE_SCHEMA_URL).read, permitted_classes: [Symbol])
|
|
162
|
+
rescue OpenURI::HTTPError, SocketError, Timeout::Error, Psych::SyntaxError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Validate basic structure
|
|
167
|
+
#
|
|
168
|
+
# @param definition [Hash] the definition
|
|
169
|
+
# @return [Hash] errors and warnings
|
|
170
|
+
def validate_structure(definition)
|
|
171
|
+
errors = []
|
|
172
|
+
warnings = []
|
|
173
|
+
|
|
174
|
+
# Check if definition is a hash
|
|
175
|
+
return { errors: ['Definition must be a hash/object'], warnings: [] } unless definition.is_a?(Hash)
|
|
176
|
+
|
|
177
|
+
# Check name format (NOT validated by schema)
|
|
178
|
+
name = definition[:name] || definition['name']
|
|
179
|
+
if name
|
|
180
|
+
name_str = name.to_s
|
|
181
|
+
errors << 'Tool name must not contain whitespace' if name_str =~ /\s/
|
|
182
|
+
warnings << 'Tool name starting with number may cause issues' if name_str =~ /^[0-9]/
|
|
183
|
+
warnings << 'Tool name should contain only lowercase letters, numbers, hyphens, and underscores' if name_str !~ /^[a-z0-9_-]+$/
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Check schema version format (NOT validated by schema)
|
|
187
|
+
has_schema_version = definition.key?(:ukiryu_schema) || definition.key?('ukiryu_schema')
|
|
188
|
+
if has_schema_version
|
|
189
|
+
schema_version = definition[:ukiryu_schema] || definition['ukiryu_schema']
|
|
190
|
+
schema_version_str = schema_version.to_s
|
|
191
|
+
warnings << "Invalid schema version format: #{schema_version} (expected format: '1.0')" unless schema_version_str =~ /^\d+\.\d+$/
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
{ errors: errors, warnings: warnings }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Validate a profile
|
|
198
|
+
#
|
|
199
|
+
# @param profile [Hash] the profile
|
|
200
|
+
# @param index [Integer] profile index
|
|
201
|
+
# @return [Hash] errors and warnings
|
|
202
|
+
def validate_profile(profile, index)
|
|
203
|
+
errors = []
|
|
204
|
+
warnings = []
|
|
205
|
+
|
|
206
|
+
return { errors: ["Profile #{index} must be a hash/object"], warnings: [] } unless profile.is_a?(Hash)
|
|
207
|
+
|
|
208
|
+
# All profile structural validation is already done by JSON Schema
|
|
209
|
+
# (required fields, types, enum values, etc.)
|
|
210
|
+
|
|
211
|
+
{ errors: errors, warnings: warnings }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Validate against JSON Schema
|
|
215
|
+
#
|
|
216
|
+
# @param definition [Hash] the definition
|
|
217
|
+
# @param schema [Hash] JSON Schema
|
|
218
|
+
# @return [Hash] errors and warnings
|
|
219
|
+
def validate_against_schema(definition, schema)
|
|
220
|
+
errors = []
|
|
221
|
+
warnings = []
|
|
222
|
+
|
|
223
|
+
begin
|
|
224
|
+
# Debug: Check data BEFORE stringify_keys
|
|
225
|
+
if ENV['DEBUG_SCHEMA_VALIDATION']
|
|
226
|
+
tool_name = definition['name'] || definition[:name] || 'unknown'
|
|
227
|
+
puts "DEBUG: validate_against_schema for tool: #{tool_name}"
|
|
228
|
+
|
|
229
|
+
# Check flags[0] before stringify
|
|
230
|
+
if definition['profiles'] && definition['profiles'][0] &&
|
|
231
|
+
definition['profiles'][0]['commands'] &&
|
|
232
|
+
definition['profiles'][0]['commands'][0] &&
|
|
233
|
+
definition['profiles'][0]['commands'][0]['flags'] &&
|
|
234
|
+
definition['profiles'][0]['commands'][0]['flags'][0]
|
|
235
|
+
flg0_before = definition['profiles'][0]['commands'][0]['flags'][0]
|
|
236
|
+
puts "DEBUG: Flag 0 BEFORE stringify: #{flg0_before.inspect}"
|
|
237
|
+
puts "DEBUG: Flag 0 name BEFORE stringify: #{flg0_before['name'].inspect}"
|
|
238
|
+
puts "DEBUG: Flag 0 name class BEFORE stringify: #{flg0_before['name'].class}"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Convert symbol keys to strings for JSON Schema validation
|
|
243
|
+
stringified = stringify_keys(definition)
|
|
244
|
+
# Debug: Check if keys are strings after stringify_keys
|
|
245
|
+
if ENV['DEBUG_SCHEMA_VALIDATION']
|
|
246
|
+
puts "DEBUG: After stringify_keys, checking keys..."
|
|
247
|
+
puts "DEBUG: Top-level keys: #{stringified.keys.inspect}"
|
|
248
|
+
puts "DEBUG: Top-level key classes: #{stringified.keys.map(&:class).inspect}"
|
|
249
|
+
|
|
250
|
+
# Check options[0] and flags[0] if they exist
|
|
251
|
+
if stringified['profiles'] && stringified['profiles'][0] &&
|
|
252
|
+
stringified['profiles'][0]['commands'] &&
|
|
253
|
+
stringified['profiles'][0]['commands'][0]
|
|
254
|
+
cmd = stringified['profiles'][0]['commands'][0]
|
|
255
|
+
|
|
256
|
+
# Check options[0]
|
|
257
|
+
if cmd['options'] && cmd['options'][0]
|
|
258
|
+
opt0 = cmd['options'][0]
|
|
259
|
+
puts "DEBUG: Option 0 after stringify: #{opt0.inspect}"
|
|
260
|
+
puts "DEBUG: Option 0 keys: #{opt0.keys.inspect}"
|
|
261
|
+
puts "DEBUG: Option 0 name: #{opt0['name'].inspect}"
|
|
262
|
+
puts "DEBUG: Option 0 name class: #{opt0['name'].class}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Check flags[0]
|
|
266
|
+
if cmd['flags'] && cmd['flags'][0]
|
|
267
|
+
flg0 = cmd['flags'][0]
|
|
268
|
+
puts "DEBUG: Flag 0 after stringify: #{flg0.inspect}"
|
|
269
|
+
puts "DEBUG: Flag 0 keys: #{flg0.keys.inspect}"
|
|
270
|
+
puts "DEBUG: Flag 0 name: #{flg0['name'].inspect}"
|
|
271
|
+
puts "DEBUG: Flag 0 name class: #{flg0['name'].class}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Also check options[26] for grep
|
|
275
|
+
if cmd['options'] && cmd['options'][26]
|
|
276
|
+
opt26 = cmd['options'][26]
|
|
277
|
+
puts "DEBUG: Option 26 after stringify: #{opt26.inspect}"
|
|
278
|
+
puts "DEBUG: Option 26 name: #{opt26['name'].inspect}"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
puts "DEBUG: JSON::Schema version: #{JSON::Schema::VERSION rescue 'unknown'}"
|
|
282
|
+
end
|
|
283
|
+
validation = JSON::Validator.fully_validate(schema, stringified, errors_as_objects: true)
|
|
284
|
+
|
|
285
|
+
validation.each do |error|
|
|
286
|
+
if error[:type] == 'unknown'
|
|
287
|
+
warnings << error[:message]
|
|
288
|
+
else
|
|
289
|
+
errors << "#{error[:message]} (at #{error[:fragment]})"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
rescue JSON::Schema::ValidationError => e
|
|
293
|
+
errors << "Schema validation error: #{e.message}"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
{ errors: errors, warnings: warnings }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Convert symbol keys to strings
|
|
300
|
+
#
|
|
301
|
+
# @param hash [Hash] the hash
|
|
302
|
+
# @return [Hash] hash with string keys
|
|
303
|
+
def stringify_keys(hash)
|
|
304
|
+
return hash unless hash.is_a?(Hash)
|
|
305
|
+
|
|
306
|
+
hash.transform_keys(&:to_s).transform_values do |v|
|
|
307
|
+
case v
|
|
308
|
+
when Hash
|
|
309
|
+
stringify_keys(v)
|
|
310
|
+
when Array
|
|
311
|
+
v.map { |item| item.is_a?(Hash) ? stringify_keys(item) : item }
|
|
312
|
+
else
|
|
313
|
+
v
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'metadata'
|
|
4
|
+
require_relative 'loader'
|
|
5
|
+
|
|
6
|
+
module Ukiryu
|
|
7
|
+
module Definition
|
|
8
|
+
# Discover tool definitions in standard filesystem locations
|
|
9
|
+
#
|
|
10
|
+
# This class searches for tool definitions in XDG-compliant paths
|
|
11
|
+
# and tool-bundled locations.
|
|
12
|
+
class Discovery
|
|
13
|
+
# User data directory (XDG_DATA_HOME or ~/.local/share)
|
|
14
|
+
#
|
|
15
|
+
# @return [String] user data directory
|
|
16
|
+
def self.xdg_data_home
|
|
17
|
+
ENV.fetch('XDG_DATA_HOME', File.expand_path('~/.local/share'))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# System data directories (XDG_DATA_DIRS or /usr/local/share:/usr/share)
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<String>] system data directories
|
|
23
|
+
def self.xdg_data_dirs
|
|
24
|
+
if ENV.key?('XDG_DATA_DIRS')
|
|
25
|
+
ENV.fetch('XDG_DATA_DIRS', '').split(':').map(&:strip).reject(&:empty?)
|
|
26
|
+
else
|
|
27
|
+
['/usr/local/share', '/usr/share']
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get the user definitions directory
|
|
32
|
+
#
|
|
33
|
+
# @return [String] path to user definitions directory
|
|
34
|
+
def self.user_definitions_directory
|
|
35
|
+
File.join(xdg_data_home, 'ukiryu', 'definitions')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get all search paths for definitions
|
|
39
|
+
#
|
|
40
|
+
# Returns paths in priority order (highest priority first):
|
|
41
|
+
# 1. User definitions
|
|
42
|
+
# 2. Tool-bundled paths (dynamically discovered)
|
|
43
|
+
# 3. Local system definitions
|
|
44
|
+
# 4. System definitions
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<String>] search paths
|
|
47
|
+
def self.search_paths
|
|
48
|
+
paths = []
|
|
49
|
+
|
|
50
|
+
# 1. User definitions (highest priority after explicit flag)
|
|
51
|
+
paths << user_definitions_directory
|
|
52
|
+
|
|
53
|
+
# 2. Tool-bundled paths (dynamically discovered)
|
|
54
|
+
paths.concat(tool_bundled_paths)
|
|
55
|
+
|
|
56
|
+
# 3. Local system definitions
|
|
57
|
+
xdg_data_dirs.each do |dir|
|
|
58
|
+
paths << File.join(dir, 'ukiryu', 'definitions')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 4. System definitions
|
|
62
|
+
paths << File.join(xdg_data_home, 'ukiryu', 'definitions')
|
|
63
|
+
|
|
64
|
+
paths.uniq
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get tool-bundled definition paths
|
|
68
|
+
#
|
|
69
|
+
# Searches for tool installations and checks for bundled definitions
|
|
70
|
+
#
|
|
71
|
+
# @return [Array<String>] tool-bundled paths
|
|
72
|
+
def self.tool_bundled_paths
|
|
73
|
+
paths = []
|
|
74
|
+
|
|
75
|
+
# Check PATH for tool installations
|
|
76
|
+
ENV.fetch('PATH', '').split(':').each do |bin_dir|
|
|
77
|
+
next unless File.directory?(bin_dir)
|
|
78
|
+
|
|
79
|
+
# Check for ukiryu subdirectory alongside bin
|
|
80
|
+
parent_dir = File.dirname(bin_dir)
|
|
81
|
+
ukiryu_dir = File.join(parent_dir, 'ukiryu')
|
|
82
|
+
paths << ukiryu_dir if File.directory?(ukiryu_dir)
|
|
83
|
+
|
|
84
|
+
# Check for share/ukiryu subdirectory
|
|
85
|
+
share_dir = File.join(parent_dir, 'share', 'ukiryu')
|
|
86
|
+
paths << share_dir if File.directory?(share_dir)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check /opt directory structure
|
|
90
|
+
opt_paths = Dir.glob('/opt/*/ukiryu').select { |d| File.directory?(d) }
|
|
91
|
+
paths.concat(opt_paths)
|
|
92
|
+
|
|
93
|
+
paths.uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Discover all available definitions
|
|
97
|
+
#
|
|
98
|
+
# Searches all paths for tool definitions and returns metadata
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash<String, Array<DefinitionMetadata>>] hash of tool names to metadata
|
|
101
|
+
def self.discover
|
|
102
|
+
definitions = Hash.new { |h, k| h[k] = [] }
|
|
103
|
+
|
|
104
|
+
search_paths.each do |search_path|
|
|
105
|
+
next unless File.directory?(search_path)
|
|
106
|
+
|
|
107
|
+
discover_in_path(search_path).each do |metadata|
|
|
108
|
+
definitions[metadata.name] << metadata
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Sort each tool's definitions by priority
|
|
113
|
+
definitions.each_value(&:sort!)
|
|
114
|
+
|
|
115
|
+
definitions
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Find the best definition for a tool
|
|
119
|
+
#
|
|
120
|
+
# @param tool_name [String] the tool name
|
|
121
|
+
# @param version [String, nil] optional version constraint
|
|
122
|
+
# @return [DefinitionMetadata, nil] the best matching definition, or nil
|
|
123
|
+
def self.find(tool_name, version = nil)
|
|
124
|
+
definitions = discover[tool_name]
|
|
125
|
+
return nil if definitions.nil? || definitions.empty?
|
|
126
|
+
|
|
127
|
+
if version
|
|
128
|
+
# Find exact version match
|
|
129
|
+
definitions.find { |d| d.version == version }
|
|
130
|
+
else
|
|
131
|
+
# Return highest priority definition
|
|
132
|
+
definitions.first
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# List all discovered tool names
|
|
137
|
+
#
|
|
138
|
+
# @return [Array<String>] tool names
|
|
139
|
+
def self.available_tools
|
|
140
|
+
discover.keys.sort
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Get all definitions for a specific tool
|
|
144
|
+
#
|
|
145
|
+
# @param tool_name [String] the tool name
|
|
146
|
+
# @return [Array<DefinitionMetadata>] array of definitions
|
|
147
|
+
def self.definitions_for(tool_name)
|
|
148
|
+
discover[tool_name] || []
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Discover definitions in a specific path
|
|
152
|
+
#
|
|
153
|
+
# @param search_path [String] the path to search
|
|
154
|
+
# @return [Array<DefinitionMetadata>] array of discovered metadata
|
|
155
|
+
def self.discover_in_path(search_path)
|
|
156
|
+
definitions = []
|
|
157
|
+
|
|
158
|
+
# Determine source type based on path
|
|
159
|
+
source_type = determine_source_type(search_path)
|
|
160
|
+
|
|
161
|
+
# Look for tool subdirectories (structure: {tool}/{version}.yaml)
|
|
162
|
+
if File.directory?(search_path)
|
|
163
|
+
Dir.foreach(search_path) do |tool_name|
|
|
164
|
+
next if tool_name.start_with?('.')
|
|
165
|
+
|
|
166
|
+
tool_dir = File.join(search_path, tool_name)
|
|
167
|
+
next unless File.directory?(tool_dir)
|
|
168
|
+
|
|
169
|
+
# Find YAML files in tool directory
|
|
170
|
+
Dir.glob(File.join(tool_dir, '*.yaml')).each do |yaml_file|
|
|
171
|
+
metadata = metadata_from_file(yaml_file, source_type)
|
|
172
|
+
definitions << metadata if metadata
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
# Skip invalid files silently
|
|
175
|
+
warn "[Ukiryu] Skipping invalid definition #{yaml_file}: #{e.message}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Also check for flat YAML files (structure: {tool}-{version}.yaml or {version}.yaml)
|
|
181
|
+
Dir.glob(File.join(search_path, '*-*.yaml')).each do |yaml_file|
|
|
182
|
+
metadata = metadata_from_file(yaml_file, source_type)
|
|
183
|
+
definitions << metadata if metadata
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
# Skip invalid files silently
|
|
186
|
+
warn "[Ukiryu] Skipping invalid definition #{yaml_file}: #{e.message}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
definitions
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Determine the source type for a search path
|
|
193
|
+
#
|
|
194
|
+
# @param path [String] the search path
|
|
195
|
+
# @return [Symbol] source type
|
|
196
|
+
def self.determine_source_type(path)
|
|
197
|
+
expanded = File.expand_path(path)
|
|
198
|
+
|
|
199
|
+
if expanded.include?(xdg_data_home)
|
|
200
|
+
:user
|
|
201
|
+
elsif expanded.include?('/opt/')
|
|
202
|
+
:bundled
|
|
203
|
+
elsif expanded.include?('/usr/local/share')
|
|
204
|
+
:local_system
|
|
205
|
+
elsif expanded.include?('/usr/share')
|
|
206
|
+
:system
|
|
207
|
+
else
|
|
208
|
+
:bundled # Default to bundled for unknown paths
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Create metadata from a YAML file
|
|
213
|
+
#
|
|
214
|
+
# @param yaml_file [String] path to the YAML file
|
|
215
|
+
# @param source_type [Symbol] source type
|
|
216
|
+
# @return [DefinitionMetadata, nil] metadata or nil if invalid
|
|
217
|
+
def self.metadata_from_file(yaml_file, source_type)
|
|
218
|
+
# Try to load just the name and version from YAML
|
|
219
|
+
yaml_content = File.read(yaml_file)
|
|
220
|
+
data = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
|
|
221
|
+
|
|
222
|
+
return nil unless data.is_a?(Hash)
|
|
223
|
+
return nil unless data['name']
|
|
224
|
+
|
|
225
|
+
name = data['name']
|
|
226
|
+
version = data['version'] || '1.0' # Default version
|
|
227
|
+
|
|
228
|
+
DefinitionMetadata.new(
|
|
229
|
+
name: name,
|
|
230
|
+
version: version,
|
|
231
|
+
path: yaml_file,
|
|
232
|
+
source_type: source_type
|
|
233
|
+
)
|
|
234
|
+
rescue Psych::SyntaxError, StandardError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|