rfcxml 0.3.0 → 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 (96) 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 +86 -20
  6. data/Gemfile +3 -1
  7. data/README.adoc +255 -35
  8. data/Rakefile +55 -0
  9. data/lib/rfcxml/v3/abstract.rb +2 -1
  10. data/lib/rfcxml/v3/address.rb +2 -1
  11. data/lib/rfcxml/v3/annotation.rb +2 -1
  12. data/lib/rfcxml/v3/area.rb +1 -1
  13. data/lib/rfcxml/v3/artset.rb +2 -1
  14. data/lib/rfcxml/v3/artwork.rb +13 -10
  15. data/lib/rfcxml/v3/aside.rb +2 -1
  16. data/lib/rfcxml/v3/author.rb +16 -9
  17. data/lib/rfcxml/v3/back.rb +2 -1
  18. data/lib/rfcxml/v3/bcp14.rb +1 -1
  19. data/lib/rfcxml/v3/blockquote.rb +2 -1
  20. data/lib/rfcxml/v3/boilerplate.rb +2 -1
  21. data/lib/rfcxml/v3/br.rb +1 -1
  22. data/lib/rfcxml/v3/c.rb +2 -1
  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 -1
  27. data/lib/rfcxml/v3/country.rb +1 -1
  28. data/lib/rfcxml/v3/cref.rb +2 -1
  29. data/lib/rfcxml/v3/date.rb +4 -4
  30. data/lib/rfcxml/v3/dd.rb +2 -1
  31. data/lib/rfcxml/v3/displayreference.rb +1 -1
  32. data/lib/rfcxml/v3/dl.rb +2 -1
  33. data/lib/rfcxml/v3/dt.rb +2 -1
  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 -11
  39. data/lib/rfcxml/v3/format.rb +1 -1
  40. data/lib/rfcxml/v3/front.rb +2 -1
  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 -1
  44. data/lib/rfcxml/v3/link.rb +1 -1
  45. data/lib/rfcxml/v3/list.rb +2 -1
  46. data/lib/rfcxml/v3/middle.rb +2 -1
  47. data/lib/rfcxml/v3/name.rb +2 -1
  48. data/lib/rfcxml/v3/note.rb +2 -1
  49. data/lib/rfcxml/v3/ol.rb +5 -2
  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 -1
  54. data/lib/rfcxml/v3/postal_line.rb +1 -1
  55. data/lib/rfcxml/v3/postamble.rb +2 -1
  56. data/lib/rfcxml/v3/preamble.rb +2 -1
  57. data/lib/rfcxml/v3/refcontent.rb +2 -1
  58. data/lib/rfcxml/v3/reference.rb +7 -4
  59. data/lib/rfcxml/v3/referencegroup.rb +2 -1
  60. data/lib/rfcxml/v3/references.rb +2 -1
  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 +60 -12
  64. data/lib/rfcxml/v3/section.rb +11 -4
  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 +2 -1
  71. data/lib/rfcxml/v3/sub.rb +2 -1
  72. data/lib/rfcxml/v3/sup.rb +2 -1
  73. data/lib/rfcxml/v3/table.rb +1 -1
  74. data/lib/rfcxml/v3/tbody.rb +1 -1
  75. data/lib/rfcxml/v3/td.rb +23 -4
  76. data/lib/rfcxml/v3/text.rb +2 -1
  77. data/lib/rfcxml/v3/texttable.rb +12 -6
  78. data/lib/rfcxml/v3/tfoot.rb +1 -1
  79. data/lib/rfcxml/v3/th.rb +1 -1
  80. data/lib/rfcxml/v3/thead.rb +1 -1
  81. data/lib/rfcxml/v3/title.rb +3 -2
  82. data/lib/rfcxml/v3/toc.rb +1 -1
  83. data/lib/rfcxml/v3/tr.rb +3 -1
  84. data/lib/rfcxml/v3/tt.rb +1 -1
  85. data/lib/rfcxml/v3/ttcol.rb +4 -2
  86. data/lib/rfcxml/v3/u.rb +1 -1
  87. data/lib/rfcxml/v3/ul.rb +10 -4
  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 -1
  92. data/lib/rfcxml/version.rb +1 -1
  93. data/lib/rfcxml.rb +1 -0
  94. data/scripts/README.md +110 -0
  95. data/scripts/roundtrip_test.rb +361 -0
  96. metadata +5 -2
