rfcxml 0.2.1 → 0.4.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/roundtrip.yml +79 -0
  3. data/.gitignore +3 -0
  4. data/.rubocop.yml +9 -3
  5. data/.rubocop_todo.yml +87 -17
  6. data/Gemfile +4 -2
  7. data/README.adoc +386 -18
  8. data/Rakefile +55 -0
  9. data/lib/rfcxml/v3/abstract.rb +2 -6
  10. data/lib/rfcxml/v3/address.rb +2 -7
  11. data/lib/rfcxml/v3/annotation.rb +2 -15
  12. data/lib/rfcxml/v3/area.rb +1 -1
  13. data/lib/rfcxml/v3/artset.rb +2 -3
  14. data/lib/rfcxml/v3/artwork.rb +13 -10
  15. data/lib/rfcxml/v3/aside.rb +2 -12
  16. data/lib/rfcxml/v3/author.rb +16 -12
  17. data/lib/rfcxml/v3/back.rb +2 -5
  18. data/lib/rfcxml/v3/bcp14.rb +1 -1
  19. data/lib/rfcxml/v3/blockquote.rb +2 -26
  20. data/lib/rfcxml/v3/boilerplate.rb +2 -3
  21. data/lib/rfcxml/v3/br.rb +1 -1
  22. data/lib/rfcxml/v3/c.rb +2 -7
  23. data/lib/rfcxml/v3/city.rb +1 -1
  24. data/lib/rfcxml/v3/cityarea.rb +1 -1
  25. data/lib/rfcxml/v3/code.rb +1 -1
  26. data/lib/rfcxml/v3/contact.rb +2 -4
  27. data/lib/rfcxml/v3/country.rb +1 -1
  28. data/lib/rfcxml/v3/cref.rb +2 -11
  29. data/lib/rfcxml/v3/date.rb +4 -4
  30. data/lib/rfcxml/v3/dd.rb +2 -25
  31. data/lib/rfcxml/v3/displayreference.rb +1 -1
  32. data/lib/rfcxml/v3/dl.rb +2 -7
  33. data/lib/rfcxml/v3/dt.rb +2 -14
  34. data/lib/rfcxml/v3/email.rb +1 -1
  35. data/lib/rfcxml/v3/eref.rb +1 -1
  36. data/lib/rfcxml/v3/extaddr.rb +1 -1
  37. data/lib/rfcxml/v3/facsimile.rb +1 -1
  38. data/lib/rfcxml/v3/figure.rb +17 -19
  39. data/lib/rfcxml/v3/format.rb +1 -1
  40. data/lib/rfcxml/v3/front.rb +2 -13
  41. data/lib/rfcxml/v3/iref.rb +4 -3
  42. data/lib/rfcxml/v3/keyword.rb +1 -1
  43. data/lib/rfcxml/v3/li.rb +2 -28
  44. data/lib/rfcxml/v3/link.rb +1 -1
  45. data/lib/rfcxml/v3/list.rb +3 -4
  46. data/lib/rfcxml/v3/middle.rb +2 -3
  47. data/lib/rfcxml/v3/name.rb +2 -14
  48. data/lib/rfcxml/v3/note.rb +2 -7
  49. data/lib/rfcxml/v3/ol.rb +5 -4
  50. data/lib/rfcxml/v3/organization.rb +10 -5
  51. data/lib/rfcxml/v3/phone.rb +1 -1
  52. data/lib/rfcxml/v3/pobox.rb +1 -1
  53. data/lib/rfcxml/v3/postal.rb +2 -12
  54. data/lib/rfcxml/v3/postal_line.rb +1 -1
  55. data/lib/rfcxml/v3/postamble.rb +2 -7
  56. data/lib/rfcxml/v3/preamble.rb +2 -15
  57. data/lib/rfcxml/v3/refcontent.rb +2 -4
  58. data/lib/rfcxml/v3/reference.rb +7 -10
  59. data/lib/rfcxml/v3/referencegroup.rb +2 -3
  60. data/lib/rfcxml/v3/references.rb +2 -5
  61. data/lib/rfcxml/v3/region.rb +1 -1
  62. data/lib/rfcxml/v3/relref.rb +1 -1
  63. data/lib/rfcxml/v3/rfc.rb +62 -14
  64. data/lib/rfcxml/v3/section.rb +11 -21
  65. data/lib/rfcxml/v3/series_info.rb +5 -4
  66. data/lib/rfcxml/v3/sortingcode.rb +1 -1
  67. data/lib/rfcxml/v3/sourcecode.rb +13 -9
  68. data/lib/rfcxml/v3/spanx.rb +1 -1
  69. data/lib/rfcxml/v3/street.rb +1 -1
  70. data/lib/rfcxml/v3/strong.rb +6 -8
  71. data/lib/rfcxml/v3/sub.rb +6 -15
  72. data/lib/rfcxml/v3/sup.rb +2 -3
  73. data/lib/rfcxml/v3/table.rb +1 -7
  74. data/lib/rfcxml/v3/tbody.rb +1 -3
  75. data/lib/rfcxml/v3/td.rb +23 -26
  76. data/lib/rfcxml/v3/text.rb +2 -20
  77. data/lib/rfcxml/v3/texttable.rb +12 -12
  78. data/lib/rfcxml/v3/tfoot.rb +1 -3
  79. data/lib/rfcxml/v3/th.rb +1 -3
  80. data/lib/rfcxml/v3/thead.rb +1 -3
  81. data/lib/rfcxml/v3/title.rb +3 -4
  82. data/lib/rfcxml/v3/toc.rb +1 -3
  83. data/lib/rfcxml/v3/tr.rb +3 -4
  84. data/lib/rfcxml/v3/tt.rb +5 -19
  85. data/lib/rfcxml/v3/ttcol.rb +4 -7
  86. data/lib/rfcxml/v3/u.rb +1 -1
  87. data/lib/rfcxml/v3/ul.rb +10 -6
  88. data/lib/rfcxml/v3/uri.rb +1 -1
  89. data/lib/rfcxml/v3/vspace.rb +1 -1
  90. data/lib/rfcxml/v3/workgroup.rb +1 -1
  91. data/lib/rfcxml/v3/xref.rb +2 -3
  92. data/lib/rfcxml/v3/xref_text.rb +7 -16
  93. data/lib/rfcxml/v3.rb +100 -2
  94. data/lib/rfcxml/version.rb +1 -1
  95. data/lib/rfcxml.rb +3 -4
  96. data/rfcxml.gemspec +3 -2
  97. data/scripts/README.md +110 -0
  98. data/scripts/roundtrip_test.rb +361 -0
  99. metadata +9 -6
  100. data/lib/rfcxml/v3/em.rb +0 -14
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # RFC XML Round-Trip Test Script
5
+ #
6
+ # Usage:
7
+ # ruby scripts/roundtrip_test.rb # All 920 files
8
+ # ruby scripts/roundtrip_test.rb file.xml # Single file
9
+ # ruby scripts/roundtrip_test.rb file1.xml file2.xml # Multiple files
10
+ # ruby scripts/roundtrip_test.rb "spec/*.xml" # Glob pattern
11
+ # ruby scripts/roundtrip_test.rb spec/xmlsource-rfc8650-latest/ # Directory
12
+ #
13
+ # Environment:
14
+ # THREADS=n - Number of threads (default: 8)
15
+ # VERBOSE=1 - Show per-file progress
16
+ #
17
+ # Output:
18
+ # tmp/roundtrip-results-{timestamp}/
19
+ # SUMMARY.yml - Overall summary
20
+ # PASS_{filename}.xml # Empty marker for passed tests
21
+ # FAIL_{filename}.yml # Structured failure details
22
+ # ERROR_{filename}.yml # Structured error details
23
+ # SOURCE_{filename}.xml # Round-tripped output for debugging
24
+ #
25
+
26
+ require "bundler/setup"
27
+ require "rfcxml"
28
+ require "canon"
29
+ require "yaml"
30
+ require "fileutils"
31
+ require "time"
32
+
33
+ class RoundTripTester
34
+ DEFAULT_THREADS = 8
35
+ DEFAULT_XML_DIR = File.expand_path("../spec/xmlsource-rfc8650-latest",
36
+ __dir__)
37
+ TMP_DIR = File.expand_path("../tmp", __dir__)
38
+
39
+ attr_reader :results_dir
40
+
41
+ def initialize(files:, threads: DEFAULT_THREADS, verbose: false)
42
+ @files = files
43
+ @threads = threads
44
+ @verbose = verbose
45
+ @mutex = Mutex.new
46
+ @processed = 0
47
+ @results = []
48
+ @start_time = nil
49
+
50
+ # Create temp results directory
51
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
52
+ @results_dir = File.join(TMP_DIR, "roundtrip-results-#{timestamp}")
53
+ FileUtils.mkdir_p(@results_dir)
54
+ end
55
+
56
+ def run
57
+ puts "=" * 70
58
+ puts "RFC XML Round-Trip Test"
59
+ puts "=" * 70
60
+ puts "Files: #{@files.size}"
61
+ puts "Threads: #{@threads}"
62
+ puts "Output: #{@results_dir}"
63
+ puts "=" * 70
64
+
65
+ @start_time = Time.now
66
+
67
+ if @threads == 1 || @files.size == 1
68
+ run_sequential
69
+ else
70
+ run_parallel
71
+ end
72
+
73
+ write_summary
74
+ print_report
75
+ end
76
+
77
+ private
78
+
79
+ def run_sequential
80
+ @files.each_with_index do |file, i|
81
+ test_file(file)
82
+ print "\r Progress: #{i + 1}/#{@files.size}" unless @verbose
83
+ end
84
+ puts
85
+ end
86
+
87
+ def run_parallel
88
+ queue = Queue.new
89
+ @files.each { |f| queue << f }
90
+
91
+ workers = Array.new(@threads) { Thread.new { worker(queue) } }
92
+ monitor = Thread.new { progress_monitor(queue) }
93
+
94
+ workers.each(&:join)
95
+ monitor.kill
96
+ puts
97
+ end
98
+
99
+ def worker(queue)
100
+ until queue.empty?
101
+ file = queue.pop(true)
102
+ test_file(file)
103
+ end
104
+ rescue ThreadError
105
+ # Queue empty
106
+ end
107
+
108
+ def test_file(filepath)
109
+ basename = File.basename(filepath)
110
+ result = perform_test(filepath, basename)
111
+
112
+ @mutex.synchronize do
113
+ @results << result
114
+ @processed += 1
115
+ puts " [#{@processed}/#{@files.size}] #{result[:status].upcase}: #{basename}" if @verbose
116
+ end
117
+ end
118
+
119
+ def perform_test(filepath, basename)
120
+ # Read input
121
+ input = begin
122
+ File.read(filepath)
123
+ rescue StandardError => e
124
+ return build_error_result(basename, "Read", e)
125
+ end
126
+
127
+ # Parse
128
+ parsed = begin
129
+ Rfcxml::V3::Rfc.from_xml(input)
130
+ rescue StandardError => e
131
+ return build_error_result(basename, "Parse", e)
132
+ end
133
+
134
+ # Serialize
135
+ output = begin
136
+ parsed.to_xml(pretty: true, declaration: true, encoding: "utf-8")
137
+ rescue StandardError => e
138
+ return build_error_result(basename, "Serialize", e)
139
+ end
140
+
141
+ # Compare using Canon DOM diff with explicit match options
142
+ # Using DOM diff with attribute_order: ignore and attribute_values: normalize
143
+ # handles round-trip differences correctly
144
+ comparison = begin
145
+ Canon::Comparison.equivalent?(
146
+ output,
147
+ input,
148
+ diff_algorithm: :dom,
149
+ format: :xml,
150
+ match: {
151
+ attribute_order: :ignore,
152
+ attribute_values: :normalize,
153
+ text_content: :normalize,
154
+ structural_whitespace: :ignore,
155
+ },
156
+ verbose: true,
157
+ )
158
+ rescue StandardError => e
159
+ return build_error_result(basename, "Compare", e)
160
+ end
161
+
162
+ # Check result
163
+ equivalent = comparison.respond_to?(:equivalent?) ? comparison.equivalent? : comparison
164
+
165
+ if equivalent
166
+ # Pass - write empty marker file
167
+ write_pass_marker(basename)
168
+ { file: basename, status: :pass }
169
+ else
170
+ # Fail - write detailed report and source
171
+ differences = extract_differences(comparison)
172
+ write_fail_report(basename, filepath, differences)
173
+ write_source_output(basename, output)
174
+ { file: basename, status: :fail, differences: differences }
175
+ end
176
+ end
177
+
178
+ def build_error_result(basename, phase, error)
179
+ error_info = {
180
+ phase: phase,
181
+ error_class: error.class.name,
182
+ error_message: error.message,
183
+ backtrace: error.backtrace&.first(5),
184
+ }
185
+ write_error_report(basename, error_info)
186
+ { file: basename, status: :error, error: error_info }
187
+ end
188
+
189
+ def extract_differences(comparison)
190
+ return [] unless comparison.respond_to?(:differences)
191
+
192
+ comparison.differences.map do |diff|
193
+ {
194
+ path: diff.path,
195
+ dimension: diff.dimension,
196
+ reason: diff.reason,
197
+ normative: diff.normative,
198
+ formatting: diff.formatting,
199
+ attributes_before: diff.attributes_before&.to_h,
200
+ attributes_after: diff.attributes_after&.to_h,
201
+ }
202
+ end
203
+ end
204
+
205
+ def write_pass_marker(basename)
206
+ path = File.join(@results_dir, "PASS_#{basename}")
207
+ FileUtils.touch(path)
208
+ end
209
+
210
+ def write_fail_report(basename, source_path, differences)
211
+ normative = differences.select { |d| d[:normative] }
212
+
213
+ report = {
214
+ source_file: source_path,
215
+ status: :failed,
216
+ normative_differences: normative.size,
217
+ total_differences: differences.size,
218
+ differences: normative.first(20), # Limit to first 20 normative diffs
219
+ }
220
+
221
+ safe_name = basename.gsub(/[^\w.-]/, "_")
222
+ path = File.join(@results_dir, "FAIL_#{safe_name}.yml")
223
+ File.write(path, report.to_yaml)
224
+ end
225
+
226
+ def write_error_report(basename, error_info)
227
+ report = {
228
+ source_file: basename,
229
+ status: :error,
230
+ **error_info,
231
+ }
232
+
233
+ safe_name = basename.gsub(/[^\w.-]/, "_")
234
+ path = File.join(@results_dir, "ERROR_#{safe_name}.yml")
235
+ File.write(path, report.to_yaml)
236
+ end
237
+
238
+ def write_source_output(basename, output)
239
+ safe_name = basename.gsub(/[^\w.-]/, "_")
240
+ path = File.join(@results_dir, "SOURCE_#{safe_name}")
241
+ File.write(path, output)
242
+ end
243
+
244
+ def write_summary
245
+ passed = @results.select { |r| r[:status] == :pass }
246
+ failed = @results.select { |r| r[:status] == :fail }
247
+ errors = @results.select { |r| r[:status] == :error }
248
+
249
+ summary = {
250
+ timestamp: Time.now.iso8601,
251
+ configuration: {
252
+ threads: @threads,
253
+ files_tested: @files.size,
254
+ elapsed_seconds: (Time.now - @start_time).round(2),
255
+ },
256
+ results: {
257
+ passed: passed.size,
258
+ failed: failed.size,
259
+ errors: errors.size,
260
+ pass_rate: "#{(passed.size.to_f / @files.size * 100).round(1)}%",
261
+ },
262
+ failed_files: failed.map { |r| r[:file] },
263
+ error_files: errors.map { |r| r[:file] },
264
+ }
265
+
266
+ path = File.join(@results_dir, "SUMMARY.yml")
267
+ File.write(path, summary.to_yaml)
268
+
269
+ @summary = summary
270
+ end
271
+
272
+ def print_report
273
+ elapsed = Time.now - @start_time
274
+ passed = @results.count { |r| r[:status] == :pass }
275
+ failed = @results.count { |r| r[:status] == :fail }
276
+ errors = @results.count { |r| r[:status] == :error }
277
+
278
+ puts
279
+ puts "=" * 70
280
+ puts "RESULTS"
281
+ puts "=" * 70
282
+ puts "Elapsed: #{elapsed.round(2)}s"
283
+ puts "PASSED: #{passed}/#{@files.size} (#{pct(passed)}%)"
284
+ puts "FAILED: #{failed}/#{@files.size} (#{pct(failed)}%)"
285
+ puts "ERRORS: #{errors}/#{@files.size} (#{pct(errors)}%)"
286
+ puts
287
+ puts "Output dir: #{@results_dir}"
288
+
289
+ if failed.positive? || errors.positive?
290
+ puts
291
+ puts "See #{@results_dir}/ for detailed reports:"
292
+ puts " FAIL_*.yml - Failure details"
293
+ puts " ERROR_*.yml - Error details"
294
+ puts " SOURCE_*.xml - Round-tripped output"
295
+ end
296
+ end
297
+
298
+ def pct(count)
299
+ return 0.0 if @files.empty?
300
+
301
+ (count.to_f / @files.size * 100).round(1)
302
+ end
303
+
304
+ def progress_monitor(_queue)
305
+ loop do
306
+ sleep 1
307
+ @mutex.synchronize do
308
+ return if @processed >= @files.size
309
+
310
+ print "\r Progress: #{@processed}/#{@files.size} (#{pct(@processed)}%) "
311
+ $stdout.flush
312
+ end
313
+ end
314
+ end
315
+ end
316
+
317
+ # === CLI Argument Parsing ===
318
+
319
+ def parse_args(args)
320
+ threads = ENV.fetch("THREADS", RoundTripTester::DEFAULT_THREADS).to_i
321
+ verbose = ENV["VERBOSE"] || ENV.fetch("V", nil)
322
+
323
+ files = []
324
+
325
+ args.each do |arg|
326
+ if File.directory?(arg)
327
+ files.concat(Dir.glob(File.join(arg, "*.xml")))
328
+ elsif File.file?(arg)
329
+ files << File.expand_path(arg)
330
+ elsif arg.include?("*")
331
+ files.concat(Dir.glob(arg))
332
+ else
333
+ # Try as file path
334
+ path = File.expand_path(arg)
335
+ files << path if File.file?(path)
336
+ end
337
+ end
338
+
339
+ # Default: all files in default directory
340
+ if files.empty?
341
+ files = Dir.glob(File.join(RoundTripTester::DEFAULT_XML_DIR, "*.xml"))
342
+ end
343
+
344
+ files = files.sort.uniq
345
+
346
+ { files: files, threads: threads, verbose: verbose }
347
+ end
348
+
349
+ # === Main ===
350
+
351
+ if __FILE__ == $PROGRAM_NAME
352
+ options = parse_args(ARGV)
353
+
354
+ if options[:files].empty?
355
+ puts "No XML files found"
356
+ exit 1
357
+ end
358
+
359
+ tester = RoundTripTester.new(**options)
360
+ tester.run
361
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rfcxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-03-19 00:00:00.000000000 Z
11
+ date: 2026-03-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.7'
19
+ version: 0.8.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.7'
26
+ version: 0.8.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: nokogiri
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -47,6 +47,7 @@ extra_rdoc_files: []
47
47
  files:
