purl 0.1.0 → 1.0.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.
data/Rakefile CHANGED
@@ -6,3 +6,388 @@ require "minitest/test_task"
6
6
  Minitest::TestTask.create
7
7
 
8
8
  task default: :test
9
+
10
+ namespace :spec do
11
+ desc "Show available PURL specification tasks"
12
+ task :help do
13
+ puts "šŸ”§ PURL Specification Tasks"
14
+ puts "=" * 30
15
+ puts "rake spec:update - Fetch latest test cases from official PURL spec repository"
16
+ puts "rake spec:stats - Show statistics about current test suite data"
17
+ puts "rake spec:compliance - Run all compliance tests against the official test suite"
18
+ puts "rake spec:debug - Show detailed info about failing test cases"
19
+ puts "rake spec:types - Show information about all PURL types and their support"
20
+ puts "rake spec:verify_types - Verify our types list against the official specification"
21
+ puts "rake spec:help - Show this help message"
22
+ puts
23
+ puts "Example workflow:"
24
+ puts " 1. rake spec:update # Get latest test cases"
25
+ puts " 2. rake spec:stats # Review test suite composition"
26
+ puts " 3. rake spec:compliance # Run compliance tests"
27
+ puts " 4. rake spec:debug # Debug any failures"
28
+ puts
29
+ puts "The test suite data is stored in test-suite-data.json at the project root."
30
+ end
31
+
32
+ desc "Import/update official PURL specification test cases"
33
+ task :update do
34
+ require "net/http"
35
+ require "uri"
36
+ require "json"
37
+ require "fileutils"
38
+
39
+ puts "Fetching official PURL specification test cases..."
40
+
41
+ # URL for the official test suite data
42
+ url = "https://raw.githubusercontent.com/package-url/purl-spec/master/test-suite-data.json"
43
+ test_file_path = File.join(__dir__, "test-suite-data.json")
44
+ backup_path = "#{test_file_path}.backup"
45
+
46
+ begin
47
+ # Create backup of existing file if it exists
48
+ if File.exist?(test_file_path)
49
+ puts "Creating backup of existing test file..."
50
+ FileUtils.cp(test_file_path, backup_path)
51
+ end
52
+
53
+ # Fetch the latest test data
54
+ uri = URI(url)
55
+ response = Net::HTTP.get_response(uri)
56
+
57
+ if response.code == "200"
58
+ # Validate that we got valid JSON
59
+ test_data = JSON.parse(response.body)
60
+
61
+ # Write the new test data
62
+ File.write(test_file_path, response.body)
63
+
64
+ puts "āœ… Successfully updated test suite data!"
65
+ puts " - Test cases: #{test_data.length}"
66
+ puts " - File: #{test_file_path}"
67
+
68
+ # Remove backup if update was successful
69
+ File.delete(backup_path) if File.exist?(backup_path)
70
+
71
+ # Show summary of test case types
72
+ types = test_data.group_by { |tc| tc["type"] || "unknown" }.transform_values(&:count)
73
+ puts "\nšŸ“Š Test case distribution by package type:"
74
+ types.sort_by { |type, _| type.to_s }.each do |type, count|
75
+ puts " #{type}: #{count} cases"
76
+ end
77
+
78
+ # Show invalid vs valid cases
79
+ invalid_count = test_data.count { |tc| tc["is_invalid"] }
80
+ valid_count = test_data.count { |tc| !tc["is_invalid"] }
81
+ puts "\nšŸ“‹ Test case categories:"
82
+ puts " Valid cases: #{valid_count}"
83
+ puts " Invalid cases: #{invalid_count}"
84
+
85
+ else
86
+ raise "HTTP request failed with status #{response.code}: #{response.message}"
87
+ end
88
+
89
+ rescue => e
90
+ puts "āŒ Failed to update test suite data: #{e.message}"
91
+
92
+ # Restore backup if update failed
93
+ if File.exist?(backup_path)
94
+ puts "Restoring backup..."
95
+ FileUtils.mv(backup_path, test_file_path)
96
+ end
97
+
98
+ exit 1
99
+ end
100
+ end
101
+
102
+ desc "Show current test suite statistics"
103
+ task :stats do
104
+ require "json"
105
+
106
+ test_file_path = File.join(__dir__, "test-suite-data.json")
107
+
108
+ unless File.exist?(test_file_path)
109
+ puts "āŒ Test suite data file not found. Run 'rake spec:update' first."
110
+ exit 1
111
+ end
112
+
113
+ begin
114
+ test_data = JSON.parse(File.read(test_file_path))
115
+
116
+ puts "šŸ“Š PURL Test Suite Statistics"
117
+ puts "=" * 40
118
+ puts "Total test cases: #{test_data.length}"
119
+ puts "File location: #{test_file_path}"
120
+ puts "File size: #{File.size(test_file_path)} bytes"
121
+ puts "Last modified: #{File.mtime(test_file_path)}"
122
+
123
+ # Distribution by package type
124
+ puts "\nšŸ“¦ Distribution by package type:"
125
+ types = test_data.group_by { |tc| tc["type"] || "unknown" }.transform_values(&:count)
126
+ types.sort_by { |_, count| -count }.each do |type, count|
127
+ percentage = (count.to_f / test_data.length * 100).round(1)
128
+ puts " #{type.to_s.ljust(12)} #{count.to_s.rjust(3)} cases (#{percentage}%)"
129
+ end
130
+
131
+ # Valid vs invalid cases
132
+ invalid_count = test_data.count { |tc| tc["is_invalid"] }
133
+ valid_count = test_data.count { |tc| !tc["is_invalid"] }
134
+ puts "\nāœ… Test case validity:"
135
+ puts " Valid cases: #{valid_count} (#{(valid_count.to_f / test_data.length * 100).round(1)}%)"
136
+ puts " Invalid cases: #{invalid_count} (#{(invalid_count.to_f / test_data.length * 100).round(1)}%)"
137
+
138
+ # Cases with different components
139
+ has_namespace = test_data.count { |tc| tc["namespace"] }
140
+ has_version = test_data.count { |tc| tc["version"] }
141
+ has_qualifiers = test_data.count { |tc| tc["qualifiers"] && !tc["qualifiers"].empty? }
142
+ has_subpath = test_data.count { |tc| tc["subpath"] }
143
+
144
+ puts "\nšŸ”§ Component usage:"
145
+ puts " With namespace: #{has_namespace} cases"
146
+ puts " With version: #{has_version} cases"
147
+ puts " With qualifiers: #{has_qualifiers} cases"
148
+ puts " With subpath: #{has_subpath} cases"
149
+
150
+ rescue JSON::ParserError => e
151
+ puts "āŒ Failed to parse test suite data: #{e.message}"
152
+ exit 1
153
+ rescue => e
154
+ puts "āŒ Error reading test suite data: #{e.message}"
155
+ exit 1
156
+ end
157
+ end
158
+
159
+ desc "Run compliance tests against the official test suite"
160
+ task :compliance do
161
+ puts "Running PURL specification compliance tests..."
162
+ system("ruby test/test_purl_spec_compliance.rb")
163
+ end
164
+
165
+ desc "Verify our PURL types against the official specification"
166
+ task :verify_types do
167
+ require "net/http"
168
+ require "uri"
169
+ require_relative "lib/purl"
170
+
171
+ puts "šŸ” Verifying PURL Types Against Official Specification"
172
+ puts "=" * 60
173
+
174
+ begin
175
+ # Fetch the official PURL-TYPES.rst file
176
+ url = "https://raw.githubusercontent.com/package-url/purl-spec/main/PURL-TYPES.rst"
177
+ uri = URI(url)
178
+ response = Net::HTTP.get_response(uri)
179
+
180
+ if response.code != "200"
181
+ puts "āŒ Failed to fetch official specification: HTTP #{response.code}"
182
+ exit 1
183
+ end
184
+
185
+ content = response.body
186
+
187
+ # Extract type names from the specification
188
+ # Look for lines like "**alpm**" or lines that start type definitions
189
+ official_types = []
190
+ content.scan(/^\*\*(\w+)\*\*/) do |match|
191
+ official_types << match[0].downcase
192
+ end
193
+
194
+ # Also look for types in different format patterns (but be more restrictive)
195
+ content.scan(/^(\w+)\s*$/) do |match|
196
+ type = match[0].downcase
197
+ # Filter out common words that aren't types and document sections
198
+ unless %w[types purl package url specification license abstract].include?(type)
199
+ official_types << type if type.length > 2 && type != "license"
200
+ end
201
+ end
202
+
203
+ official_types = official_types.uniq.sort
204
+ our_types = Purl.known_types.sort
205
+
206
+ puts "šŸ“Š Comparison Results:"
207
+ puts " Official specification: #{official_types.length} types"
208
+ puts " Our implementation: #{our_types.length} types"
209
+
210
+ # Find missing types
211
+ missing_from_ours = official_types - our_types
212
+ extra_in_ours = our_types - official_types
213
+
214
+ if missing_from_ours.empty? && extra_in_ours.empty?
215
+ puts "\nāœ… Perfect match! All types are synchronized."
216
+ else
217
+ if missing_from_ours.any?
218
+ puts "\nāŒ Types in specification but missing from our list:"
219
+ missing_from_ours.each { |type| puts " - #{type}" }
220
+ end
221
+
222
+ if extra_in_ours.any?
223
+ puts "\nāš ļø Types in our list but not found in specification:"
224
+ extra_in_ours.each { |type| puts " + #{type}" }
225
+ end
226
+ end
227
+
228
+ puts "\nšŸ“‹ All Official Types Found:"
229
+ official_types.each_with_index do |type, index|
230
+ status = our_types.include?(type) ? "āœ“" : "āœ—"
231
+ puts " #{status} #{(index + 1).to_s.rjust(2)}. #{type}"
232
+ end
233
+
234
+ rescue => e
235
+ puts "āŒ Error verifying types: #{e.message}"
236
+ exit 1
237
+ end
238
+ end
239
+
240
+ desc "Show information about PURL types"
241
+ task :types do
242
+ require_relative "lib/purl"
243
+
244
+ puts "šŸ” PURL Type Information"
245
+ puts "=" * 40
246
+
247
+ puts "\nšŸ“‹ All Known PURL Types (#{Purl.known_types.length}):"
248
+ Purl.known_types.each_slice(4) do |slice|
249
+ puts " #{slice.map { |t| t.ljust(12) }.join(" ")}"
250
+ end
251
+
252
+ puts "\n🌐 Registry URL Generation Support (#{Purl.registry_supported_types.length}):"
253
+ Purl.registry_supported_types.each_slice(4) do |slice|
254
+ puts " #{slice.map { |t| t.ljust(12) }.join(" ")}"
255
+ end
256
+
257
+ puts "\nšŸ”„ Reverse Parsing Support (#{Purl.reverse_parsing_supported_types.length}):"
258
+ Purl.reverse_parsing_supported_types.each_slice(4) do |slice|
259
+ puts " #{slice.map { |t| t.ljust(12) }.join(" ")}"
260
+ end
261
+
262
+ puts "\nšŸ“Š Type Support Matrix:"
263
+ puts " Type Known Registry Reverse"
264
+ puts " " + "-" * 35
265
+
266
+ all_types = (Purl.known_types + Purl.registry_supported_types).uniq.sort
267
+ all_types.each do |type|
268
+ info = Purl.type_info(type)
269
+ known_mark = info[:known] ? "āœ“" : "āœ—"
270
+ registry_mark = info[:registry_url_generation] ? "āœ“" : "āœ—"
271
+ reverse_mark = info[:reverse_parsing] ? "āœ“" : "āœ—"
272
+
273
+ puts " #{type.ljust(12)} #{known_mark.center(5)} #{registry_mark.center(9)} #{reverse_mark.center(7)}"
274
+ end
275
+
276
+ puts "\nšŸ›¤ Route Patterns Examples:"
277
+ ["gem", "npm", "maven"].each do |type|
278
+ patterns = Purl::RegistryURL.route_patterns_for(type)
279
+ if patterns.any?
280
+ puts "\n #{type.upcase}:"
281
+ patterns.each { |pattern| puts " #{pattern}" }
282
+ end
283
+ end
284
+ end
285
+
286
+ desc "Show failing test cases for debugging"
287
+ task :debug do
288
+ require "json"
289
+ require_relative "lib/purl"
290
+
291
+ test_file_path = File.join(__dir__, "test-suite-data.json")
292
+
293
+ unless File.exist?(test_file_path)
294
+ puts "āŒ Test suite data file not found. Run 'rake spec:update' first."
295
+ exit 1
296
+ end
297
+
298
+ test_data = JSON.parse(File.read(test_file_path))
299
+
300
+ puts "šŸ” Debugging failing test cases..."
301
+ puts "=" * 50
302
+
303
+ failed_cases = []
304
+
305
+ test_data.each_with_index do |test_case, index|
306
+ description = test_case["description"]
307
+ purl_string = test_case["purl"]
308
+ is_invalid = test_case["is_invalid"]
309
+
310
+ begin
311
+ if is_invalid
312
+ begin
313
+ Purl::PackageURL.parse(purl_string)
314
+ failed_cases << {
315
+ index: index + 1,
316
+ description: description,
317
+ purl: purl_string,
318
+ error: "Expected parsing to fail but it succeeded",
319
+ type: "validation"
320
+ }
321
+ rescue Purl::Error
322
+ # Correctly failed - this is expected
323
+ end
324
+ else
325
+ purl = Purl::PackageURL.parse(purl_string)
326
+
327
+ # Check if canonical form matches expected
328
+ expected_canonical = test_case["canonical_purl"]
329
+ if expected_canonical && purl.to_s != expected_canonical
330
+ failed_cases << {
331
+ index: index + 1,
332
+ description: description,
333
+ purl: purl_string,
334
+ error: "Canonical mismatch: expected '#{expected_canonical}', got '#{purl.to_s}'",
335
+ type: "canonical"
336
+ }
337
+ end
338
+
339
+ # Check component mismatches
340
+ %w[type namespace name version qualifiers subpath].each do |component|
341
+ expected = test_case[component]
342
+ actual = purl.send(component)
343
+
344
+ if expected != actual
345
+ failed_cases << {
346
+ index: index + 1,
347
+ description: description,
348
+ purl: purl_string,
349
+ error: "#{component.capitalize} mismatch: expected #{expected.inspect}, got #{actual.inspect}",
350
+ type: "component"
351
+ }
352
+ break # Only report first mismatch per test case
353
+ end
354
+ end
355
+ end
356
+ rescue => e
357
+ failed_cases << {
358
+ index: index + 1,
359
+ description: description,
360
+ purl: purl_string,
361
+ error: "#{e.class}: #{e.message}",
362
+ type: "exception"
363
+ }
364
+ end
365
+ end
366
+
367
+ if failed_cases.empty?
368
+ puts "šŸŽ‰ All test cases are passing!"
369
+ else
370
+ puts "āŒ Found #{failed_cases.length} failing test cases:\n"
371
+
372
+ # Group by failure type
373
+ failed_cases.group_by { |fc| fc[:type] }.each do |failure_type, cases|
374
+ puts "#{failure_type.upcase} FAILURES (#{cases.length}):"
375
+ puts "-" * 30
376
+
377
+ cases.first(5).each do |failed_case| # Show first 5 of each type
378
+ puts "Case #{failed_case[:index]}: #{failed_case[:description]}"
379
+ puts " PURL: #{failed_case[:purl]}"
380
+ puts " Error: #{failed_case[:error]}"
381
+ puts
382
+ end
383
+
384
+ if cases.length > 5
385
+ puts " ... and #{cases.length - 5} more #{failure_type} failures\n"
386
+ end
387
+ end
388
+
389
+ success_rate = ((test_data.length - failed_cases.length).to_f / test_data.length * 100).round(1)
390
+ puts "Overall success rate: #{success_rate}% (#{test_data.length - failed_cases.length}/#{test_data.length})"
391
+ end
392
+ end
393
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purl
4
+ # Base error class for all Purl-related errors
5
+ class Error < StandardError; end
6
+
7
+ # Validation errors for PURL components
8
+ class ValidationError < Error
9
+ attr_reader :component, :value, :rule
10
+
11
+ def initialize(message, component: nil, value: nil, rule: nil)
12
+ super(message)
13
+ @component = component
14
+ @value = value
15
+ @rule = rule
16
+ end
17
+ end
18
+
19
+ # Parsing errors for malformed PURL strings
20
+ class ParseError < Error; end
21
+
22
+ # Specific validation errors
23
+ class InvalidTypeError < ValidationError; end
24
+ class InvalidNameError < ValidationError; end
25
+ class InvalidNamespaceError < ValidationError; end
26
+ class InvalidQualifierError < ValidationError; end
27
+ class InvalidVersionError < ValidationError; end
28
+ class InvalidSubpathError < ValidationError; end
29
+
30
+ # Parsing-specific errors
31
+ class InvalidSchemeError < ParseError; end
32
+ class MalformedUrlError < ParseError; end
33
+
34
+ # Registry URL generation errors
35
+ class RegistryError < Error
36
+ attr_reader :type
37
+
38
+ def initialize(message, type: nil)
39
+ super(message)
40
+ @type = type
41
+ end
42
+ end
43
+
44
+ class UnsupportedTypeError < RegistryError
45
+ attr_reader :supported_types
46
+
47
+ def initialize(message, type: nil, supported_types: [])
48
+ super(message, type: type)
49
+ @supported_types = supported_types
50
+ end
51
+ end
52
+
53
+ class MissingRegistryInfoError < RegistryError
54
+ attr_reader :missing
55
+
56
+ def initialize(message, type: nil, missing: nil)
57
+ super(message, type: type)
58
+ @missing = missing
59
+ end
60
+ end
61
+
62
+ # Legacy compatibility - matches packageurl-ruby's exception name
63
+ InvalidPackageURL = ParseError
64
+ end