data/scripts/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # RFC XML Round-Trip Test Script
2
+
3
+ ## Purpose
4
+
5
+ Test round-trip parsing of RFC XML v3 files using Canon gem for semantic comparison.
6
+
7
+ **Process:** XML → Parse → Serialize → Compare
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ # Test single file
13
+ ruby scripts/roundtrip_test.rb spec/xmlsource-rfc8650-latest/rfc8704.xml
14
+
15
+ # Test multiple files
16
+ ruby scripts/roundtrip_test.rb file1.xml file2.xml file3.xml
17
+
18
+ # Test all files in directory
19
+ ruby scripts/roundtrip_test.rb spec/xmlsource-rfc8650-latest/
20
+
21
+ # Test with glob pattern
22
+ ruby scripts/roundtrip_test.rb "spec/xmlsource-rfc8650-latest/rfc87*.xml"
23
+
24
+ # Test all 920 files (default)
25
+ ruby scripts/roundtrip_test.rb
26
+ ```
27
+
28
+ ## Environment Variables
29
+
30
+ | Variable | Default | Description |
31
+ |-----------|---------|--------------------------------|
32
+ | `THREADS` | 8 | Number of worker threads |
33
+ | `VERBOSE` | false | Show per-file progress |
34
+
35
+ ```bash
36
+ THREADS=1 VERBOSE=1 ruby scripts/roundtrip_test.rb file.xml
37
+ ```
38
+
39
+ ## Output
40
+
41
+ Test results are written to `tmp/roundtrip-results-{timestamp}/`:
42
+
43
+ ```
44
+ tmp/roundtrip-results-20260318_200000/
45
+ SUMMARY.yml # Overall summary (YAML)
46
+ PASS_{filename} # Empty marker for passed tests
47
+ FAIL_{filename}.yml # Structured failure details
48
+ ERROR_{filename}.yml # Structured error details
49
+ SOURCE_{filename} # Round-tripped output for debugging
50
+ ```
51
+
52
+ ### SUMMARY.yml Structure
53
+
54
+ ```yaml
55
+ timestamp: "2026-03-18T20:00:00+08:00"
56
+ configuration:
57
+ threads: 8
58
+ files_tested: 920
59
+ elapsed_seconds: 664.75
60
+ results:
61
+ passed: 833
62
+ failed: 83
63
+ errors: 4
64
+ pass_rate: "90.5%"
65
+ failed_files:
66
+ - rfc8704.xml
67
+ - rfc8705.xml
68
+ error_files:
69
+ - rfc8650.xml
70
+ ```
71
+
72
+ ### FAIL_*.yml Structure
73
+
74
+ ```yaml
75
+ source_file: spec/xmlsource-rfc8650-latest/rfc8704.xml
76
+ status: failed
77
+ normative_differences: 1
78
+ total_differences: 267
79
+ differences:
80
+ - path: /rfc[1]/back[1]/references/reference/front/author
81
+ dimension: attribute_presence
82
+ reason: "only in first: initials, surname"
83
+ normative: true
84
+ formatting:
85
+ attributes_before: { initials: "", surname: "" }
86
+ attributes_after: {}
87
+ ```
88
+
89
+ ## Known Issues
90
+
91
+ ### Threading Bug (4 files)
92
+
93
+ When using `THREADS > 1`, some files fail with:
94
+ ```
95
+ NoMethodError: undefined method 'transform' for nil
96
+ ```
97
+
98
+ Affected files: rfc8650.xml, rfc8651.xml, rfc8654.xml, rfc8657.xml
99
+
100
+ **Workaround:** Use `THREADS=1` for these files.
101
+
102
+ ### Empty Attribute Handling
103
+
104
+ Some models need `value_map: { to: { empty: :empty } }` to preserve empty string attributes during round-trip.
105
+
106
+ ## Dependencies
107
+
108
+ - `rfcxml` gem - RFC XML parsing
109
+ - `canon` gem - Semantic XML comparison
110
+ - `lutaml-model` - XML serialization
@@ -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.3.0
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: 2026-03-18 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
@@ -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"
@@ -149,6 +150,8 @@ files:
149
150
  - reference-docs/v3.xsd
150
151
  - reference-docs/xml.xsd
151
152
  - rfcxml.gemspec
153
+ - scripts/README.md
154
+ - scripts/roundtrip_test.rb
152
155
  - sig/xml2rfc.rbs
153
156
  homepage: https://github.com/metanorma/rfcxml
154
157
  licenses: