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,373 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ # Run the umbrella (Db) suite — specs live in spec/relaton/ and run from there
9
+ bundle exec rake spec:relaton
10
+
11
+ # Run a single spec file (specs are CWD-relative, so cd into the suite dir)
12
+ cd spec/relaton && bundle exec rspec db_spec.rb
13
+
14
+ # Run a specific test by line number
15
+ cd spec/relaton && bundle exec rspec db_spec.rb:234
16
+
17
+ # Lint
18
+ bundle exec rubocop
19
+ ```
20
+
21
+ ## Architecture
22
+
23
+ Relaton is a Ruby gem that fetches, caches, and manages bibliographic references to technical standards from 25+ organizations (ISO, IEC, IETF, NIST, IEEE, etc.).
24
+
25
+ ### Plugin Registry Pattern
26
+
27
+ **Relaton::Registry** (singleton) auto-discovers and manages backend processor gems (relaton-iso, relaton-iec, relaton-ietf, etc.). Each processor implements the **Relaton::Processor** interface (`get`, `from_xml`, `from_yaml`, `grammar_hash`, `prefix`, `defaultprefix`). The registry routes reference codes to the correct processor by matching prefixes (e.g., "ISO 19115" → `:relaton_iso`).
28
+
29
+ Registration is **lazy**: `register_gems` requires only each flavor's lightweight `…/processor` file, never the heavy flavor top-level, so flavor deps load on first use rather than at startup. Any processor method touching a flavor constant must `require_relative "../<flavor>"` first — see the root `CLAUDE.md` "Registry is lazy" note and `spec/relaton/lazy_loading_spec.rb`.
30
+
31
+ ### Global prefix register (relaton-db#103)
32
+
33
+ Separate from the reference-dispatch matching above, each processor declares the
34
+ **global document-ID prefixes its SDO owns** via `@prefixes` (Array<String>) in
35
+ `initialize` — e.g. NIST → `%w[NIST NBS]`, ISO → `["ISO", "ISO/IEC", "IEC/ISO",
36
+ "ISO/IEC/IEEE"]`. `Core::Processor#prefixes` defaults to `[prefix]`, so the ~24
37
+ single-prefix flavors need no declaration; only multi-/conflicting-prefix
38
+ flavors (iso, iec, nist, ieee, bsi) set it. **Joint prefixes are listed
39
+ symmetrically** by every co-publisher (both ISO and IEC list `ISO/IEC`; all three
40
+ of iso/iec/ieee list `ISO/IEC/IEEE`) so the register returns every owning flavor.
41
+ This is a distinct exact-match list, *not* the `@defaultprefix` regex — those
42
+ require a trailing `\s` and can't match a bare prefix like `"ISO/IEC"`.
43
+
44
+ Two lookups over it:
45
+ - `Registry#processors_by_prefix(prefix)` → the owning **processor objects**,
46
+ case-insensitive exact match, in registration (`SUPPORTED_GEMS`) order. **Lazy**
47
+ — touches no flavor constant. Use this if you must stay lazy.
48
+ - `Relaton.prefix_flavor(prefix)` (in `lib/relaton.rb`) → the owning **flavor
49
+ module(s)** as an Array (`[]` if none). Dereferencing a module constant
50
+ triggers that flavor's autoload, so this **loads** the matched flavor(s) — by
51
+ design, since the caller asked for the module.
52
+
53
+ Prefixes are **sourced from pubid** — the source of truth for each SDO's
54
+ identifier grammar (per @ronaldtse's review on relaton-db#103). A processor sets
55
+ `@pubid_flavor` to its Pubid module name (e.g. `:Iso`) in `initialize`, and
56
+ `Core::Processor#prefixes` lazily reads `Pubid::<Flavor>.prefixes`
57
+ (`require "pubid"` on first call, memoized). pubid returns each SDO's leading
58
+ identifier tokens, including non-obvious ones (BSI `DD`, NIST `FIPS`) and joint
59
+ forms listed symmetrically across co-publishers (`ISO/IEC` in both ISO's and
60
+ IEC's lists). Flavors with no `@pubid_flavor` fall back to `[prefix]`. The pubid
61
+ API (`Pubid.prefixes(flavor)` / `Pubid.prefix_flavors` / the `PrefixesSupport`
62
+ mixin) was specified in `HANDOFFS/metanorma__pubid.md` and implemented upstream;
63
+ relaton pins the pubid version that carries it.
64
+
65
+ Conflicting prefixes where the *same* base identifier is co-published as distinct
66
+ per-publisher documents (e.g. `IEC 80000-13` vs `ISO 80000-3` — different cover,
67
+ location, even foreword) are a **separate follow-up**: the register already
68
+ returns both flavors, but returning the correct publisher-specific *content*
69
+ touches `Db#fetch`, not the prefix list.
70
+
71
+ Scope note: only *prefixes* live here. Broader "SDO/organization metadata" (org
72
+ names — relaton-db#132; logos — metanorma#346) was deliberately kept **out** of
73
+ relaton proper, destined for a separate store/gem; don't add it to the processors.
74
+
75
+ ### Db (lib/relaton/db.rb) — Main Public API
76
+
77
+ `Relaton::Db#fetch(ref, year, opts)` is the primary entry point. It:
78
+ 1. Routes the reference with `Registry#route` (see **Routing** below)
79
+ 2. Handles combined references (`+` for derivedFrom, `,` for amendments) in `combine_doc`
80
+ 3. Delegates to `check_bibliocache` which manages the dual-cache lookup and network fetch flow
81
+
82
+ ### Routing (`Registry#route`, relaton#205)
83
+
84
+ One rule for `fetch`, `fetch_db`, `fetch_async`, `fetch_std` and `docid_type`.
85
+ It returns `[stdclass, pubid]`:
86
+
87
+ 1. **Parse with pubid, and keep only the parsing flavor's own exact parse.**
88
+ `Pubid.parse` falls back to a partial parse by any flavor (`ATN5014` → `IEC
89
+ ATN5014`), and a permissive grammar reads another publisher's string exactly
90
+ (`ISO REF` as IEC, `ABC 123456` as GB, a bare DOI as UN). A parse counts only
91
+ when it renders the reference back (`to_s == reference`, pubid's own
92
+ round-trip test) **and** the flavor claims the reference by one of its
93
+ `Pubid::<Flavor>.prefixes` (`prefix_of?`: the reference ends there or goes on
94
+ with a separator; a prefix that ends in punctuation, `doi:`, needs none).
95
+ A flavor whose identifiers print without a publisher token opts out of the
96
+ claim with `Core::Processor#bare_identifiers?`: OGC (`19-025r1`, the form
97
+ its documents carry) and 3GPP (`TS 23.207:REL-18/18.0.0`, its index form).
98
+ Without the opt-in both raised, and `TR 00.01U:UMTS/3.0.0` went to JIS by
99
+ the `TR` regex. A URN no flavor owns (`urn:foo:bar`) makes `Pubid.parse`
100
+ raise a plain `ArgumentError`, which counts as no parse.
101
+ 2. **Route by class ancestry** (`processor_by_pubid`), so the registration order
102
+ does not matter (a spec routes with the processors reversed).
103
+ 3. **A co-published identifier: the printed form decides.** There is no
104
+ canonical form of it (pubid#469), and `Pubid.parse` tries the owners of a
105
+ joint prefix alphabetically, so `ISO/IEC 27001` parses as IEC. A reference
106
+ that starts with a prefix several flavors own (`joint_prefixes`: `ISO/IEC`,
107
+ `IEC/ISO`, `ISO/IEC/IEEE`) routes to the owner whose own prefix is the first `/` token:
108
+ `ISO/IEC …` → ISO, `IEC/ISO …` → IEC. The parsed pubid is kept only when it
109
+ is that flavor's. This is **relaton's routing policy, not a pubid gap**: the
110
+ same string `ISO/IEC 27001:2022` is in both the ISO and the IEC catalogs, and
111
+ pubid must not elect one reading of it nor route records (pubid#469). The
112
+ #205 comment puts the choice here — the printed form first, then the
113
+ co-publisher's flavor when the first one has no record (**Co-publisher
114
+ fall-through**, below).
115
+ 4. **Else the prefix regex** (`class_by_ref`): combined references, the
116
+ `PREFIX(code)` wrapper, `IEV`, URNs, and spellings a flavor normalizes on
117
+ render (`OGC 19-025r1`, `UN TRADE/…`, `3GPP TS …`, `IEEE 802.11-2016`).
118
+ 5. **Else `Relaton::UnknownReferenceError`** (`< Relaton::Error`).
119
+ `fetch_async` logs it and yields nil; `docid_type` returns `[nil, code]`;
120
+ relaton-cli logs it and returns no document.
121
+
122
+ **One parse per fetch (#205 PR 2).** `Db#query_and_key` turns the routed pubid
123
+ into the **query pubid** once (`Core::Processor#query_pubid(ref, opts,
124
+ parsed)`: the routed parse, else `#cache_pubid`, kept only when it is a
125
+ `Pubid::Identifier`), keys the cache with it (`#cache_key(ref, year, opts,
126
+ query)`, which folds year/all_parts on **copies**), and hands that same object
127
+ to the flavor: `processor.get(query || code, year, opts)`. So
128
+ `db.fetch("ISO 19115-1")` is one grammar parse, and `db_spec.rb` counts it.
129
+ The year and `all_parts` still reach `get` as arguments; the pubid is unfolded.
130
+ A nil query pubid (no pubid class, a policy miss such as `IEV`, a CCSDS
131
+ format) sends the String and leaves the query uncached. `fetch_api` keeps the
132
+ String for its URL. If `urn_to_code` rewrote the reference, the routed parse is
133
+ of another string, and `query_pubid` parses the code. IEC's `urn_to_code` acts
134
+ on a `urn:` only: `Relaton::Iec.urn_to_code` splits on `:`, and it used to
135
+ rewrite `IEC 60034-1:1969+AMD1:1977+AMD2:1979+AMD3:1980 CSV` into
136
+ `1979+AMD3 1980 CSV`.
137
+
138
+ **Co-publisher fall-through (#205 PR 3).** A not-found from the lead flavor is
139
+ not the last word for a co-published document. The organizations serve the
140
+ joint portfolio by their own policies (IEC serves ISO-led documents, ISO serves
141
+ IEC-portfolio ones; the IEC index fixture holds ~7,450 `ISO/IEC` rows), so
142
+ `Db#fetch` (and `fetch_db`, `fetch_async`, and a `fetch_std` that routes) then
143
+ asks the co-publishers' flavors (`Db#copublisher_fetch`):
144
+
145
+ - **Order: the parsed pubid's, lead first.** `Core::Processor#copublishers(pubid)`
146
+ reads the publishers after the lead from the query pubid — `copublishers` for
147
+ ISO and IEC, IEEE's `publishers`/`copublisher` through its override. pubid#472
148
+ made the order the printed one. `Registry#class_by_publisher` maps each to the
149
+ flavor whose own prefix it is (`ASTM`, `IDF` have none, and are skipped).
150
+ - **Each co-publisher uses its own flavor end to end**: its own parse of the
151
+ String (`ISO/IEC 27001` reaches IEC as a `Pubid::Iec` pubid), its own cache
152
+ key and bucket, its own `not_found` row. A second fetch reads ISO's
153
+ `not_found` and IEC's cached document with no network call. No row crosses
154
+ flavors, because `bib_retval` must read XML with the flavor that wrote it.
155
+ - **A co-publisher whose flavor gives no pubid for the lead's form is
156
+ skipped**, with a log line: a parse error (`IEEE/ISO 11073-10101` in ISO or
157
+ IEC), or a flavor that reads the form as a miss (IEEE's `cache_pubid` gives
158
+ nil for `ISO/IEC/IEEE 15288:2023/DAmd 1`). Without that rule IEEE would get
159
+ the String with no cache key, so every fetch would call it again. There is
160
+ no re-render into the co-publisher's arrangement: pubid#469 elects no
161
+ reading, and #472's "ISO face" keeps the printed publisher order. **Only the
162
+ probe parse is rescued** (`Db#copublisher_query`): a parse error from inside
163
+ the co-publisher's `get` is a flavor or data bug, and it propagates.
164
+ - The co-publishers get the reference **as the lead read it**
165
+ (`strip_id_wrapper`: no `ISO(…)` wrapper, no en dash). Without that, a
166
+ wrapped or en-dash reference raised after the lead's miss.
167
+ - **Not a fall-through:** a lead hit; a `Relaton::RequestError` (a transport
168
+ failure is not "not found", and it propagates); a flavor the caller names in
169
+ `fetch_std`; each part of a combined reference (`combine_doc` calls
170
+ `check_bibliocache` directly); a URN, which reaches the co-publisher as the
171
+ URN String that its flavor cannot parse, so it is skipped.
172
+ - **Known cost of the per-flavor rows:** a repeat fetch logs the lead's
173
+ "not found in cache" before IEC's cached document answers, and when the lead
174
+ later gains the record, the co-publisher's copy answers until the lead's
175
+ `not_found` row expires (60 days) or the caller passes `no_cache`.
176
+ - On the pinned pubid (`27454393`, pre-#472) an IEEE joint development lists
177
+ its `publishers` in a reordered form (`IEEE/ISO/IEC 8802-3` → ISO, IEC, IEEE).
178
+ It does not change an answer today: ISO and IEC cannot parse an IEEE-led form,
179
+ so both are skipped.
180
+
181
+ **The flavor contract for `get`** (every flavor in this gem keeps it):
182
+
183
+ - **Accept a String or the flavor's pubid.** Parse only a String; a String-only
184
+ rewrite (`IEV`, `upcase`, an en dash, a `BIPM ` prefix) stays in the String
185
+ branch. Unwrap an `AllParts` pubid the way the String `(all parts)` is read.
186
+ - **Never mutate the pubid.** It is also the cache key, and `to_all_parts`
187
+ wraps the same object. Copy before a setter (`exclude`, or
188
+ `pubid.class.from_hash(pubid.to_hash)`); ISO `root.date=`, IEC `date=` and
189
+ JIS `year=` used to write into the caller's object.
190
+ - **Use `ref.to_s` for text** — log keys (`Util.info …, key: ref.to_s`; the
191
+ JSON log formatter would serialize a pubid), portal search text, messages.
192
+ Routing's own parse prints back as the reference. A pubid from
193
+ `#cache_pubid` (prefix-fallback routing, a named `fetch_std`, a
194
+ `combine_doc` piece, a `urn_to_code` rewrite) can print a **normalized**
195
+ form: `NISTIR 8200` → `NIST IR 8200`, `NIST SP 800-38A Add` → `… Add.`,
196
+ `CIPM Meeting 43` → `CIPM 43rd Meeting`, `IEEE 528` → `IEEE Std 528`,
197
+ `… Expert commentary` → `… Expert Commentary`, and OGC and 3GPP print with no
198
+ publisher token. So a text lookup must accept the normalized print: BSI's
199
+ `ExComm` rewrite is case-insensitive for this reason, and 3GPP logs with
200
+ `to_s(with_publisher: true)`.
201
+ - `spec/relaton/support/umbrella.rb` has the `pubid_of("ISO 19115-1")` matcher
202
+ for a stubbed `get`'s first argument.
203
+
204
+ `Relaton::Db#fetch_all(text, edition, year)` searches cached entries, filtering by text content (via `match_xml_text?`), edition, and/or year. Returns an array of deserialized bibliographic items from both local and global caches.
205
+
206
+ The dual-cache strategy uses a **global cache** (`~/.relaton/cache`) and an optional **local cache** (project-level). `check_bibliocache` checks local first, falls back to global, and syncs between them.
207
+
208
+ ### Cache (lib/relaton/db/cache.rb) — pubid-keyed index on lutaml-store
209
+
210
+ relaton#189 item 2 / relaton#204. Two lutaml-store `FileSystem` stores under
211
+ `<dir>/v2/`:
212
+
213
+ - **`rows/`** — the index. One store key per **bucket**, `<flavor>/<root number>`
214
+ (`iso/19115`), whose value is the list of the bucket's `CacheEntry` rows
215
+ (`lib/relaton/db/cache_entry.rb`): `id` (the pubid's `to_hash`) or `key` (a
216
+ legacy string), `status` (`doc` / `not_found`), `file`, `fetched`. A lookup
217
+ reads one small bucket; a write is one atomic `update` of it. A `_versions`
218
+ key holds each flavor's `grammar_hash`; a changed one drops that flavor's
219
+ rows and documents on open.
220
+ - **`docs/`** — the XML documents. **Several rows can point to one document.**
221
+ A fetch whose answer has another identifier than the query
222
+ (`ISO 19115-1` → `ISO 19115-1:2014`) writes the query row and the item row,
223
+ both naming the same file. There are no redirect entries. A document is
224
+ deleted with the last row that points to it; a row whose document is gone is
225
+ a miss.
226
+
227
+ Matching: the **exact** row first (canonical `to_hash`, not pubid `==`, which
228
+ is false after a `from_hash` round trip for some flavors, e.g. UN). Only a
229
+ **dated** query then falls back to `query === row_id` in the same bucket —
230
+ an undated query keeps its own row, so the 60-day expiry of undated entries
231
+ still applies and it never answers with another query's year-stripped copy.
232
+ **`===` alone is too wide here**: it reads an omitted `part` as "any part", so
233
+ `ISO 19115:2003 === ISO 19115-1:2003` (and the same for IEC, BSI, JIS, GB).
234
+ `Cache#subset_of?` therefore also requires that every component the row adds
235
+ (at any depth) is allowed: only a language for the dated fallback
236
+ (`LANGUAGE_COMPONENTS`), a year/date/month or a language for the date-range
237
+ `candidates` (`EDITION_COMPONENTS`). A row with no `root.number` (DOI, ISBN)
238
+ goes to one of 256 digest buckets (`<flavor>/~<hex>`).
239
+
240
+ Expiry (`Cache#valid_row?`): an undated row, and **every** `not_found` row,
241
+ is valid for 60 days (`UNDATED_TTL`) from its `fetched` date; only a `doc`
242
+ row looked up with a `year` argument never expires (`fetch("ISO 9999:2030")`
243
+ passes no `year`, so its rows expire too). A `not_found` for a dated query
244
+ used to stay for ever (`year || age < 60`), so a document published after
245
+ the first miss was never fetched (#204).
246
+
247
+ Locking and atomic writes come from lutaml-store (≥ 0.3.2 — 0.3.0's
248
+ FileSystem adapter was unsafe, lutaml/lutaml-store#17): an exclusive `flock`
249
+ on `<root>/.lock` plus a per-root Monitor for `update`/`transaction`, and temp
250
+ file + rename per write. `Cache#store` writes the document and both rows in
251
+ one `rows` transaction; the lock order is always `rows`, then `docs`.
252
+ Two specs check this with the real lock, in two processes (#204):
253
+ `db_cache_spec.rb` ("keeps every row when two processes write") and
254
+ `db_spec.rb` ("keeps one consistent cache when two processes fetch", the whole
255
+ `Db#fetch` path). They start the processes with `Process.spawn`
256
+ (`run_children`, `spec/relaton/support/child_processes.rb`), not `fork`, so
257
+ they also run on Windows, where production has two separate commands that
258
+ share `~/.relaton/cache`. A child is a fresh Ruby: RSpec stubs do not reach
259
+ it, so its script replaces what it needs (the `Db` spec redefines ISO's
260
+ `Bibliography.get`). A spawned child loads its code for seconds and works
261
+ for less than one, so each script calls `start_barrier!` after its requires,
262
+ and the children start their work together. Both specs also put every row in
263
+ **one bucket** on purpose: two processes that write the same rows, or
264
+ different buckets, hide a lost update (the `Cache` spec used 50 buckets
265
+ before, and passed with no lock). Measured with the `flock` made a no-op
266
+ (loaded into the children through `RUBYOPT`): the `Cache` spec failed 5 of 5
267
+ runs and the `Db` spec 4 of 5; with a sleep added inside `update` too, both
268
+ failed 3 of 3. With the real lock they passed 20 of 20.
269
+
270
+ An old file-per-key cache (anything in `<dir>` beside `v2/`) is **moved** to
271
+ `<dir>-v1.bak` on open, never deleted — **once**: when the `.bak` exists the
272
+ old entries stay where they are, because an older relaton sharing the
273
+ directory writes the old layout again, and v2 ignores it.
274
+
275
+ Keys: a parsed pubid; a wrapped string (`ISO(ISO 19115-1)`), which the cache
276
+ parses through the prefix's processor — `load_entry`/`save_entry` still take
277
+ it; or a plain string no processor owns (bucket `_key/<string>`).
278
+
279
+ Values: `Db` and `Cache` pass the document XML (`String`) or a
280
+ `Relaton::Db::NotFound` (`Data` with `fetched`, in `cache_entry.rb`), never a
281
+ marker string. `Db` reads with `Cache#read` and tests `is_a?(NotFound)`; keep
282
+ `not_found` regexes out of `db.rb` (`db_spec.rb` guards it). The string API is
283
+ kept for compatibility: `Cache#[]` and `Db#load_entry` return
284
+ `"not_found <date>"` (`NotFound#to_s`), and `Cache#[]=`/`#store` still accept
285
+ that string, which `Cache#coerce` turns into a `NotFound` at the entrance.
286
+
287
+ ### WorkersPool (lib/relaton/db/workers_pool.rb)
288
+
289
+ Thread pool for `fetch_async`. Default 10 threads per processor, overridable via `RELATON_FETCH_PARALLEL` env var.
290
+
291
+ ### Cache key
292
+
293
+ `Db#query_and_key` asks the processor: `Core::Processor#cache_key(ref, year,
294
+ opts, query)` takes the query pubid (above; else it parses the reference **as
295
+ written** with the routed flavor's pubid class, `#cache_pubid` →
296
+ `pubid_class.parse`), then folds `year`
297
+ (`#fold_year`) and `all_parts` (`to_all_parts`) into the pubid.
298
+ `#pubid_class` reads `@pubid_identifier`, else `@pubid_flavor` —
299
+ `@pubid_identifier` exists so a flavor can key its cache with pubid without
300
+ also sourcing `#prefixes` from pubid.
301
+
302
+ - **Canonical references only.** Relaton does not rewrite a non-canonical
303
+ citation before it parses it: `I-D.draft-…`, `IETF RFC 8341`,
304
+ `BIPM Metrologia …`, a W3C URL, `NIST … (IPD)` / `(January 2014)` /
305
+ `NISTIR 8200:2018`, `JIS … (規格群)`, a lowercase `iec …`, an en dash — each
306
+ gives no key and reaches the flavor's `get` as written, which normalizes it
307
+ itself where it can (relaton#235). The canonical identifiers the data
308
+ repos publish all parse as written (measured over every spec index fixture);
309
+ pubid/pubid#463 and #464, which asked pubid to accept the other spellings,
310
+ were closed for this.
311
+ - **References with one key must get one answer from the flavor.** pubid can
312
+ read several spellings as one identifier (`doi:…`, `DOI:…` and a
313
+ `https://doi.org/…` URL are one `Pubid::Doi` key). If the flavor's `get`
314
+ answers only some of them, a miss on another spelling is cached as
315
+ `not_found` under the shared key and poisons the canonical one for 60 days.
316
+ So `Doi::Crossref.get` strips every such prefix. Check this whenever a
317
+ flavor's routing or its pubid grammar changes.
318
+ - **Generated references must be canonical too.** `combine_doc` joins an ITU
319
+ or NIST supplement with a space (`NIST SP 800-38A Add`), since pubid does not
320
+ parse `NIST SP 800-38A/Add`.
321
+ - **A processor overrides `cache_pubid` only for policy**, not to normalize:
322
+ Adobe, IANA, IEEE and ISBN read an unparseable reference as a miss, so their
323
+ parse error means "no key"; IEC answers `IEV` with the vocabulary, so `IEV`
324
+ gets no key.
325
+ - **A flavor parse error is a fallback, not a failure**: the query is not
326
+ keyed, and the flavor's `get` receives the reference as written
327
+ (`Core::Processor#cache_pubid` rescues to nil, with a log line; root
328
+ `CLAUDE.md`, "A query reference the flavor's grammar cannot read falls
329
+ back to the flavor as written").
330
+ - **The year goes where the flavor's `get` puts it.** The default
331
+ `#fold_year` sets the identifier's own year; ISO, CEN and BSI apply a year
332
+ to `#root` (the base document of a supplement, the adopted document of an
333
+ adoption), so their processors use `#fold_year_on_root`. A flavor with no
334
+ year component drops it, which matches their `get` (it ignores the year).
335
+ - **No key, no cache.** A processor with a pubid class that answers `nil` is
336
+ not cached for that query: a flavor miss by the flavor's own rule (Adobe,
337
+ IANA, IEEE, an incorrect ISBN, `IEV`) and a query whose answer the cache
338
+ cannot hold (a CCSDS format, which filters the item's sources — CCSDS
339
+ overrides `query_pubid`, so its `get` also receives the String; an OGC year,
340
+ which is not the pubid's document-number year — OGC keeps that rule in
341
+ `cache_key`, so its `get` still receives the pubid).
342
+ - **The publication date range is never in the key.** It selects among the
343
+ cached editions (`Cache#candidates` + `pub_date_in_range?`); on a miss the
344
+ flavor is asked with the range and the answer is cached under **its own**
345
+ identifier only, so the undated query row keeps pointing to the latest
346
+ edition. Bounds may be `YYYY`, `YYYY-MM` or `YYYY-MM-DD`.
347
+ - A processor with **no** pubid class (a third-party one) keeps the legacy
348
+ string key from `std_id` (`ISO(ISO 19115-1:2014 (all parts))`).
349
+
350
+ ## Testing
351
+
352
+ - Umbrella (Db) specs live in `spec/relaton/` and run from there (`rake spec:relaton`)
353
+ - RSpec with VCR cassettes in `spec/relaton/vcr_cassettes/` for recorded HTTP interactions
354
+ - Tests create `testcache`/`testcache2` directories and clean them in `before(:each)`.
355
+ Assert cache contents through `Relaton::Db::Cache` (`#[]`, `#rows`, `#all`),
356
+ not through file names: the store names its files itself.
357
+ - Cache-related tests need `<fetched>` elements in XML for `valid_entry?` to return true
358
+ - Integration tests in `spec/relaton/relaton_spec.rb`; unit tests under `spec/relaton/`
359
+ - **ISO lookups are stubbed, not cassette-recorded.** Flavor gems (relaton-iso/iec/nist)
360
+ fetch a large live `index-v2` and deserialize every id through a pinned pubid build,
361
+ so a single drifted id in the live index makes the whole index unparseable and ISO
362
+ lookups return `nil`. Umbrella specs therefore stub `Relaton::Iso::Bibliography.get`
363
+ (and other flavors' `.get`) to return hand-built `ItemData` — the umbrella's job is to
364
+ test `Db` orchestration (`combine_doc`, caching, api fallback), not relaton-iso's index.
365
+ Build stub items with the `docidentifier:` key (not `docid:`) so the id survives the
366
+ cache XML round-trip. Don't reintroduce a live-index cassette for these.
367
+
368
+ ## Style
369
+
370
+ - RuboCop config inherits from [Ribose OSS guides](https://github.com/riboseinc/oss-guides), target Ruby 3.3
371
+ - Each cache operation is atomic in lutaml-store (threads and processes). `Db`'s
372
+ `@semaphore` (Mutex) only serializes its own check-then-act sequences across
373
+ the two caches (validity check, clone, fetch-and-store).
data/lib/relaton/db.rb CHANGED
@@ -153,7 +153,27 @@ module Relaton
153
153
  return check_bibliocache(code, year, opts, std) if std
154
154
 
155
155
  std, pubid = @registry.route(code)
156
- fetch_routed(code, year, opts, std, pubid)
156
+ fetch_routed(code, year, opts, std, pubid) ||
157
+ fetch_equivalent(code, year, opts)
158
+ end
159
+
160
+ # Cross-SDO equivalence as data: on a total miss, walk the equivalence
161
+ # tables from relaton/equivalence.rb in both directions — a national
162
+ # adoption falls back to the EN, the EN to the ISO, and a base document
163
+ # falls forward to its declared adoptions (.related_for). Each candidate
164
+ # goes through the full routed fetch (cache, co-publishers); an
165
+ # unrouteable candidate is skipped.
166
+ #
167
+ # @return [Relaton::Bib::ItemData, nil]
168
+ def fetch_equivalent(code, year, opts)
169
+ Equivalence.related_for(code).each do |candidate|
170
+ Util.info "Not found; trying equivalent `#{candidate}`", key: code
171
+ std, pubid = @registry.route(candidate)
172
+ bib = fetch_routed(candidate, year, opts, std, pubid) and return bib
173
+ rescue UnknownReferenceError
174
+ next
175
+ end
176
+ nil
157
177
  end
158
178
 
159
179
  # The document identifier class corresponding to the given code
@@ -0,0 +1,105 @@
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
6
+
7
+ relaton-doi is a Ruby gem that fetches bibliographic metadata via DOI identifiers from the Crossref API and converts them into Relaton bibliographic objects. It detects DOI patterns to produce flavor-specific items (NIST, IETF, BIPM, IEEE) or generic `Bib::ItemData`.
8
+
9
+ ## Common Commands
10
+
11
+ ```bash
12
+ bundle exec rake spec # Run all tests (default rake task)
13
+ bundle exec rspec spec/relaton/doi/parser_spec.rb # Run a single spec file
14
+ bundle exec rspec spec/relaton/doi/parser_spec.rb:224 # Run a single example by line
15
+ rubocop # Lint
16
+ rubocop -a # Lint with auto-correct
17
+ ```
18
+
19
+ ## Architecture
20
+
21
+ **Namespace:** `Relaton::Doi` (migrated from legacy `RelatonDoi`).
22
+
23
+ **Core flow:** `Crossref.get(doi)` → HTTP fetch from api.crossref.org → `Parser.parse(json_hash)` → flavor-specific `ItemData`
24
+
25
+ Key classes in `lib/relaton/doi/`:
26
+
27
+ - **`Crossref`** — module with `get(doi)` and `get_by_id(id)`. `get` takes a
28
+ String or a parsed `Pubid::Doi::Identifier`; `doi_of` reduces either to the
29
+ bare `prefix/suffix` the API takes. It strips a `doi:` scheme in any case
30
+ and a `doi.org` URL, because `Relaton::Db` keys all of those as one DOI — a
31
+ form this method did not read would get a miss cached for the others. Uses **Mechanize** (`Mechanize.new` with a custom `USER_AGENT`). `get_by_id` retries twice, backing off by `x-rate-limit-interval * n`, then raises `Relaton::RequestError`; only a 404 returns nil.
32
+ - **`Parser`** — largest file (~827 lines). Converts Crossref JSON hashes to Relaton objects. Factory method `parse(src)` delegates to `create_bibitem` which picks the right ItemData class based on DOI pattern (`/nist/` → `Nist::ItemData`, `/rfc\d+/` → `Ietf::ItemData`, etc.). Contains ~30 `parse_*` helper methods for individual bibliographic fields.
33
+ - **`Processor`** — `Relaton::Processor` subclass for the Relaton registry system. Entry point for `get`, `from_xml`, `hash_to_bib`.
34
+ - **`Util`** — logging utility, extends `Relaton::Bib::Util` with `PROGNAME = "relaton-doi"`.
35
+
36
+ ## Test Setup
37
+
38
+ - **RSpec** with `expect` syntax only (monkey patching disabled)
39
+ - **VCR** cassettes in `spec/vcr_cassettes/` record Crossref HTTP responses, re-recorded every 7 days on purpose (see root `CLAUDE.md`). Because Crossref rate-limits, **re-record this suite alone** — under parallel `rake spec` it answers 429 and the empty body gets recorded as if it were data.
40
+ - **XML fixtures** in `spec/fixtures/` — expected output XML files. The `read_fixture` helper auto-substitutes today's date into `<fetched>` tags.
41
+ - **equivalent-xml** gem for XML comparison in integration tests
42
+ - Integration tests in `spec/relaton/doi_spec.rb` cover 40+ document types via VCR cassettes
43
+ - Unit tests in `spec/relaton/doi/parser_spec.rb` test Parser methods directly with hash inputs
44
+
45
+ ## Rate limiting
46
+
47
+ Crossref advertises `X-Rate-Limit-Limit: 10` / `X-Rate-Limit-Interval: 1s` and
48
+ `X-Concurrency-Limit: 3`. Both request paths honour that and **fail loud rather
49
+ than degrade**:
50
+
51
+ - `Parser#fetch_crossref` handles `429` *before* its generic 4xx→nil return,
52
+ sleeping `retry_delay * attempt` (`Retry-After`, else `X-Rate-Limit-Interval`,
53
+ else a 1s floor) with the **sleep itself** clamped to `MAX_RETRY_DELAY`, for up
54
+ to `MAX_RETRIES` attempts, then raising `Relaton::RequestError`. Only the
55
+ numeric form of `Retry-After` is used — RFC 9110 also permits an HTTP-date, and
56
+ scanning digits out of a date would yield the day-of-month.
57
+ - `Crossref.get_by_id` retries twice with `Crossref.backoff`, then raises; only a
58
+ 404 returns nil.
59
+
60
+ Both apply a **1-second floor**, because a throttled response may carry no
61
+ rate-limit headers at all (the 429s observed here had only
62
+ `Date`/`Content-Length`/`Connection`). Treating a missing header as `0` would
63
+ retry with no delay — hammering the endpoint that just asked us to slow down.
64
+
65
+ **A throttle must never collapse into "not found."** `fetch_crossref`'s callers
66
+ (`#parent_item`, `#fetch_location`) read nil as "this record has no parent /
67
+ no location", so a 429 returning nil silently produced an *incomplete* item —
68
+ e.g. a book chapter with none of its editors — instead of an error. That is also
69
+ how 429s ended up baked into cassettes as if they were data; see the cassette
70
+ notes in the root `CLAUDE.md`.
71
+
72
+ ## Key Constants in Parser
73
+
74
+ - `TYPES` — maps 23 Crossref document types to Relaton types (e.g., `"book-chapter"` → `"inbook"`)
75
+ - `REALATION_TYPES` — maps 37 Crossref relation types to Relaton relation types
76
+ - `COUNTRIES` — `%w[USA]`, used by `parse_place` to distinguish country vs region
77
+
78
+ ## Crossref markup
79
+
80
+ Crossref returns abstracts and titles as JATS, and it sometimes encodes that markup as HTML
81
+ entities. `Parser#normalize_markup` decodes the entities; `create_title`, `parse_abstract`,
82
+ `parse_series`, `included_in_relation` and `create_org` all route through it, so an
83
+ affiliation name no longer goes out double escaped (`AT&amp;T` instead of `AT&T`).
84
+
85
+ **It decodes only content that holds no markup of its own** (`TAG_RE`), and it passes `nil`
86
+ through. Crossref escapes an ampersand and a literal angle bracket *inside* real markup, so
87
+ an unconditional decode — which is what upstream relaton-doi#26 does — turns
88
+ `<jats:p>Smith &amp; Jones</jats:p>` into markup that no longer parses. The sanitizer then
89
+ gives up on it and an unescapable string reaches the output: metanorma-pdfa#99 again, on the
90
+ far more common input. A `nil` reaches `create_org` from a funder entry carrying only a DOI
91
+ and from an affiliation carrying only a ROR id, and `CGI.unescapeHTML(nil)` raises
92
+ `TypeError`, which aborted the whole fetch.
93
+
94
+ The JATS also carries a `jats:` prefix on the elements and sometimes an `xlink:` prefix on an
95
+ attribute, which Relaton never declares. **Removing those prefixes is not this flavor's job**
96
+ — `Relaton::Bib::Sanitizer` runs on every `Bib::Title` and `Bib::Abstract` assignment and
97
+ declares each undeclared prefix so the markup parses and maps to the basicdoc set. Upstream
98
+ relaton-doi (relaton-doi#26) carries its own `drop_namespaces` only because that sanitizer fix
99
+ is not in a released relaton-bib; here both ship in one gem, so this flavor keeps the entity
100
+ decode alone. Do not add a second prefix-stripping layer. The unit specs in
101
+ `spec/doi/relaton/doi/parser_spec.rb` pin the behaviour, not the layer, so they hold either way.
102
+
103
+ `spec/doi/relaton/doi_spec.rb`'s shared `"fetch document"` example asserts
104
+ `Nokogiri::XML(xml).errors` is empty. That is the property relaton-render requires
105
+ (metanorma-pdfa#99), and it guards every integration example.
@@ -0,0 +1,89 @@
1
+ = Relaton::Doi retrieves bibliographic items using DOI
2
+
3
+ Relaton::Doi is a Ruby gem that implements the
4
+ https://github.com/metanorma/metanorma-model-iso#iso-bibliographic-item[IsoBibliographicItem model].
5
+
6
+ You can use it to retrieve metadata of Standards from https://crossref.org, and
7
+ access such metadata through the `BibliographicItem` object.
8
+
9
+ To collect metadata this gem makes a few requests to the CrossRef API. It may take a few seconds to get the metadata.
10
+
11
+ == Usage
12
+
13
+ === Retrieving items of known type using DOI
14
+
15
+ If the resulting bibliographic item returned from CrossRef is a known Relaton
16
+ flavor, such as a NIST or IEEE standard, the corresponding Relaton class object
17
+ will be returned via the call.
18
+
19
+ [source,ruby]
20
+ ----
21
+ require 'relaton/doi'
22
+ => true
23
+
24
+ # get NIST standard
25
+ Relaton::Doi::Crossref.get "doi:10.6028/nist.ir.8245"
26
+ [relaton-doi] INFO: (doi:10.6028/nist.ir.8245) Fetching from search.crossref.org ...
27
+ [relaton-doi] INFO: (doi:10.6028/nist.ir.8245) Found: `10.6028/nist.ir.8245`
28
+ => #<Relaton::Nist::ItemData:0x000000011040a338
29
+ ...
30
+
31
+ # get RFC standard
32
+ Relaton::Doi::Crossref.get "doi:10.17487/RFC0001"
33
+ [relaton-doi] INFO: (doi:10.17487/RFC0001) Fetching from search.crossref.org ...
34
+ [relaton-doi] INFO: (doi:10.17487/RFC0001) Found: `10.17487/rfc0001`
35
+ => #<Relaton::Ietf::ItemData:0x0000000110be8410
36
+ ...
37
+
38
+ # get BIPM standard
39
+ Relaton::Doi::Crossref.get "doi:10.1088/0026-1394/29/6/001"
40
+ [relaton-doi] INFO: (doi:10.1088/0026-1394/29/6/001) Fetching from search.crossref.org ...
41
+ [relaton-doi] INFO: (doi:10.1088/0026-1394/29/6/001) Found: `10.1088/0026-1394/29/6/001`
42
+ => #<Relaton::Bipm::ItemData:0x00000001118dcbf8
43
+ ...
44
+
45
+ # get IEEE standard
46
+ Relaton::Doi::Crossref.get "doi:10.1109/ieeestd.2014.6835311"
47
+ [relaton-doi] INFO: (doi:10.1109/ieeestd.2014.6835311) Fetching from search.crossref.org ...
48
+ [relaton-doi] INFO: (doi:10.1109/ieeestd.2014.6835311) Found: `10.1109/ieeestd.2014.6835311`
49
+ => #<Relaton::Ieee::ItemData:0x0000000110ec5c00
50
+ ...
51
+ ----
52
+
53
+ === Retrieving items of unknown types via DOI
54
+
55
+ If the resulting bibliographic item returned from CrossRef is of an unknown type
56
+ to Relaton, an instance of RelatonBib::BibliographicItem will be returned.
57
+
58
+ [source,ruby]
59
+ ----
60
+ Relaton::Doi::Crossref.get "doi:10.1109/ACCESS.2017.2739804"
61
+ [relaton-doi] INFO: (doi:10.1109/ACCESS.2017.2739804) Fetching from search.crossref.org ...
62
+ [relaton-doi] INFO: (doi:10.1109/ACCESS.2017.2739804) Found: `10.1109/access.2017.2739804`
63
+ => #<Relaton::Bib::ItemData:0x0000000110f0bde0
64
+ ...
65
+ ----
66
+
67
+ === Logging
68
+
69
+ Relaton::Doi 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.
70
+
71
+ == Development
72
+
73
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
74
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
75
+ prompt that will allow you to experiment.
76
+
77
+ To install this gem onto your local machine, run `bundle exec rake install`. To
78
+ release a new version, update the version number in `version.rb`, and then run
79
+ `bundle exec rake release`, which will create a git tag for the version, push
80
+ git commits and the created tag, and push the `.gem` file to
81
+ https://rubygems.org[rubygems.org].
82
+
83
+ == Contributing
84
+
85
+ Bug reports and pull requests are welcome on GitHub at https://github.com/relaton/relaton-doi.
86
+
87
+ == License
88
+
89
+ The gem is available as open source under the terms of the https://opensource.org/licenses/MIT[MIT License].