relaton 3.0.0.pre.alpha.17 → 3.0.0.pre.alpha.19

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/data/equivalence/adoption-chains.yaml +59 -0
  3. data/lib/relaton/3gpp/CLAUDE.md +187 -0
  4. data/lib/relaton/3gpp/README.adoc +149 -0
  5. data/lib/relaton/adobe/CLAUDE.md +104 -0
  6. data/lib/relaton/bib/CLAUDE.md +130 -0
  7. data/lib/relaton/bib/README.adoc +330 -0
  8. data/lib/relaton/bib/model/item_shared.rb +5 -1
  9. data/lib/relaton/bib/model/series.rb +1 -1
  10. data/lib/relaton/bipm/CLAUDE.md +173 -0
  11. data/lib/relaton/bipm/README.adoc +352 -0
  12. data/lib/relaton/bipm/data_fetcher.rb +3 -2
  13. data/lib/relaton/bsi/CLAUDE.md +35 -0
  14. data/lib/relaton/bsi/README.adoc +112 -0
  15. data/lib/relaton/calconnect/CLAUDE.md +177 -0
  16. data/lib/relaton/calconnect/README.adoc +140 -0
  17. data/lib/relaton/ccsds/CLAUDE.md +74 -0
  18. data/lib/relaton/ccsds/README.adoc +154 -0
  19. data/lib/relaton/cen/CLAUDE.md +219 -0
  20. data/lib/relaton/cen/README.adoc +155 -0
  21. data/lib/relaton/cie/CLAUDE.md +90 -0
  22. data/lib/relaton/cie/README.adoc +121 -0
  23. data/lib/relaton/core/CLAUDE.md +140 -0
  24. data/lib/relaton/core/README.adoc +64 -0
  25. data/lib/relaton/core/data_fetcher.rb +142 -3
  26. data/lib/relaton/db/CLAUDE.md +373 -0
  27. data/lib/relaton/db.rb +21 -1
  28. data/lib/relaton/doi/CLAUDE.md +105 -0
  29. data/lib/relaton/doi/README.adoc +89 -0
  30. data/lib/relaton/easc/CLAUDE.md +85 -0
  31. data/lib/relaton/ecma/CLAUDE.md +217 -0
  32. data/lib/relaton/ecma/README.adoc +178 -0
  33. data/lib/relaton/equivalence.rb +117 -0
  34. data/lib/relaton/etsi/CLAUDE.md +219 -0
  35. data/lib/relaton/etsi/README.adoc +137 -0
  36. data/lib/relaton/evs/CLAUDE.md +120 -0
  37. data/lib/relaton/evs/README.adoc +27 -0
  38. data/lib/relaton/gb/CLAUDE.md +47 -0
  39. data/lib/relaton/gb/README.adoc +169 -0
  40. data/lib/relaton/gost/CLAUDE.md +100 -0
  41. data/lib/relaton/iala/CLAUDE.md +97 -0
  42. data/lib/relaton/iana/CLAUDE.md +105 -0
  43. data/lib/relaton/iana/README.adoc +107 -0
  44. data/lib/relaton/iec/CLAUDE.md +13 -0
  45. data/lib/relaton/iec/README.adoc +280 -0
  46. data/lib/relaton/iec/data_fetcher.rb +24 -8
  47. data/lib/relaton/iec/project_fetcher.rb +48 -0
  48. data/lib/relaton/iec/project_parser.rb +111 -0
  49. data/lib/relaton/iec.rb +5 -0
  50. data/lib/relaton/ieee/CLAUDE.md +176 -0
  51. data/lib/relaton/ieee/README.adoc +131 -0
  52. data/lib/relaton/ietf/CLAUDE.md +357 -0
  53. data/lib/relaton/ietf/README.adoc +167 -0
  54. data/lib/relaton/iho/CLAUDE.md +70 -0
  55. data/lib/relaton/iho/README.adoc +119 -0
  56. data/lib/relaton/index/CLAUDE.md +207 -0
  57. data/lib/relaton/index/README.adoc +221 -0
  58. data/lib/relaton/index/dataset_lock.rb +122 -0
  59. data/lib/relaton/index/family.rb +63 -0
  60. data/lib/relaton/index/type.rb +32 -0
  61. data/lib/relaton/index.rb +3 -0
  62. data/lib/relaton/isbn/CLAUDE.md +47 -0
  63. data/lib/relaton/isbn/README.adoc +91 -0
  64. data/lib/relaton/iso/CLAUDE.md +38 -0
  65. data/lib/relaton/iso/README.adoc +370 -0
  66. data/lib/relaton/iso/data_fetcher.rb +16 -1
  67. data/lib/relaton/itu/CLAUDE.md +685 -0
  68. data/lib/relaton/itu/README.adoc +188 -0
  69. data/lib/relaton/jcgm/CLAUDE.md +107 -0
  70. data/lib/relaton/jis/CLAUDE.md +64 -0
  71. data/lib/relaton/jis/README.adoc +167 -0
  72. data/lib/relaton/logger/CLAUDE.md +37 -0
  73. data/lib/relaton/logger/README.adoc +120 -0
  74. data/lib/relaton/nist/CLAUDE.md +180 -0
  75. data/lib/relaton/nist/README.adoc +389 -0
  76. data/lib/relaton/nist/bibliography.rb +17 -1
  77. data/lib/relaton/nist/data_fetcher.rb +60 -4
  78. data/lib/relaton/nist/hit_collection.rb +121 -59
  79. data/lib/relaton/nist/scraper.rb +21 -7
  80. data/lib/relaton/oasis/CLAUDE.md +251 -0
  81. data/lib/relaton/oasis/README.adoc +132 -0
  82. data/lib/relaton/ogc/CLAUDE.md +242 -0
  83. data/lib/relaton/ogc/README.adoc +134 -0
  84. data/lib/relaton/ogc/data_fetcher.rb +9 -3
  85. data/lib/relaton/oiml/CLAUDE.md +47 -0
  86. data/lib/relaton/oiml/README.adoc +70 -0
  87. data/lib/relaton/omg/CLAUDE.md +137 -0
  88. data/lib/relaton/omg/README.adoc +101 -0
  89. data/lib/relaton/plateau/CLAUDE.md +126 -0
  90. data/lib/relaton/plateau/README.adoc +139 -0
  91. data/lib/relaton/sdo/CLAUDE.md +81 -0
  92. data/lib/relaton/un/CLAUDE.md +91 -0
  93. data/lib/relaton/un/README.adoc +125 -0
  94. data/lib/relaton/version.rb +1 -1
  95. data/lib/relaton/w3c/CLAUDE.md +369 -0
  96. data/lib/relaton/w3c/README.adoc +140 -0
  97. data/lib/relaton/w3c/data_fetcher.rb +1 -1
  98. data/lib/relaton/xsf/CLAUDE.md +189 -0
  99. data/lib/relaton/xsf/README.adoc +127 -0
  100. data/lib/relaton.rb +1 -0
  101. metadata +85 -4
@@ -67,15 +67,32 @@ module Relaton
67
67
  # both sides of the equality, not only from the hits.
68
68
  parts << :stage if refid.stage
69
69
  compare_refid = refid.stage ? refid.exclude(:stage) : refid
70
- arr = @array.select do |item|
71
- pubid = ::Pubid::Nist::Identifier.parse(item.hit[:code])
72
- pubid.exclude(*parts) == compare_refid
73
- rescue StandardError
74
- item.hit[:code] == ref
70
+ # A stage-typed row exists now (the CSRC supplement): when the
71
+ # reference names a stage and a hit carries it, the exact-stage
72
+ # match wins outright — the stage-excluded comparison below stays
73
+ # as the family fallback for stages the dataset still lacks.
74
+ exact = []
75
+ if refid.stage
76
+ exact = @array.select do |item|
77
+ ::Pubid::Nist::Identifier.parse(item.hit[:code]) == refid
78
+ rescue StandardError
79
+ false
80
+ end
81
+ end
82
+ arr = exact
83
+ if exact.empty?
84
+ arr = @array.select do |item|
85
+ pubid = ::Pubid::Nist::Identifier.parse(item.hit[:code])
86
+ pubid.exclude(*parts) == compare_refid
87
+ rescue StandardError
88
+ item.hit[:code] == ref
89
+ end
75
90
  end
76
91
  rescue StandardError
77
92
  arr = @array.select { |item| item.hit[:code] == ref }
78
93
  ensure
94
+ # The ensure is the one exit, so every branch sets arr: a `return`
95
+ # before it is overridden here (relaton#314).
79
96
  dup = self.dup
80
97
  dup.array = arr
81
98
  return dup
@@ -108,6 +125,76 @@ module Relaton
108
125
  parts
109
126
  end
110
127
 