48
48
  - ".github/workflows/rake.yml"
49
49
  - ".github/workflows/release.yml"
50
+ - ".github/workflows/roundtrip.yml"
50
51
  - ".gitignore"
51
52
  - ".rspec"
52
53
  - ".rubocop.yml"
@@ -84,7 +85,6 @@ files:
84
85
  - lib/rfcxml/v3/displayreference.rb
85
86
  - lib/rfcxml/v3/dl.rb
86
87
  - lib/rfcxml/v3/dt.rb
87
- - lib/rfcxml/v3/em.rb
88
88
  - lib/rfcxml/v3/email.rb
89
89
  - lib/rfcxml/v3/eref.rb
90
90
  - lib/rfcxml/v3/extaddr.rb
@@ -150,6 +150,8 @@ files:
150
150
  - reference-docs/v3.xsd
151
151
  - reference-docs/xml.xsd
152
152
  - rfcxml.gemspec
153
+ - scripts/README.md
154
+ - scripts/roundtrip_test.rb
153
155
  - sig/xml2rfc.rbs
154
156
  homepage: https://github.com/metanorma/rfcxml
155
157
  licenses:
@@ -158,6 +160,7 @@ metadata:
158
160
  homepage_uri: https://github.com/metanorma/rfcxml
159
161
  source_code_uri: https://github.com/metanorma/rfcxml
160
162
  changelog_uri: https://github.com/metanorma/rfcxml/releases
163
+ rubygems_mfa_required: 'true'
161
164
  post_install_message:
162
165
  rdoc_options: []
163
166
  require_paths:
@@ -166,7 +169,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
166
169
  requirements:
167
170
  - - ">="
168
171
  - !ruby/object:Gem::Version
169
- version: 2.7.0
172
+ version: 3.2.0
170
173
  required_rubygems_version: !ruby/object:Gem::Requirement
171
174
  requirements:
172
175
  - - ">="
data/lib/rfcxml/v3/em.rb DELETED
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "lutaml/model"
4
- require_relative "strong"
5
-
6
- module Rfcxml
7
- module V3
8
- class Em < Strong
9
- xml do
10
- root "em"
11
- end
12
- end
13
- end
14
- end