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
@@ -0,0 +1,176 @@
1
+ # CLAUDE.md
2
+
3
+ ## Development
4
+
5
+ - `bundle install` — install dependencies
6
+ - `bundle exec rake spec` — run tests
7
+ - `bundle exec rubocop` — lint
8
+
9
+ ## Index (pubid-structured `index-v2`)
10
+
11
+ IEEE consumes and produces the **structured `index-v2`** (`INDEXFILE = "index-v2"`
12
+ in `lib/relaton/ieee.rb`): each row's `:id` is a `Pubid::Ieee::Identifier`
13
+ serialized to the `_type: pubid:ieee:*` (lutaml) form, keyed on the index via
14
+ `pubid_class: ::Pubid::Ieee::Identifier`.
15
+
16
+ - **Write side.** Unlike the doc-fetching pipeline, the index is built by a
17
+ **separate step**: `Relaton::Ieee::DataFetcher.build_index(dir:, index_file:)`
18
+ (`data_fetcher.rb`). It globs the per-document `data/*.yaml` files, extracts each
19
+ primary non-trademark IEEE docidentifier, parses it via `.pubid` (a guarded
20
+ `::Pubid::Ieee::Identifier.parse` + `from_hash(to_hash) == to_hash` round-trip
21
+ check), `add_or_update`s the object, and `save`s. Ids pubid can't parse/round-trip
22
+ are skipped; a per-file skip warning **and** a final
23
+ `IEEE index-v2.yaml: N/M indexed, K skipped (P% coverage)` line are logged so the
24
+ loss is never silent. `DataFetcher.fetch` (doc generation) is unchanged — the
25
+ data repo (`relaton-data-ieee`) calls `build_index` after obtaining `data/`.
26
+ - **Read side.** `bibliography.rb#search` passes `pubid_class:` to `find_or_create`,
27
+ parses the query with `parse_pubid` (falling back to the raw String on failure),
28
+ passes the pubid to `index.search` for number-narrowing, and picks the row by
29
+ `row[:id].to_s`. `processor.rb#remove_index_file` passes `url: true` and `file:`
30
+ only: the delete never reads the index (see `lib/relaton/index/CLAUDE.md`).
31
+
32
+ ## Identifier resolution (`RawbibIdParser`, pubid-first)
33
+
34
+ `RawbibIdParser.parse(normtitle, stdnumber)` turns a raw IEEE catalog
35
+ `normtitle` into a **`::Pubid::Ieee::Identifier`** (`idams_parser.rb`/prefilter
36
+ consume `.to_s` / `.to_s(trademark: true)` / docnumber). It is **pubid-first**:
37
+ pubid parses ~93.5% of normtitles directly; the bespoke regex case statement
38
+ (`#parse_fallback`, ~430 lines) only normalizes the messy remainder into a
39
+ canonical string pubid then parses (→ 98.7% pubid objects, 100% coverage).
40
+
41
+ - **Faithfulness guard.** `#parse` always renders the bespoke reference
42
+ (`parse_fallback(...).to_id`) and only accepts a pubid parse when it kept every
43
+ non-date digit (`#faithful?`, a date-stripped **digit** multiset — digit-level
44
+ so a benign re-split like `P1476/D4` vs `P14764` counts as faithful while a real
45
+ loss like `/D3.0`→`/D.0` does not). If no pubid parse is faithful it returns the
46
+ private fallback `Renderer` (the old bespoke id string) — so there is **zero
47
+ silent digit loss**. ~1.3% of docs keep the `Renderer`.
48
+ - **An id must have a digit in its number.** The last two fallback branches
49
+ ("publisher, number" and the `else` that uses `stdnumber`) accept a number
50
+ only when it has a digit. Without that rule the first two words of any title
51
+ became an id (`A Call to Action …` → `A Call`), and a category `stdnumber`
52
+ became one too (`White Paper` → `IEEE White Paper`). Since the id is the
53
+ output filename, 176 records shared 42 files and 134 were overwritten. Now
54
+ `parse` returns nil for them, and `DataFetcher#parse_entry` skips them with
55
+ the `PubID parse error` warning. A real standard with a title-like
56
+ `normtitle` falls through to `stdnumber` and keeps its id
57
+ (`IEEE Standard for Ethernet …` / `802.3bm-2015` → `IEEE Std 802.3bm-2015`);
58
+ 16 such standards were hidden by the old branch. **The digit rule alone is
59
+ not enough** — see `#no_standard_number?` under **Fetch robustness**.
60
+ - **No public `PubId` class.** The old `Relaton::Ieee::PubId` is gone; its
61
+ rendering survives only as the private `RawbibIdParser::Renderer` used by the
62
+ fallback. pubid (`::Pubid::Ieee::Identifier`) is the identifier abstraction.
63
+ - **Coordination with pubid.** IEEE ids now render in pubid's canonical form
64
+ (`/D-5`→`/D5`, `/Amd 5-2012` with a space, `802.16-2012 - Redline`, trademark
65
+ `™`/`®` attached to the document **number**, full corrigendum expansion). This
66
+ required a batch of pubid fixes (draft-verbatim, redline, revision incl.
67
+ numbered `RevN`, edition, trademark, historical formats, update_codes one-offs).
68
+ `Core::DataFetcher#output_file` also sanitizes commas so pubid's `", Mar 2011"`
69
+ dates don't leak into filenames. The fallback `Renderer`'s suffix strings are
70
+ **not** the canonical spellings and shouldn't be "fixed" to match: they are the
71
+ input to `to_id`, which pubid re-parses, so `/D-N`, `/R-N`, `/E-N` are
72
+ deliberately the *parseable* forms pubid canonicalizes to `/DN` etc. The
73
+ **trademark** rendering is the exception that must track pubid: `IdamsParser`
74
+ emits the `scope: trademark` docidentifier with `to_s(trademark: true)` on
75
+ whatever `parse` returned, so the `Renderer`'s ®/™ rule
76
+ (`Renderer::Id::REGISTERED_SERIES`) mirrors pubid#322 — series read off the
77
+ *number* with a leading project `P` stripped, `8802` ungated, everything else
78
+ gated on IEEE being a (co)publisher. Otherwise a `P802.x` draft would print ™
79
+ down the fallback path and ® down the pubid path. The one
80
+ exception is `amd_to_s`, which emits the **spaced** `/Amd N`: pubid parses both
81
+ spellings, so the spaced one costs nothing and keeps the rendering canonical
82
+ for the ~1.3% of ids that miss the faithfulness guard and keep the `Renderer`
83
+ string. Changing any of these also moves the `extract_index_entry` dedup key in
84
+ `data_fetcher.rb`.
85
+
86
+ ## Fetch robustness (no silent corpus loss)
87
+
88
+ Amendment docnumbers can be pathologically long — pubid's `to_s` embeds the full
89
+ "(Amendment to … as amended by …)" clause (300+ chars). Two guards keep one such
90
+ id from silently halving the crawl (see the `data_fetcher.rb` history):
91
+
92
+ - **A record with no standard number is skipped.** `#no_standard_number?` is
93
+ true when `publicationsubtype` is in `NON_STANDARD_SUBTYPES` (`Whitepapers`,
94
+ `Research Documents`) **and** `stdnumber` has no digit. IEEE gives such a
95
+ document no designation and cites it by title, so every id built from it is
96
+ a title fragment. The digit rule in `RawbibIdParser` cannot catch all of
97
+ them, because pubid parses a digit out of the title itself
98
+ (`IEEE 802 Nendica Report: …` → `IEEE Std 802`, three documents; `IEEE 3D
99
+ Body Processing …` → `IEEE Std 3D`, three more). Both conditions are
100
+ required, so a record of such a subtype that does carry a number keeps it
101
+ (`Amendment 1:2011 to SMPTE ST 430-9:2008`). The check runs in **two**
102
+ places: `#parse_entry` (with a warning) and `#extract_index_entry` — the
103
+ prefilter groups files by docnumber and keeps the newest of each group, so a
104
+ white paper that reads as `IEEE Std 802` would otherwise join the real
105
+ standard's group and push its file out. Measured on the raw corpus: 157
106
+ records skipped, and no kept document has a digit-less id. The subtype match
107
+ is exact, which the data supports — all 570,744 raw files carry one of three
108
+ spellings (`Standard Docs` 564,611, `Whitepapers` 5,240,
109
+ `Research Documents` 891), so a new spelling is the one thing to watch for.
110
+ - **Bounded filenames.** `Core::DataFetcher#output_file` caps the basename at
111
+ `MAX_BASENAME_BYTES` (255): when the sanitized id exceeds the OS limit it
112
+ byte-truncates (`byteslice(…).scrub("")`, valid UTF-8) and appends a 12-char
113
+ SHA1 of the **full** docid — bounded, unique, and **deterministic** so
114
+ `save_doc`/`reconcile_staged_outputs`/`read_bib` all resolve the same name.
115
+ Without this, `File.write` raised `Errno::ENAMETOOLONG`.
116
+ - **Per-document commit guard.** Both the serial (`run_shard`) and parallel
117
+ (`spawn_batch` fork) paths route through one `commit_entry` helper that wraps
118
+ `commit_doc` in a `rescue` (logs via `Util.error`, continues). A single bad doc
119
+ no longer aborts a serial crawl, and a forked worker still reaches its
120
+ `File.binwrite(state_path, …)` so `merge_state_files` sees its `saved_writes`
121
+ and `reconcile_staged_outputs` doesn't delete the whole batch's staged files.
122
+ Reconcile also logs the straggler-cleanup count so an anomaly isn't silent.
123
+ - **A title-fragment id can still slip past `#no_standard_number?`.**
124
+ `#no_standard_number?` only fires for a `publicationsubtype` in
125
+ `NON_STANDARD_SUBTYPES`, and a generic mid-tier `RawbibIdParser` fallback
126
+ branch meant for a real catalog number (e.g. "publisher, number, part",
127
+ built for `"IEEE 802.3"`) can still read a short, numbered title as one
128
+ (`"WEB 3.0: The Evolution of ..."` → `WEB 3.0`) for a subtype spelling that
129
+ list doesn't cover. `RawbibIdParser.fabricated_title_id?(pid, title,
130
+ isbn_present)` is a second, subtype-agnostic guard on the *resolved id*
131
+ itself, called from both `IdamsParser#pubid` (full parse, `title` from
132
+ `@doc.btitle` — distinct from `normtitle`, which for a genuine standard
133
+ holds its designation, not prose) and `DataFetcher#extract_index_entry`
134
+ (cheap-parse prefilter, `title` the *last* `<title>` in the raw XML — the
135
+ first is always the top-level designation, present even for a real
136
+ standard). It must run in both places for the same reason
137
+ `#no_standard_number?` does: the prefilter groups files by docnumber before
138
+ the full parse ever runs, so a fabricated id left un-nilled there still
139
+ merges a whitepaper's group with an unrelated document's and can silently
140
+ drop a file from the corpus.
141
+ **The check is on the id's *class*, not (only) the title prefix:** it
142
+ requires `pid.is_a?(Renderer)` — i.e. the unvalidated bespoke fallback
143
+ string, never a real `::Pubid::Ieee::Identifier` that pubid's own grammar
144
+ actually parsed — **and** the title is a literal prefix of `pid.to_s`
145
+ **and** the document carries a real ISBN. Title-prefix and ISBN alone are
146
+ not enough: measured over 9,091 successfully-parsed raw-corpus records, 7
147
+ genuine, correctly-resolved standards have a title that literally begins
148
+ with their own real designation and also carry an ISBN (`"IEEE Std
149
+ 1636.99-2013, IEEE Standard for ..."`) — an earlier version of this guard
150
+ without the `Renderer` restriction nulled out their real ids. All 7 resolve
151
+ to a real `Pubid::Ieee::Identifier`, never a `Renderer`, so the class check
152
+ is what tells them apart from the 25 known fabricated ids in a
153
+ `relaton-data-ieee` audit, all of which are `Renderer`s. A flagged record is
154
+ treated like any other unparseable id: `docnumber` is nil and
155
+ `DataFetcher#parse_entry` skips it with the `PubID parse error` warning.
156
+
157
+ ## Testing
158
+
159
+ - **Framework:** RSpec with VCR cassettes and WebMock.
160
+ - **Index fixture:** `spec/ieee/fixtures/index-v2.zip` (a small structured index for
161
+ the read-spec targets) is deserialized through `::Pubid::Ieee::Identifier` and
162
+ pre-loaded into the `Relaton::Index` pool in `before(:suite)`
163
+ (`spec/ieee/support/webmock.rb`, NIST recipe). `index_builder_spec.rb` covers
164
+ `build_index` (structured rows + skip logging).
165
+ When a document 404s after a cassette refresh, the fixture has gone stale
166
+ against the republished index: copy the matching row **wholesale** (`:id:` *and*
167
+ `:file:`) out of the live `…/relaton-data-ieee/refs/heads/v2/index-v2.zip`,
168
+ rewrite the zip with the single `index-v2.yaml` entry, and **delete the cassette**
169
+ so it re-records — `record: :once` will not re-record a *changed* request URI, it
170
+ raises `UnhandledHTTPRequestError` instead.
171
+ - **Expected-XML fixtures self-heal only when absent.** `ieee_spec.rb` and
172
+ `idams_parser_spec.rb` write `fixtures/<name>.xml` with `unless File.exist?`, so
173
+ reconciling a pubid rendering change means deleting the fixture, re-running, and
174
+ reviewing the diff — not hand-editing it.
175
+ - **VCR cassettes:** `spec/ieee/vcr_cassettes/` — `index-v2.zip` downloads are
176
+ ignored by VCR (handled by the fixture).
@@ -0,0 +1,131 @@
1
+ = Relaton::Ieee
2
+
3
+ RelatonIeee is a Ruby gem that implements the https://github.com/relaton/relaton-model-ieee#ieee-bibliographic-item-model[IeeeBibliographicItem model].
4
+
5
+ You can use it to retrieve metadata of IEEE Standards from standards.ieee.org, and access such metadata through the `Relaton::Ieee::ItemData` object.
6
+
7
+ == Usage
8
+
9
+ === Search for a standard using keywords
10
+
11
+ [source,ruby]
12
+ ----
13
+ require 'relaton/ieee'
14
+ => true
15
+
16
+ item = Relaton::Ieee::Bibliography.search("IEEE Std 528-2019")
17
+ => #<Relaton::Ieee::ItemData:0x000000012891cf70
18
+ ...
19
+ ----
20
+
21
+ === XML serialization
22
+
23
+ [source,ruby]
24
+ ----
25
+ item.to_xml
26
+ => "<bibitem id="IEEEStd5282019" type="standard" schema-version="v1.4.1">
27
+ <fetched>2026-01-30</fetched>
28
+ <title type="main">IEEE Standard for Inertial Sensor Terminology</title>
29
+ <uri type="src">https://ieeexplore.ieee.org/document/8863799</uri>
30
+ <docidentifier type="IEEE" primary="true">IEEE Std 528-2019</docidentifier>
31
+ ...
32
+ </bibitem>"
33
+ ----
34
+
35
+ With argument `bibdata: true` it ouputs XML wrapped by `bibdata` element and adds flavor `ext` element.
36
+
37
+ [source,ruby]
38
+ ----
39
+ item.to_xml bibdata: true
40
+ => "<bibdata type="standard" schema-version="v1.4.1">
41
+ <fetched>2026-01-30</fetched>
42
+ <title type="main">IEEE Standard for Inertial Sensor Terminology</title>
43
+ <uri type="src">https://ieeexplore.ieee.org/document/8863799</uri>
44
+ <docidentifier type="IEEE" primary="true">IEEE Std 528-2019</docidentifier>
45
+ ...
46
+ <ext schema-version="v1.0.1">
47
+ <doctype>standard</doctype>
48
+ <flavor>ieee</flavor>
49
+ ...
50
+ </ext>
51
+ </bibdata>"
52
+ ----
53
+
54
+ === Get document by reference
55
+ [source,ruby]
56
+ ----
57
+ Relaton::Ieee::Bibliography.get("IEEE Std 528-2019")
58
+ [relaton-ieee] INFO: (IEEE Std 528-2019) Fetching from Relaton repository ...
59
+ [relaton-ieee] INFO: (IEEE Std 528-2019) Found: `IEEE Std 528-2019`
60
+ => #<Relaton::Ieee::ItemData:0x00000001288b9380
61
+ ...
62
+ ----
63
+
64
+ === Typed source links
65
+
66
+ Each IEEE document has `src` type link.
67
+
68
+ [source,ruby]
69
+ ----
70
+ item.source.first.type
71
+ => "src"
72
+
73
+ item.source.first.content
74
+ => "https://ieeexplore.ieee.org/document/8863799"
75
+ ----
76
+
77
+ === Create bibliographic item from XML
78
+ [source,ruby]
79
+ ----
80
+ Relaton::Ieee::Item.from_xml File.read "spec/fixtures/ieee_528_2019.xml"
81
+ => #<Relaton::Ieee::ItemData:0x0000000128815a00
82
+ ...
83
+ ----
84
+
85
+ === Create bibliographic item from YAML
86
+ [source,ruby]
87
+ ----
88
+ Relaton::Ieee::Item.from_yaml File.read('spec/fixtures/item.yaml')
89
+ => #<Relaton::Ieee::ItemData:0x0000000122f70c88
90
+ ...
91
+ ----
92
+
93
+ === Fetch data
94
+
95
+ There is an IEEE dataset https://github.com/relaton/ieee-rawbib which can be converted into BibXML/BibYAML formats. The dataset needs to be placed into the local directory.
96
+
97
+ The method `Relaton::Ieee::DataFetcher.fetch(source, output: "data", format: "yaml")` converts all the documents from the local `ieee-rawbib` directory and saves them to the `./data` folder in YAML format.
98
+ Arguments:
99
+
100
+ - `source` - a name of data source (default 'ieee-rawbib').
101
+ - `output` - folder to save documents (default './data').
102
+ - `format` - the format in which the documents are saved. Possible formats are: `yaml`, `xml` (default `yaml`).
103
+
104
+ [source,ruby]
105
+ ----
106
+ Relaton::Ieee::DataFetcher.fetch
107
+ Started at: 2021-09-24 17:55:07 +0200
108
+ Stopped at: 2021-09-24 17:57:30 +0200
109
+ Done in: 143 sec.
110
+ => nil
111
+ ----
112
+
113
+ === Logging
114
+
115
+ RelatonIeee 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.
116
+
117
+ == Development
118
+
119
+ 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.
120
+
121
+ To update the index test fixture (used by tests), run `rake spec:update_index`. This downloads the latest `index-v1.zip` from the https://github.com/relaton/relaton-data-ieee[relaton-data-ieee] repository.
122
+
123
+ 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 tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
124
+
125
+ == Contributing
126
+
127
+ Bug reports and pull requests are welcome on GitHub at https://github.com/relaton/relaton_ieee.
128
+
129
+ == License
130
+
131
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,357 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What This Gem Does
6
+
7
+ The `Relaton::Ietf` flavor fetches and parses IETF bibliographic data (RFCs, Internet-Drafts, BCPs, FYIs, STDs) into the Relaton data model. Models are `lutaml-model`-based.
8
+
9
+ ## Commands
10
+
11
+ The suite runs from its own directory (`spec/ietf/`), as every flavor suite does.
12
+
13
+ ```bash
14
+ bundle exec rake spec:ietf # run this flavor's suite
15
+ cd spec/ietf && bundle exec rspec -I . relaton/ietf/bibxml_parser_spec.rb # single file
16
+ cd spec/ietf && bundle exec rspec -I . relaton/ietf/rfc/entry_spec.rb:19 # single example
17
+ cd spec/ietf && bundle exec rspec -I . -e "creates primary docid" # by description
18
+ bundle exec rubocop # lint
19
+ ```
20
+
21
+ ## Architecture
22
+
23
+ ### Data Model (Lutaml::Model)
24
+
25
+ All models use `Lutaml::Model::Serializable` with `attribute` declarations and `xml do ... end` blocks for serialization. The IETF classes extend base `Relaton::Bib` classes:
26
+
27
+ - `Relaton::Ietf::ItemData < Bib::ItemData` — core bibliographic data
28
+ - `Relaton::Ietf::Item < Bib::Item` — adds `ext: Ext` attribute
29
+ - `Relaton::Ietf::Bibdata < Item` / `Bibitem < Item` — include shared serialization concerns
30
+ - `Relaton::Ietf::Ext` — IETF extensions: `doctype`, `flavor`, `stream`, `area`, `ipr`, `pi`
31
+ - `Relaton::Ietf::Doctype` — types: `"rfc"`, `"internet-draft"`
32
+
33
+ ### Namespace Resolution
34
+
35
+ Converter classes use `Bib::NamespaceHelper` which resolves `namespace` by taking the first two segments of the class name. For `Relaton::Ietf::BibXMLParser::FromRfcxml`, `namespace` returns `Relaton::Ietf`, so `namespace::ItemData` → `Relaton::Ietf::ItemData`, `namespace::Ext` → `Relaton::Ietf::Ext`, etc.
36
+
37
+ ### Key Flows
38
+
39
+ 1. **Single document lookup**: `Processor#get` → `Bibliography.get(code)` → `Scraper.scrape_page` → parses the reference with `Pubid::Ietf` → searches the one combined `index-v2` from `relaton-data-ietf` → fetches that row's YAML
40
+
41
+ 2. **Bulk data fetching**: `DataFetcher` extends `Relaton::Core::DataFetcher` with three datasets:
42
+ - `ietf-rfcsubseries` / `ietf-rfc-entries`: parse `rfc-index.xml` via `Rfc::Index` / `Rfc::Entry#to_item`
43
+ - `ietf-internet-drafts`: parse local BibXML files via `BibXMLParser.parse`
44
+
45
+ 3. **BibXML parsing** (`BibXMLParser` module):
46
+ - `parse(xml)` — parses `<reference>` elements via `FromRfcxml` converter
47
+ - `parse_rfc(xml)` — parses `<rfc>` root documents via `FromRfc` converter
48
+ - Converters inherit from `Bib::Converter::BibXml::FromRfcxml` with IETF overrides for contributor/person/org handling
49
+
50
+ 4. **RFC Index parsing** (`Rfc::Entry#to_item`): converts RFC editor index entries (BCP/FYI/STD/RFC) into `ItemData`, handling subseries vs full RFC entries differently
51
+
52
+ ### Converter Inheritance Chain
53
+
54
+ ```
55
+ Bib::Converter::BibXml::FromRfcxml # base: handles <reference> generically
56
+ └── Ietf::BibXMLParser::FromRfcxml # IETF overrides: publisher, org recognition, person names
57
+ └── Ietf::BibXMLParser::FromRfc # <rfc> root: uses doc_name instead of anchor, no ref-level series_info
58
+ ```
59
+
60
+ `FromRfc` must override all methods that access `@reference.anchor`, `@reference.target`, `@reference.format`, or `@reference.series_info` since `Rfcxml::V3::Rfc` lacks these attributes (unlike `Rfcxml::V3::Reference`).
61
+
62
+ ## Unified index (relaton#109)
63
+
64
+ Producer and consumer are both on the pubid `index-v2` now. `DataFetcher` writes
65
+ it; `Scraper` reads one combined index from `relaton-data-ietf` — replacing the
66
+ three per-type `index-v1` files (`:RFC`/`:RSS`/`:IDS`) it used to read from
67
+ `relaton-data-{rfcs,rfcsubseries,ids}`. Those three keep publishing `index-v1`
68
+ untouched, for released relatons.
69
+
70
+ `INDEXFILE` is a single constant again (`index-v2`); the `INDEXFILE_V2` that
71
+ existed while the two halves were split is gone.
72
+
73
+ Decisions taken (2026-08-18 ... 2026-08-24):
74
+
75
+ | | |
76
+ |---|---|
77
+ | Index structure | **unchanged** - `:id` + `:file` only. No `docids[]`, no summary fields; a third key invalidates the whole index for every released gem |
78
+ | `:id` shape | structured pubid hash (`Pubid::Ietf::Identifier#to_hash`) |
79
+ | Row per record | keyed by the record's **primary** docidentifier. A sub-series id resolves to its own container record, *not* to a constituent RFC - 42 of 365 sub-series include more than one RFC; consumers follow `relation: includes` |
80
+ | DOI ids | omitted - not `pubid:ietf`-representable, and one unparseable row kills the index. DOIs are served by the `doi` flavor |
81
+ | Who builds it | `DataFetcher`, during the crawl - not a separate pass over the written records, which would be a second answer to "what is this record's id" |
82
+ | Three type repos | untouched: they pin `relaton/relaton-ietf`, not this monorepo, so they keep emitting `index-v1` |
83
+
84
+ **Resolved 2026-08-26.** `relaton-data-ietf`'s RFC and sub-series records were
85
+ schema-invalid — missing `ext.doctype`, and RFC committee organizations carrying
86
+ `subdivision` with no `name` — because that repo converted the `ietf-tools`
87
+ v1.2.3 mirrors rather than crawling. It now runs `Relaton::Ietf::DataFetcher`
88
+ directly, which synthesises `doctype`, `status`, `stream`, the organization names
89
+ and the resolved WG titles. The two `pending` examples in `ietf_spec.rb` reported
90
+ `FIXED` on the first run against the new corpus and have been removed.
91
+
92
+ Two consequences of that migration to know about: record filenames are now
93
+ lowercase *and* unpadded (`data/RFC8341.yaml` -> `data/rfc8341.yaml`,
94
+ `data/STD0066.yaml` -> `data/std66.yaml`), since `output_file` derives them from
95
+ the docnumber; and the record content changed enough that the three XML fixtures
96
+ had to be regenerated.
97
+
98
+ ### The consumer: `Scraper` queries with parsed identifiers
99
+
100
+ ```ruby
101
+ Relaton::Index.find_or_create(
102
+ :IETF, url: "#{IETF}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml",
103
+ pubid_class: ::Pubid::Ietf::Identifier
104
+ )
105
+ ```
106
+
107
+ `parse_id` turns the reference into a `Pubid::Ietf::Identifier` before searching,
108
+ and **that is not cosmetic**: `Type#search_candidates` narrows only for non-String
109
+ queries, so a String falls into `match_item`'s `item[:id].to_s.include?(id)`, which
110
+ renders every pubid in the index on every lookup. Measured against the live
111
+ 177k-row index: **~43 s per reference with a String, sub-millisecond with a parsed
112
+ id.** A String query also returns *nothing* if `pubid_class:` is omitted, since the
113
+ rows would then be raw hashes.
114
+
115
+ `parse_id` also normalises the Internet-Draft spellings - `I-D.<slug>` and
116
+ `I-D <slug>` - onto the `draft-...` form, adding the `draft-` stem when absent
117
+ (`I-D.ietf-quic-transport`, the bibxml anchor and the `docnumber` records carry).
118
+ The old plain-string index matched that by substring; matching is exact now.
119
+ A reference pubid cannot parse returns nil, so `Bibliography.get "CN 8341"` logs
120
+ "Not found." rather than raising.
121
+
122
+ ### The producer: `DataFetcher` writes `index-v2`
123
+
124
+ `DataFetcher` builds the index during the crawl, from the identifier it already
125
+ holds for each record — `record_index_entry` parses the primary docidentifier and
126
+ `add_or_update`s the pubid:
127
+
128
+ ```ruby
129
+ Relaton::Index.find_or_create(
130
+ :IETF, file: "#{INDEXFILE}.yaml", pubid_class: ::Pubid::Ietf::Identifier
131
+ )
132
+ ```
133
+
134
+ **Both halves are load-bearing.** `FileIO#save` serialises an id to its `_type:`
135
+ hash only when it is an instance of the configured `pubid_class`, so passing a
136
+ String to a `pubid_class:` index writes a v1-shaped file under a v2 name — no
137
+ error, just an index that never narrows.
138
+
139
+ Three properties worth keeping:
140
+
141
+ - **Key on the primary docidentifier**, which `serialize_and_write` already
142
+ picks. Not the first entry: draft records carry an `I-D.foo` anchor id (not a
143
+ pubid grammar) and their unversioned series id, which is the *primary* of a
144
+ separate aggregator record — keying on either collides on one `id.to_s` and
145
+ `add_or_update` silently overwrites `:file`.
146
+ - **DOIs stay out.** `10.17487/RFC3986` is not a `pubid:ietf` identifier, and one
147
+ unparseable row invalidates the whole index. The primary is never the DOI, so
148
+ this is already right — don't "improve" it into indexing every docidentifier.
149
+ - **Sub-series key to their own record.** `STD 66` → the STD container, not a
150
+ constituent RFC: 42 of the 365 published sub-series include more than one, so
151
+ there is no single document to alias to. Consumers follow `relation: includes`.
152
+
153
+ A record whose id pubid rejects is written but not indexed, warned about, and
154
+ tallied for a summary at the end of the crawl. The load is all-or-nothing —
155
+ `deserialize_id` raises on the first bad id and `load_index` then rejects the
156
+ entire index — so one malformed upstream record must cost one document, never
157
+ every lookup. All 176,862 published ids parse today; this guards against drift.
158
+
159
+ **Blast radius, worth re-checking before touching this.** The six data repos
160
+ feeding today's released consumers do *not* use this monorepo: `ietf-tools/*` pin
161
+ `relaton/relaton-ietf` @ `main` (`RelatonIetf::DataFetcher`), and
162
+ `relaton/relaton-data-{rfcs,rfcsubseries,ids}` pin it @ `lutaml-integration` —
163
+ both branches carry their own `INDEXFILE = "index-v1"` and their own fetcher. Only
164
+ `relaton/relaton-data-ietf` runs this one. So the producer flip needs no
165
+ dual-write and no cutover window.
166
+
167
+ ### Still outstanding
168
+
169
+ Retiring `relaton-data-{rfcs,rfcsubseries,ids}`'s `index-v1` files, and their
170
+ `data-index/configs.yml` rows, waits until a relaton carrying this change has
171
+ shipped — see `/work/HANDOFFS/relaton__support__ietf-index-config-and-cron.md`.
172
+
173
+ ## Filename collisions: workers write, the parent settles the names
174
+
175
+ `Core::DataFetcher#output_file` sanitizes `.`, `,`, `/`, `:`, `(`, `)`, `-` and
176
+ whitespace all to `-`, so two **distinct** docids can want one path. This flavor
177
+ is the only one that writes from forked processes, so it is the only one where
178
+ that could destroy a record without a word.
179
+
180
+ It did. In the first production crawl of `relaton-data-ietf`,
181
+ `data/draft-ietf-6man-hbh-processing.yaml` — the aggregator for a draft with 21
182
+ versions — was written and then overwritten by a singleton built from the
183
+ malformed sibling id `draft-ietf-6man-hbh-processing-`, which sanitizes to the
184
+ same name. The published corpus came out 177,362 index rows over 177,361 files,
185
+ and nothing in the log said so. Exactly one such group exists in that corpus;
186
+ the mechanism, not the blast radius, is the problem.
187
+
188
+ Three pieces close it, and all three are load-bearing:
189
+
190
+ 1. **`@cross_process = true`, set in `fetch_ieft_internet_drafts` before the
191
+ first `parallelize`** so the forked children inherit it. It tells
192
+ `Core::DataFetcher#write_unique` that an existing file may belong to a peer
193
+ and must never be overwritten.
194
+ 2. **`serialize_and_write` writes through `write_unique`** and reports two extra
195
+ keys: `docid` (the id the file was written under) and `plain_file` (the name
196
+ it would have taken uncontested). `docid` is **not** `index_id` — `id` falls
197
+ back through `docnumber` and `formattedref`, so on the RFC path the two
198
+ differ (`RFC0001` vs `RFC 1`, i.e. stems `rfc0001` and `rfc-1`). Keying the
199
+ reconciliation on `index_id` renames records to the wrong names.
200
+ 3. **`reconcile_output_files`, in the parent, before `record_index_entry`.** A
201
+ worker only learns that a path was taken, never which clashing docid deserves
202
+ it, so left alone the winner would follow the race and the two filenames would
203
+ swap between crawls. The parent sees every docid, so it decides: within a
204
+ group of records that wanted one path, the alphabetically first docid keeps
205
+ it and the rest take their digest variant.
206
+
207
+ Details of (3) that are easy to get wrong:
208
+
209
+ - It runs over **every** group, not just clashing ones. A lone record that fell
210
+ back to a digest path must get its plain name back, or the published filename
211
+ churns and the old file is orphaned. `next if r[:file] == target` makes that
212
+ free for ~100% of records.
213
+ - **Losers move first.** A loser may be sitting *on* the plain path; moving the
214
+ winner there first would destroy it.
215
+ - Two results for the **same** docid are collapsed, not split: the stray file is
216
+ deleted and one warning is emitted, which is what the crawl did before. The
217
+ stray may already be **gone** — the fallback name is keyed on the docid, so two
218
+ workers that both lost the race to the plain path land on one name and the
219
+ first of them renames it onto the target. The result must follow the document
220
+ to the target anyway, or the index gets a row pointing at nothing.
221
+ - Sorting *unique docids* leaves no tie for Ruby's unstable `sort_by` to resolve
222
+ differently between runs.
223
+ - A failed rename warns and carries on. A multi-hour crawl must not die at the
224
+ last step.
225
+
226
+ `report_collisions` restates the total once at the end of the crawl, beside
227
+ `report_unparsed` and `report_unindexed` — one line for a crawl that writes
228
+ ~177k records.
229
+
230
+ The sequential paths (`fetch_ieft_rfcsubseries`, `fetch_ieft_rfcs`) get no
231
+ reconciliation pass and need none: one process, so `unique_output_file` is
232
+ already deterministic there. They do now emit a digest filename for a genuine
233
+ cross-docid collision instead of overwriting, which follows `rfc-index`
234
+ iteration order — stable for a given input.
235
+
236
+ ## Thin records inherit from their constituents
237
+
238
+ Two builders produce records for documents that have **no upstream source of
239
+ their own**, and both would otherwise publish without date, doctype or source:
240
+
241
+ | Builder | File | Inherits from |
242
+ |---|---|---|
243
+ | `to_subseries_item` | `rfc/entry.rb` | the newest RFC in its `is-also` |
244
+ | `build_unversioned_doc` | `data_fetcher.rb` | the newest version in `sorted` |
245
+
246
+ This is **not** a parsing gap to go fix in the XML mapping. A sub-series entry in
247
+ `rfc-index.xml` is only a pointer:
248
+
249
+ ```xml
250
+ <bcp-entry>
251
+ <doc-id>BCP0003</doc-id>
252
+ <is-also><doc-id>RFC1915</doc-id></is-also>
253
+ </bcp-entry>
254
+ ```
255
+
256
+ The RFC Editor publishes no date, title, author or status for it, because a
257
+ sub-series *has* no metadata of its own — it is a label on one or more RFCs.
258
+ Unversioned draft aggregators are further still from an upstream document: they
259
+ are synthesised from the versions found on disk.
260
+
261
+ Neither site needs a second pass or another read of the corpus — `rfc_index` and
262
+ `sorted` already hold the constituents at build time. (A downstream
263
+ implementation in `relaton-data-ietf`'s crawler did use a second pass over
264
+ written files, because its groups could span source repos; that constraint does
265
+ not exist here.)
266
+
267
+ **Constituent lookup is normalised on both sides, deliberately.**
268
+ `Entry.squish` folds case and strips whitespace and dots. Relation targets and the
269
+ docids they reference disagree in the published corpus (`dyndNS` vs `dyndns`),
270
+ which left 28 documents undated downstream on a strict lookup — and, in
271
+ `build_relations`, silently downgraded a full constituent bibitem to a minimal
272
+ one.
273
+
274
+ `rfc_index` is therefore **keyed by `Entry.squish(doc_id)`, and the caller builds
275
+ it** — `fetch_ieft_rfcsubseries` does so once for the crawl. Normalising inside
276
+ `Entry` instead would rebuild a ~9,800-row table for each of the 367 sub-series
277
+ (measured: ~161 MB retained, ~6 s), all of it the same table.
278
+
279
+ Why it matters beyond tidiness: the Pages index sorts by date, so undated records
280
+ sort as one undifferentiated block, and a record with no `ext.doctype` renders
281
+ with no document type at all.
282
+
283
+ Inherited values are `dup`'d rather than shared with the constituent's own bib —
284
+ aliasing them would make a later edit to the aggregator mutate the `-NN` record.
285
+
286
+ Verified against the live `rfc-index.xml`: **367/367 sub-series dated**, 367/367
287
+ with full constituent bibitems, in 1.4 s; `BCP 3` → 1996-02, `STD 51` → 1994-07,
288
+ `STD 66` → 2005-01 (RFC 3986's date).
289
+
290
+ ## bibxml encoding: transcode, don't scrub, don't skip
291
+
292
+ Some bibxml files declare `encoding='UTF-8'` and carry **Windows-1252** bytes —
293
+ smart quotes and accented Latin letters. `File.read(encoding: "UTF-8")` only
294
+ *tags* a string; it validates nothing, so those reached lutaml as invalid UTF-8
295
+ and `Rfcxml::V3::Reference.from_xml` raised `Lutaml::Model::InvalidFormatError`.
296
+
297
+ `read_bibxml` transcodes them (`data_fetcher.rb`). Three things make that the
298
+ right call rather than `scrub`:
299
+
300
+ - **All of them decode losslessly as CP1252** — 125 of the ~122k published files
301
+ are affected, 0 unmappable.
302
+ - **The bytes present prove CP1252, not Latin-1.** `0x91`–`0x94` (`‘ ’ “ ”`) are
303
+ control characters in Latin-1.
304
+ - **`scrub` is lossy.** It replaces each byte with `U+FFFD`: `client’s` becomes
305
+ `client�s`, `Muñoz` becomes `Mu�oz`. Author surnames are among the
306
+ affected fields. Verified on a real file — scrub and transcode yield the same
307
+ *length*, so a length check will not catch the damage.
308
+
309
+ Don't copy `lib/relaton/bipm/si_brochure_parser.rb:33-35`, whose
310
+ `force_encoding` guard is a no-op. The `scrub` idiom at
311
+ `lib/relaton/itu/data_crawler_r.rb:588-599` is right *there* because it repairs
312
+ scraped HTML with no recoverable original; here there is one.
313
+
314
+ ### Why the rescue is per file, and why the tally is in the parent
315
+
316
+ `parse_bibxml` rescues `StandardError` — not a narrow list. lutaml raises
317
+ `InvalidFormatError` on bad bytes, but the converter also runs regexes over
318
+ parsed text (`parse_surname_initials` and friends), which raise
319
+ `ArgumentError: invalid byte sequence` on anything that slips through.
320
+
321
+ It has to be **inside** the worker. The drafts path runs under `Parallel.map`,
322
+ which discards every result from the pass when a worker raises — so one bad file
323
+ out of ~167k meant no index at all, plus the already-written records orphaned on
324
+ disk, because `record_index_entry` and `index.save` only run after both passes
325
+ return. A rescue around `parallelize` would salvage "the crawl didn't die", not
326
+ "the other 166,999 files got indexed".
327
+
328
+ The skip therefore rides back as `{ unparsed: path, error: }` and is counted in
329
+ the parent, the way `parse_pubid`'s result rides back as `result[:pubid]` — a
330
+ counter incremented in a worker process is lost. `report_unparsed` warns once
331
+ with a count and a sample, not once per file.
332
+
333
+ A failed version is dropped from `sorted` **before** `link_neighbor_relations`
334
+ and `build_unversioned_doc` run, since both dereference `entry[:bib]`. Knock-on
335
+ to accept: neighbours then link across the gap, and if the dropped file was the
336
+ newest, the aggregator inherits from the previous version. With CP1252 recovery
337
+ that should approach zero — which is the argument for recovering over skipping.
338
+
339
+ Verified end to end on 41,408 real draft files (16 invalid UTF-8): the crawl
340
+ completes in 57 s, writes 46,461 records and 46,461 index rows, **0 skipped**,
341
+ and the affected files come through with their real characters —
342
+ `\x93Access Node Contr` → `“Access Node Contr`.
343
+
344
+ ## Testing Patterns
345
+
346
+ - **Index fixture:** `spec/ietf/fixtures/ietf-index-v2.zip` - the combined index, copied **wholesale** from `relaton-data-ietf` rather than curated, since a subset goes stale and surfaces as a 404 on the document fetch. `spec/ietf/support/webmock.rb` deserialises it into pubid ids, sorts by the narrowing key, sets `sorted = true` (without which every lookup is a ~40 s scan) and pre-loads it into the pool as `:IETF` in `before(:suite)`; `spec/ietf/support/vcr.rb` ignores requests ending `index-v2.zip`. Refresh by re-downloading that zip. There is **no** `rake spec:update_index` task in this repo.
347
+ - **VCR cassettes** in `spec/ietf/vcr_cassettes/` record HTTP interactions; tests use `vcr: "cassette_name"` metadata
348
+ - **WebMock** disables net connections by default
349
+ - **Fixtures** in `spec/ietf/fixtures/` — XML/YAML expected outputs; many tests auto-generate fixtures on first run (`File.write file, xml unless File.exist? file`)
350
+ - **An oracle for `read_bibxml` must read binary.** The method is byte-verbatim (`File.binread`), so comparing it against `File.read(path, encoding: "UTF-8")` compares two different strings **on Windows only**: Ruby opens a file in text mode there and collapses CRLF to LF. The repo declares no `.gitattributes` and the GitHub Windows runner image defaults to `core.autocrlf=true`, so a fixture stored with LF is checked out CRLF — which failed all three `windows-latest` jobs while every Linux and macOS job passed. Use `File.read(path, mode: "rb", encoding: "UTF-8")`. macOS cannot reproduce this by checking the fixture out as CRLF, because its text-mode read does not translate; emulate Windows explicitly with `File.read(path, encoding: "UTF-8", universal_newline: true)`. The two `keeps CRLF line endings verbatim` examples pin the contract with `binwrite` temp files, independent of the checkout — one per branch, since the CP1252 repair rebuilds the string run by run and is where a line ending is likeliest to be lost. Note that a byte-equality oracle alone cannot catch a dropped `force_encoding`: `rfc.xml` is pure ASCII, and Ruby compares two ASCII-only strings as equal across encodings, so the example asserts `encoding` separately.
351
+ - **Schema validation** via `ruby-jing` against the shared RNG grammars in the repo-root `grammar/` (`Jing.new "../../grammar/relaton-ietf-compile.rng"`, relative to the suite's CWD `spec/ietf/`)
352
+ - **Shared examples** for org/person parsing in `bibxml_parser_spec.rb` (`parse_org`, `parse_person`)
353
+ - **`equivalent-xml`** gem used for XML comparison (`be_equivalent_to` matcher)
354
+
355
+ ## RuboCop
356
+
357
+ Inherits from `rubose/rubocop-rubose` (Ribose OSS config). Target Ruby 3.1. Rails cops disabled.