128
+ # The id of a pubs-export row: from its DOI (or its docidentifier),
129
+ # with the stage and the update its uri carries. A class method, so the
130
+ # scraper derives a related row's id the same way.
131
+ #
132
+ # @param json [Hash] pubs-export row
133
+ # @return [String]
134
+ def self.pubs_export_id(json) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
135
+ if json && json["doi"]
136
+ json["doi"].sub(/^10.6028\//, "")
137
+ else
138
+ json["docidentifier"]
139
+ end => id
140
+
141
+ # Not sub!: the row belongs to the shared PubsExport data.
142
+ id = id.sub(/(?:-draft\d*|\.\wpd)$/, "")
143
+ id = id.gsub(".", " ").sub(/-Add(\d*)$/, ' Add.\1') if id.match?(/-Add\d*$/)
144
+ # Normalize to space-separated form so pubid 2.x parses as
145
+ # parsed_format=:short and renders without dots; also force the
146
+ # "NIST " prefix so publisher_was_parsed is set.
147
+ parse_input = id.gsub(/\bNIST\./, "NIST ")
148
+ parse_input = "NIST #{parse_input}" unless parse_input.start_with?("NIST ")
149
+ pid = ::Pubid::Nist::Identifier.parse(parse_input)
150
+
151
+ # Canonicalize edition spelling: DOI-derived codes are already short
152
+ # ("…r2"), but no-DOI drafts come from the verbose docidentifier
153
+ # ("… Rev. 2") and pubid preserves that spelling via original_prefix.
154
+ # Drop it so every code renders the canonical short form.
155
+ pid.edition.original_prefix = nil if pid.respond_to?(:edition) && pid.edition
156
+
157
+ # Stage: URI is authoritative, fall back to iteration. "final" => no stage.
158
+ uri_stage = json["uri"] && json["uri"][%r{/(final|ipd|fpd|\dpd)(?:-\(\d+\))?(?:/|$)}, 1]
159
+ stage_src = uri_stage || json["iteration"]
160
+ case stage_src
161
+ when nil, "final"
162
+ # no stage — "final" means published
163
+ when "fpd"
164
+ pid.stage = ::Pubid::Nist::Components::Stage.new id: "f", type: "pd"
165
+ when /\A(\w)pd\z/
166
+ pid.stage = ::Pubid::Nist::Components::Stage.new id: Regexp.last_match(1), type: "pd"
167
+ end
168
+
169
+ /\/upd(?<upd>\d+)\// =~ json["uri"]
170
+ # In pubid 2.x render paths the Update is read from
171
+ # update_component (Components::Update), not the legacy :update
172
+ # slot — assigning to :update would render as "-upd/Upd2" via the
173
+ # elsif fallback path in Base#to_short_style.
174
+ pid.update_component = ::Pubid::Nist::Components::Update.new(number: upd) if upd
175
+ pid.to_s
176
+ rescue StandardError
177
+ id += " #{json["iteration"]}" if json["iteration"] && json["iteration"] != "final"
178
+ id
179
+ end
180
+
181
+ #
182
+ # Build hit data from a CSRC pubs-export row. Shared with
183
+ # DataFetcher#fetch_csrc, so a crawled row and a runtime hit carry the
184
+ # same code.
185
+ #
186
+ # @param json [Hash] pubs-export row
187
+ #
188
+ # @return [Hash] hit data
189
+ #
190
+ def hit_data(json) # rubocop:disable Metrics/AbcSize
191
+ /(?<series>(?<=-)\w+$)/ =~ json["series"]
192
+ title = [json["title-main"], json["title-sub"]].compact.join " - "
193
+ release_date = parse_date(json["published-date"], str: false)
194
+ { code: pubs_export_id(json), series: series.upcase, title: title, url: json["uri"],
195
+ status: json["status"], release_date: release_date, json: json }
196
+ end
197
+
111
198
  private
112
199
 
113
200
  def code_parts(code) # rubocop:disable Metrics/MethodLength
@@ -196,22 +283,45 @@ module Relaton
196
283
  # @return [self] sorted hits collection
197
284
  #
198
285
  def sort_hits!
286
+ ref_stage = begin
287
+ st = ::Pubid::Nist::Identifier.parse(@reference)&.stage
288
+ st && st.id.to_s + st.type.to_s # the feed iteration form ("ipd")
289
+ rescue StandardError
290
+ nil
291
+ end
199
292
  @array.sort! do |a, b|
293
+ # A stage-carrying reference prefers the hit whose stage matches
294
+ # (the ipd draft sorts before the final it drafts for); a
295
+ # stage-less reference keeps the stage-less hits first (relaton#146).
296
+ cmp = hit_stage_rank(a, ref_stage) <=> hit_stage_rank(b, ref_stage)
297
+ next cmp unless cmp.zero?
298
+
200
299
  base_a, upd_a = base_and_update(a.hit[:code])
201
300
  base_b, upd_b = base_and_update(b.hit[:code])
202
301
 
203
- cmp = base_a <=> base_b
302
+ cmp = base_a.to_s <=> base_b.to_s
204
303
  next cmp unless cmp.zero?
205
304
 
206
305
  # Same base code: prefer higher /UpdN (latest update wins).
207
306
  cmp = upd_b <=> upd_a
208
307
  next cmp unless cmp.zero?
209
308
 
210
- b.hit[:release_date] <=> a.hit[:release_date]
309
+ cmp = b.hit[:release_date] <=> a.hit[:release_date]
310
+ next cmp unless cmp
311
+ cmp
211
312
  end
212
313
  self
213
314
  end
214
315
 
316
+ # The hit's stage id ("i" of an ipd, "f" of an fpd), or nil for a
317
+ # stage-less code. A parse failure reads as stage-less.
318
+ def hit_stage_rank(hit, ref_stage)
319
+ # The stage the hit carries: the CSRC feed's iteration field ("ipd",
320
+ # "fpd", "2pd") — the code itself strips stage suffixes.
321
+ hit_stage = hit.hit[:json].is_a?(Hash) ? hit.hit[:json]["iteration"] : nil
322
+ hit_stage == ref_stage ? 0 : 1 # rank 0 = the stage the reference names
323
+ end
324
+
215
325
  # Split a code like "NIST FIPS 140-2/Upd2" into ["NIST FIPS 140-2", 2].
216
326
  # Codes without an update suffix return update 0.
217
327
  def base_and_update(code)
@@ -285,61 +395,13 @@ module Relaton
285
395
  #
286
396
  # @return [Array<Relaton::Nist::Hit>] hits
287
397
  #
288
- def from_json # rubocop:disable Metrics/AbcSize
398
+ def from_json
289
399
  Util.info "Fetching from csrc.nist.gov ...", key: @reference
290
- select_data.map do |h|
291
- /(?<series>(?<=-)\w+$)/ =~ h["series"]
292
- title = [h["title-main"], h["title-sub"]].compact.join " - "
293
- release_date = parse_date(h["published-date"], str: false)
294
- Hit.new({ code: pubs_export_id(h), series: series.upcase, title: title, url: h["uri"],
295
- status: h["status"], release_date: release_date, json: h }, self)
296
- end
400
+ select_data.map { |h| Hit.new(hit_data(h), self) }
297
401
  end
298
402
 
299
- def pubs_export_id(json) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
300
- if json && json["doi"]
301
- json["doi"].sub(/^10.6028\//, "")
302
- else
303
- json["docidentifier"]
304
- end => id
305
-
306
- id.sub!(/(?:-draft\d*|\.\wpd)$/, "")
307
- id = id.gsub(".", " ").sub(/-Add(\d*)$/, ' Add.\1') if id.match?(/-Add\d*$/)
308
- # Normalize to space-separated form so pubid 2.x parses as
309
- # parsed_format=:short and renders without dots; also force the
310
- # "NIST " prefix so publisher_was_parsed is set.
311
- parse_input = id.gsub(/\bNIST\./, "NIST ")
312
- parse_input = "NIST #{parse_input}" unless parse_input.start_with?("NIST ")
313
- pid = ::Pubid::Nist::Identifier.parse(parse_input)
314
-
315
- # Canonicalize edition spelling: DOI-derived codes are already short
316
- # ("…r2"), but no-DOI drafts come from the verbose docidentifier
317
- # ("… Rev. 2") and pubid preserves that spelling via original_prefix.
318
- # Drop it so every code renders the canonical short form.
319
- pid.edition.original_prefix = nil if pid.respond_to?(:edition) && pid.edition
320
-
321
- # Stage: URI is authoritative, fall back to iteration. "final" => no stage.
322
- uri_stage = json["uri"] && json["uri"][%r{/(final|ipd|fpd|\dpd)(?:-\(\d+\))?(?:/|$)}, 1]
323
- stage_src = uri_stage || json["iteration"]
324
- case stage_src
325
- when nil, "final"
326
- # no stage — "final" means published
327
- when "fpd"
328
- pid.stage = ::Pubid::Nist::Components::Stage.new id: "f", type: "pd"
329
- when /\A(\w)pd\z/
330
- pid.stage = ::Pubid::Nist::Components::Stage.new id: Regexp.last_match(1), type: "pd"
331
- end
332
-
333
- /\/upd(?<upd>\d+)\// =~ json["uri"]
334
- # In pubid 2.x render paths the Update is read from
335
- # update_component (Components::Update), not the legacy :update
336
- # slot — assigning to :update would render as "-upd/Upd2" via the
337
- # elsif fallback path in Base#to_short_style.
338
- pid.update_component = ::Pubid::Nist::Components::Update.new(number: upd) if upd
339
- pid.to_s
340
- rescue StandardError
341
- id += " #{json["iteration"]}" if json["iteration"] && json["iteration"] != "final"
342
- id
403
+ def pubs_export_id(json)
404
+ self.class.pubs_export_id json
343
405
  end
344
406
 
345
407
  #
@@ -105,16 +105,16 @@ module Relaton
105
105
  # Fetch dates
106
106
  # @param doc [Hash]
107
107
  # @param release_date [Date]
108
- # @return [Array<Bib::Date>]
108
+ # @return [Array<Date>]
109
109
  def fetch_dates(doc, release_date) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
110
- dates = [Bib::Date.new(type: "published", at: release_date.to_s)]
110
+ dates = [Date.new(type: "published", at: release_date.to_s)]
111
111
 
112
112
  issued = safe_parse_date doc["issued-date"]
113
113
  updated = safe_parse_date doc["updated-date"]
114
- dates << Bib::Date.new(type: "updated", at: updated.to_s) if updated
114
+ dates << Date.new(type: "updated", at: updated.to_s) if updated
115
115
  obsoleted = safe_parse_date doc["obsoleted-date"]
116
- dates << Bib::Date.new(type: "obsoleted", at: obsoleted.to_s) if obsoleted
117
- dates << Bib::Date.new(type: "issued", at: issued.to_s) if issued
116
+ dates << Date.new(type: "obsoleted", at: obsoleted.to_s) if obsoleted
117
+ dates << Date.new(type: "issued", at: issued.to_s) if issued
118
118
  dates
119
119
  end
120
120
 
@@ -247,14 +247,28 @@ module Relaton
247
247
  def fetch_relations_json(hit) # rubocop:disable Metrics/AbcSize
248
248
  doc = hit[:json]
249
249
  relations = doc["supersedes"].map do |r|
250
- doc_relation "supersedes", hit[:code], r["uri"]
250
+ doc_relation "supersedes", related_id(r, hit), r["uri"]
251
251
  end
252
252
 
253
253
  relations + doc["superseded-by"].map do |r|
254
- doc_relation "updates", hit[:code], r["uri"]
254
+ doc_relation "updates", related_id(r, hit), r["uri"]
255
255
  end
256
256
  end
257
257
 
258
+ # The related document's own id. The hit's code is the record's own
259
+ # id, and a relation that names it points the record at itself. The
260
+ # entry's docidentifier drops the update and the part ("SP 800-162"
261
+ # for each of 800-162, /Upd1 and /Upd2), so derive the id the way the
262
+ # record's own is derived: from the related feed row (its DOI), or
263
+ # from the entry's uri when the feed lacks that row.
264
+ def related_id(rel, hit)
265
+ row = PubsExport.instance.data.find { |d| d["uri"] == rel["uri"] }
266
+ return HitCollection.pubs_export_id(row) if row
267
+ return hit[:code] if rel["docidentifier"].to_s.strip.empty?
268
+
269
+ HitCollection.pubs_export_id(rel)
270
+ end
271
+
258
272
  # @param type [String]
259
273
  # @param ref [String]
260
274
  # @param uri [String]
@@ -0,0 +1,251 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ relaton-oasis is a Ruby gem for retrieving and serializing OASIS Standards bibliographic metadata. It is part of the Relaton family of gems and implements the IsoBibliographicItem model for OASIS standards. Uses the lutaml-model pattern on the `lutaml-integration` branch.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ bundle exec rake spec # Run all tests (default rake task)
13
+ bundle exec rake rubocop # Run linter
14
+ bundle exec rspec # Run tests directly
15
+ bundle exec rspec spec/relaton/oasis/bibitem_spec.rb # Run a single test file
16
+ bin/console # Interactive IRB with gem loaded
17
+ ```
18
+
19
+ ## Architecture
20
+
21
+ ### Class Hierarchy (under `Relaton::Oasis`)
22
+
23
+ All model classes use the lutaml-model pattern from `relaton-bib`:
24
+
25
+ - **ItemData** (`lib/relaton/oasis/item_data.rb`) — Data model class extending `Bib::ItemData`
26
+ - **Item** (`lib/relaton/oasis/item.rb`) — Base class extending `Bib::Item`, uses `ItemData` model, adds `ext` attribute of type `Ext`
27
+ - **Bibitem** (`lib/relaton/oasis/bibitem.rb`) — Extends `Item`, includes `Bib::BibitemShared` for individual bibliography entries
28
+ - **Bibdata** (`lib/relaton/oasis/bibdata.rb`) — Extends `Item`, includes `Bib::BibdataShared` for complete bibliography records
29
+ - **Ext** (`lib/relaton/oasis/ext.rb`) — Extends `Bib::Ext`, OASIS-specific metadata: `doctype`, `technology_area`, `schema_version`
30
+ - **Doctype** (`lib/relaton/oasis/doctype.rb`) — Extends `Bib::Doctype`, valid values: `specification`, `memorandum`, `resolution`, `standard`
31
+ - **Docidentifier** (`lib/relaton/oasis/docidentifier.rb`) — Extends
32
+ `Bib::Docidentifier`, parses its `content` into a `Pubid::Oasis::Identifier`
33
+ exposed as `#pubid`; see **Identifiers and the index**
34
+
35
+ ### Bibliography (under `Relaton::Oasis`)
36
+
37
+ - **Bibliography** (`lib/relaton/oasis/bibliography.rb`) — Module with `search`/`get` class methods for fetching OASIS standards from the relaton-data-oasis GitHub repository. Delegates to private methods: `find_index_entry`, `fetch_yaml`, `parse_item`
38
+
39
+ ### Data Fetching (under `Relaton::Oasis`)
40
+
41
+ These classes scrape https://www.oasis-open.org/standards/ to produce YAML/XML data files. Loaded separately via `require "relaton/oasis/data_fetcher"`.
42
+
43
+ - **DataFetcher** (`lib/relaton/oasis/data_fetcher.rb`) — Extends `Core::DataFetcher`, orchestrates fetching all OASIS standards
44
+ - **DataParser** (`lib/relaton/oasis/data_parser.rb`) — Parses individual OASIS document nodes into `ItemData`
45
+ - **DataPartParser** (`lib/relaton/oasis/data_part_parser.rb`) — Parses multi-part OASIS document nodes into `ItemData`
46
+ - **DataParserUtils** (`lib/relaton/oasis/data_parser_utils.rb`) — Shared module included by both parsers; provides contributor parsing, docid construction, retry logic, and doctype detection
47
+
48
+ ### Identifiers and the index
49
+
50
+ #### Pubid-backed docidentifier
51
+
52
+ `Relaton::Oasis::Docidentifier` parses its `content` into a
53
+ `Pubid::Oasis::Identifier` kept in `@pubid`, while the lutaml `content`
54
+ attribute stays a plain **string** for serialization. Parsing is **soft**:
55
+ `content=` lazily requires pubid and rescues `LoadError`/`StandardError`, so a
56
+ missing gem or non-OASIS content leaves `@pubid` nil rather than raising. The
57
+ stored content carries the `OASIS ` publisher token, which is
58
+ `Pubid::Oasis::Identifier#to_s`'s default rendering, so a bare slug
59
+ (`"amqp-core"`) is *not* an OASIS printed id and leaves `@pubid` nil.
60
+
61
+ The scrapers still compose the slug by hand (`DataParserUtils#parse_docid`,
62
+ `#parse_spec`, `#parse_part`, `#parse_errata`, `DataParser#title_to_docid`) —
63
+ pubid parses the result, it does not replace those heuristics.
64
+
65
+ **`remove_part!` / `remove_date!` / `to_all_parts!` stay the inherited no-ops,
66
+ deliberately.** `Pubid::Oasis::Renderer` echoes the verbatim slug held in
67
+ `original`, so clearing `part`, `version` or `stage` cannot change the printed
68
+ id — the mutation would be invisible — and rewriting `original` would invent a
69
+ reference form OASIS does not publish. OASIS slugs are free-form with an
70
+ inconsistent internal structure, and no OASIS citation drops a part or a
71
+ version; there is no "all parts" or "most recent" spelling to render. Since
72
+ `dfbd26c72` those defaults are no-ops rather than `NotImplementedError` raises,
73
+ so `Bib::ItemData#to_all_parts` and `#to_most_recent_reference` return the item
74
+ unchanged instead of blowing up. Do not "finish" this class by adding them.
75
+ (Contrast `Relaton::Ogc::Docidentifier`, whose components really do render.)
76
+
77
+ #### `index-v2`, written and read
78
+
79
+ `INDEXFILE = "index-v2"` is the only index this flavor touches: pubid-keyed
80
+ rows (`_type: pubid:oasis:standard`), published by `relaton-data-oasis` on its
81
+ `v2` branch — the same branch `Bibliography::ENDPOINT` reads.
82
+
83
+ The producer and the consumer pass `pubid_class: ::Pubid::Oasis::Identifier`:
84
+
85
+ | Call site | Why it needs `pubid_class:` |
86
+ |---|---|
87
+ | `DataFetcher#index` | `FileIO#save` calls `to_hash` only for instances of it — without it the crawl writes v1-shaped rows under a v2 name, silently |
88
+ | `Bibliography#index` | without it the rows stay raw hashes, `FileIO#sorted` is false, and every lookup scans all 605 rows |
89
+
90
+ `Processor#remove_index_file` passes `url: true` and `file:`, and no
91
+ `pubid_class:`: the delete never reads the index (see
92
+ `lib/relaton/index/CLAUDE.md`). It used to omit `url: true`, so `Db#clear` on an
93
+ empty pool deleted `./index-v2.yaml` in the working directory and kept the
94
+ cache.
95
+
96
+ `Bibliography#index` also passes `file:`, which the pre-migration code omitted.
97
+ `file:` names the cache file (`Type` falls back to `index.yaml` without it), so
98
+ the consumer and `remove_index_file` must pass the same one, or `Db#clear`
99
+ leaves the consumer's cache in place.
100
+
101
+ **This gem never writes `index-v1`.** `relaton-data-oasis`'s crawler derives it
102
+ from the v2 rows, for relaton v2 consumers only.
103
+
104
+ #### Resolving a reference
105
+
106
+ `Bibliography#find_index_entry` is the pubid `best_match` shape:
107
+
108
+ - **`#parse_ref` supplies the publisher token.** An OASIS printed id starts
109
+ with `OASIS `, and pubid's grammar requires it, but callers write bare slugs
110
+ (`mqtt-v5.0`) and `Db#fetch` passes the reference through verbatim. Adding
111
+ the token is normalization, not identification, so it happens in the flavor —
112
+ the same place W3C normalizes a URL or a leading `TR-`.
113
+ - **The parsed pubid is passed to `Type#search`, never the string.** Search
114
+ narrows only for a non-`String` argument, so passing text would disable the
115
+ binary search however the index was built. `pubid_class:` alone fixes
116
+ nothing; both had to change together.
117
+ - **Selection is pubid's asymmetric subset match** (`Pubid::SubsetMatch`):
118
+ `pubid === row[:id]`, the reference on the left. A component the reference
119
+ omits (`version`, `stage`, `part`, `label`) matches any value; a component it
120
+ states must be equal. `number` is always stated: it is the specification name,
121
+ the whole identity of an OASIS record and the key the index bsearches on.
122
+ `#ignored` survives only for `#ranking_key`, which counts the components the
123
+ caller did not ask for. Measured over the whole fixture index (605 rows,
124
+ 6,795 comparisons): the subset match and the old
125
+ `matches?(…, ignore: #ignored)` agree on every pair.
126
+ - **There is no substring fallback.** After normalization the only unparseable
127
+ inputs are a blank string and one over pubid's 1000-character cap, and a
128
+ substring scan for `""` matches every row — worse than a miss. An
129
+ unparseable reference raises `Pubid::Errors::ParseError`
130
+ (`spec/oasis/relaton/oasis/bibliography_spec.rb` pins it), so a malformed
131
+ identifier stays distinct from an absent document.
132
+
133
+ ##### Ordering: `#ranking_key`
134
+
135
+ Ignoring a component means "don't care", so a loose reference also matches the
136
+ more specific rows. The key, in order: an **exact printed id**; then the
137
+ **newest version**, segments compared as integers; then the **least specific**
138
+ row; then the **later stage revision** (digits only — OASIS stage letters have
139
+ no ranking this code may invent); then the printed id, so the result never
140
+ depends on index order.
141
+
142
+ The exact-match rule is not decoration. Five records are a bare specification
143
+ name that also has versioned siblings — `OASIS EDXL`, `OData`, `OSLC`, `SAML`,
144
+ `WSS` — and without it three of them answered with their newest sibling instead
145
+ of themselves. Published ids are unique (0 duplicates in 605 rows), so at most
146
+ one row can score there.
147
+
148
+ Verified over the whole published index: **all 605 ids resolve to their own
149
+ record**, with and without the publisher token.
150
+
151
+ ##### One behaviour is deliberately lost
152
+
153
+ v1 matched by substring, so `OASIS amqp` resolved to some `amqp-core` record.
154
+ v2 matches identifiers, so a partial specification name no longer resolves.
155
+ That is the same semantic change IANA recorded, and
156
+ `spec/oasis/relaton/oasis/bibliography_spec.rb` asserts it so it stays a
157
+ decision rather than a surprise.
158
+
159
+ #### Unparseable ids are reported, in two places on purpose
160
+
161
+ `DataFetcher#add_to_index` indexes `docid.pubid` and, when it is nil, records
162
+ the reason in `@errors` and skips the row rather than indexing it unparsed:
163
+ `Relaton::Index` rejects the WHOLE index if a single row fails to deserialize,
164
+ and its sort calls `.root.number` on every id. The data file is still written,
165
+ so a document is unindexed, never lost.
166
+
167
+ `DataParserUtils#record_unparseable_id` records the **same** thing at the moment
168
+ the id is built. That is not redundant: the part ids that only ever become a
169
+ relation's `formattedref` (`DataParser#parse_relation`,
170
+ `DataPartParser#parse_relation`) never reach `save_doc`, so an index-time hook
171
+ alone cannot see them. Both entries are keyed on the **id string**, so a
172
+ document that is saved reports one line — the fetcher's, which also names the
173
+ output file. Keep the two keys identical.
174
+
175
+ Both routes rely on `Core::DataFetcher#report_errors` treating a **String**
176
+ value in `@errors` as the message, logged verbatim at `:error`, which is the
177
+ level the `GhIssue` channel subscribes to. So each entry becomes one line of the
178
+ crawl's "Error fetching documents" GitHub issue, and the key serves only to
179
+ de-duplicate. W3C and 3GPP use exactly this `@errors` route; ISO, ITU and NIST
180
+ reach the same issue through a side list plus a `report_errors` override.
181
+ OASIS is the only flavor that also records from its parser, for the
182
+ `formattedref` reason above.
183
+
184
+ #### The index key
185
+
186
+ `Index::Type#candidates_by_number` bsearches on `id.root.number.to_s`, and
187
+ `FileIO` sorts by that same key. `Pubid::Oasis::Identifier#number` holds the
188
+ **specification name** — "OSLC-CoreShapes", "STIX", "amqp-core" — so every
189
+ version, stage and part of one specification shares a bucket, the same shape as
190
+ an IETF draft slug or an IANA registry slug.
191
+
192
+ This needed a pubid change: the attribute was called `spec` and `number` was
193
+ never set, so every row keyed on `""` — one bucket, bsearch degraded to a
194
+ linear scan, silently, exactly the IANA and W3C trap. pubid `cfe8ee84` renames
195
+ it, with **no alias**, and it ships in the released `2.0.0.alpha.10` that
196
+ `relaton.gemspec` pins — so this flavor needs nothing from the `Gemfile`'s
197
+ temporary pubid `main` pin, unlike the nine listed there.
198
+ `spec/oasis/relaton/oasis/pubid_contract_spec.rb` is the standing guard: it
199
+ fails against any pubid that does not set `number`.
200
+
201
+ The flavor is indifferent to pubid's parse-failure class, which moved twice
202
+ during this work — `RuntimeError`, then `Parslet::ParseFailed`, and in
203
+ alpha.10 `Pubid::Errors::ParseError` (a `Parslet::ParseFailed`) with
204
+ `Pubid::Errors::InvalidInputError` (an `ArgumentError`) for a non-String or an
205
+ over-long input. `Docidentifier#content=` and `Bibliography#parse_ref` both
206
+ rescue `StandardError`, which every one of those descends from.
207
+
208
+ Measured over all 605 published rows, written through
209
+ `Relaton::Oasis::Docidentifier` into a `pubid_class:`-configured index and read
210
+ back: 605 rows round-trip byte-identically, 0 unparseable, 0 keying on `""`,
211
+ 309 buckets with the largest 25, and a `STIX` lookup narrows to 25 of 605.
212
+
213
+ ### Serialization
214
+
215
+ Supports XML (with RELAX NG schema validation via `grammars/`) and YAML round-trip serialization. Classes use `from_xml`/`to_xml` and `from_yaml`/`to_yaml` class methods inherited from the lutaml-model base.
216
+
217
+ ### Test Conventions
218
+
219
+ Tests are round-trip based: parse a fixture file, serialize back, and compare output to input. XML tests also validate against the RELAX NG schema (`../../grammar/relaton-oasis-compile.rng`) using the `ruby-jing` gem — so the suite needs a Java runtime on `PATH`. Fixtures live in `spec/oasis/fixtures/`. VCR cassettes record HTTP interactions in `spec/oasis/vcr_cassettes/`. Private methods are tested via `send(:method_name, ...)`.
220
+
221
+ ## Style
222
+
223
+ RuboCop config inherits from the Ribose OSS style guide. Target Ruby version: 3.4. Rails cops are required but disabled.
224
+
225
+ ## Testing
226
+
227
+ Run the suite with `bundle exec rake spec:oasis` from the repo root; specs live
228
+ in `spec/oasis/` and run self-contained (see the root `CLAUDE.md`).
229
+
230
+ - **Index fixture:** `spec/oasis/fixtures/index-v2.zip` — the whole published
231
+ index (605 rows, verbatim), seeded into the `Relaton::Index` pool by
232
+ `spec/oasis/support/webmock.rb` in `before(:each)`, not only
233
+ `before(:suite)`: `Index::Pool#type` replaces the pooled entry whenever
234
+ `actual?` says no, and `DataFetcher#index` asks for the same `:oasis` type
235
+ with a `file:` and no `url:`. One producer example would otherwise evict the
236
+ fixture and send every later consumer example to a real download. The pool
237
+ key is `type.upcase.to_sym`, so `:oasis` is pooled as `:OASIS`.
238
+ - It is built **with `pubid_class:`**, and `type.index` runs once offline to
239
+ force the deserialize and sort. Without that the suite would pass while
240
+ exercising something the runtime never does.
241
+ - Refresh it with `bundle exec rake spec:update_index_oasis`
242
+ (`tasks/index_fixture_oasis.rb`), which copies the published rows verbatim
243
+ and refuses a v1 source rather than writing a fixture `Relaton::Index` would
244
+ reject wholesale.
245
+ - **The fixture must be written with `File.binwrite`.** The zip entry comes
246
+ back `ASCII-8BIT`, and one published row carries a non-breaking space —
247
+ `OpenC2-MQTT-v1.0]<NBSP>-CS01`, a malformed upstream "cite as" that pubid
248
+ keeps verbatim in `original`. `File.write(..., encoding: "UTF-8")`
249
+ transcodes and raises `Encoding::UndefinedConversionError` on it. Same
250
+ invariant as `Index::FileStorage#write`; OGC's copy of this fixture code gets
251
+ away with the transcode only because its rows are pure ASCII.
@@ -0,0 +1,132 @@
1
+ = Relaton::Oasis: retrieve OASIS Standards for bibliographic use using the BibliographicItem model
2
+
3
+ RelatonOasis is a Ruby gem that implements the https://github.com/metanorma/metanorma-model-iso#iso-bibliographic-item[IsoBibliographicItem model].
4
+
5
+ You can use it to retrieve metadata of OASIS Standards from https://www.oasis-open.org/standards/ and access such metadata through the `Relaton::Oasis::ItemData` object.
6
+
7
+ == Usage
8
+
9
+ === Get standard using reference.
10
+
11
+ All the OASIS documents are parsed from https://www.oasis-open.org/standards/ and stored in a GitHub repository https://github.com/relaton/relaton-data-oasis. You can find all possible references on the index page https://relaton.github.io/relaton-data-oasis/.
12
+
13
+ [source,ruby]
14
+ ----
15
+ require 'relaton/oasis'
16
+ => true
17
+
18
+ item = Relaton::Oasis::Bibliography.get "OASIS amqp-core"
19
+ [relaton-oasis] INFO: (OASIS amqp-core) Fetching from Relaton repository ...
20
+ [relaton-oasis] INFO: (OASIS amqp-core) Found: `OASIS amqp-core`
21
+ => #<Relaton::Oasis::ItemData:0x0000000120db0fa8
22
+ ...
23
+ ----
24
+
25
+ === XML serialization
26
+
27
+ [source,ruby]
28
+ ----
29
+ item.to_xml
30
+ => "<bibitem id="OASISamqpcore" type="standard" schema-version="v1.4.1">
31
+ <fetched>2026-02-27</fetched>
32
+ <title language="en" script="Latn" type="main">Advanced Message Queueing Protocol (AMQP) v1.0</title>
33
+ <docidentifier type="OASIS" primary="true">OASIS amqp-core</docidentifier>
34
+ ...
35
+ </bibitem>"
36
+ ----
37
+
38
+ With argument `bibdata: true` it outputs XML wrapped by `bibdata` element and adds flavour `ext` element.
39
+
40
+ [source,ruby]
41
+ ----
42
+ item.to_xml bibdata: true
43
+ => "<bibdata type="standard" schema-version="v1.4.1">
44
+ <fetched>2026-02-27</fetched>
45
+ <title language="en" script="Latn" type="main">Advanced Message Queueing Protocol (AMQP) v1.0</title>
46
+ <docidentifier type="OASIS" primary="true">OASIS amqp-core</docidentifier>
47
+ ...
48
+ <ext schema-version="v1.0.1">
49
+ <doctype>standard</doctype>
50
+ <flavor>oasis</flavor>
51
+ <technology-area>Messaging</technology-area>
52
+ </ext>
53
+ </bibdata>"
54
+ ----
55
+
56
+ === Typed links
57
+
58
+ Some OASIS documents have `src` type source link.
59
+
60
+ [source,ruby]
61
+ ----
62
+ item = Relaton::Oasis::Bibliography.get "OASIS amqp-core-types-v1.0-Pt1"
63
+ [relaton-oasis] INFO: (OASIS amqp-core-types-v1.0-Pt1) Fetching from Relaton repository ...
64
+ [relaton-oasis] INFO: (OASIS amqp-core-types-v1.0-Pt1) Found: `OASIS amqp-core-types-v1.0-Pt1`
65
+ => #<Relaton::Oasis::ItemData:0x0000000123ff12d8
66
+ ...
67
+
68
+ item.source[0].type
69
+ => "src"
70
+
71
+ item.source[0].content
72
+ => "http://docs.oasis-open.org/amqp/core/v1.0/os/amqp-core-types-v1.0-os.html"
73
+ ----
74
+
75
+ === Create bibliographic item from XML
76
+
77
+ [source,ruby]
78
+ ----
79
+ Relaton::Oasis::Item.from_xml File.read("spec/fixtures/oasis_bibdata.xml")
80
+ => #<Relaton::Oasis::ItemData:0x0000000123ffeb18
81
+ ...
82
+ ----
83
+
84
+ === Create bibliographic item from YAML
85
+ [source,ruby]
86
+ ----
87
+ Relaton::Oasis::Item.from_yaml File.read("spec/fixtures/item.yaml")
88
+ => #<Relaton::Oasis::ItemData:0x000000012119a290
89
+ ...
90
+ ----
91
+
92
+ === Fetch data
93
+
94
+ The OASIS documents can be fetched from https://www.oasis-open.org/standards/ and converted into RelatonXML/BibXML/BibYAML formats:
95
+
96
+ The method `Relaton::Oasis::DataFetcher.fetch(output: "data", format: "yaml")` fetches and converts all the documents from the https://www.oasis-open.org/standards/ and saves them to the `./data` folder in YAML format.
97
+
98
+ Arguments:
99
+
100
+ - `output` - folder to save documents (default './data').
101
+ - `format` - the format in which the documents are saved. Possible formats are: `yaml`, `xml`, `bibxml` (default `yaml`).
102
+
103
+ [source,ruby]
104
+ ----
105
+ require "relaton/oasis/data_fetcher"
106
+
107
+ Relaton::Oasis::DataFetcher.fetch
108
+ Started at: 2022-03-18 18:34:06 +0100
109
+ Stopped at: 2022-03-18 18:34:12 +0100
110
+ Done in: 5 sec.
111
+ => nil
112
+ ----
113
+
114
+ === Logging
115
+
116
+ RelatonOasis uses the relaton-logger gem for logging. By default, it logs to STDOUT. To change the log levels and add other loggers, read the https://github.com/relaton/relaton-logger#usage[relaton-logger] documentation.
117
+
118
+ == Development
119
+
120
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
121
+
122
+ The index test fixture used by the tests is `spec/oasis/fixtures/index-v2.zip`, a verbatim copy of the published `index-v2.zip` from the https://github.com/relaton/relaton-data-oasis[relaton-data-oasis] repository. Refresh it with `rake spec:update_index_oasis`.
123
+
124
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
125
+
126
+ == Contributing
127
+
128
+ Bug reports and pull requests are welcome on GitHub at https://github.com/relaton/relaton-oasis.
129
+
130
+ == License
131
+
132
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).