pubid 2.0.0.pre.alpha.12 → 2.0.0.pre.alpha.13

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 (227) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +43 -1
  3. data/data/ieee/update_codes.yaml +17 -4
  4. data/data/nist/update_codes.yaml +7 -3
  5. data/lib/pubid/adobe/builder.rb +2 -0
  6. data/lib/pubid/all_parts.rb +201 -0
  7. data/lib/pubid/all_parts_identifier.rb +19 -0
  8. data/lib/pubid/amca/CLAUDE.md +47 -0
  9. data/lib/pubid/amca/builder.rb +3 -5
  10. data/lib/pubid/amca/identifiers/base.rb +10 -0
  11. data/lib/pubid/amca/identifiers/publication.rb +13 -0
  12. data/lib/pubid/amca/parser.rb +2 -1
  13. data/lib/pubid/amca/renderer.rb +22 -33
  14. data/lib/pubid/amca/urn_generator.rb +21 -2
  15. data/lib/pubid/amca/urn_parser.rb +36 -10
  16. data/lib/pubid/ansi/builder.rb +6 -0
  17. data/lib/pubid/api/CLAUDE.md +23 -0
  18. data/lib/pubid/api/builder.rb +2 -0
  19. data/lib/pubid/ashrae/CLAUDE.md +13 -0
  20. data/lib/pubid/ashrae/builder.rb +58 -14
  21. data/lib/pubid/ashrae/identifiers/errata.rb +14 -2
  22. data/lib/pubid/ashrae/identifiers/interpretation.rb +2 -10
  23. data/lib/pubid/ashrae/parser.rb +62 -28
  24. data/lib/pubid/ashrae/renderer.rb +32 -1
  25. data/lib/pubid/ashrae/urn_generator.rb +32 -9
  26. data/lib/pubid/asme/CLAUDE.md +25 -0
  27. data/lib/pubid/asme/builder.rb +16 -9
  28. data/lib/pubid/asme/components/code.rb +2 -0
  29. data/lib/pubid/asme/identifiers/standard.rb +6 -1
  30. data/lib/pubid/asme/parser.rb +41 -14
  31. data/lib/pubid/astm/CLAUDE.md +9 -0
  32. data/lib/pubid/astm/builder.rb +2 -0
  33. data/lib/pubid/astm/components/code.rb +2 -0
  34. data/lib/pubid/astm/parser.rb +4 -1
  35. data/lib/pubid/bipm/CLAUDE.md +11 -0
  36. data/lib/pubid/bipm/builder.rb +2 -0
  37. data/lib/pubid/bsi/CLAUDE.md +93 -0
  38. data/lib/pubid/bsi/builder.rb +13 -11
  39. data/lib/pubid/bsi/identifiers/addendum_document.rb +2 -0
  40. data/lib/pubid/bsi/identifiers/adopted_european_norm.rb +6 -54
  41. data/lib/pubid/bsi/identifiers/adopted_international_standard.rb +5 -22
  42. data/lib/pubid/bsi/identifiers/amendment.rb +36 -12
  43. data/lib/pubid/bsi/identifiers/bundled_identifier.rb +2 -0
  44. data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +23 -26
  45. data/lib/pubid/bsi/identifiers/corrigendum.rb +29 -12
  46. data/lib/pubid/bsi/identifiers/expert_commentary.rb +6 -7
  47. data/lib/pubid/bsi/identifiers/national_annex.rb +18 -20
  48. data/lib/pubid/bsi/identifiers/root_identity.rb +31 -0
  49. data/lib/pubid/bsi/identifiers/set.rb +2 -0
  50. data/lib/pubid/bsi/identifiers/supplement_document.rb +2 -0
  51. data/lib/pubid/bsi/identifiers.rb +1 -0
  52. data/lib/pubid/bsi/parser.rb +8 -8
  53. data/lib/pubid/bsi/renderer.rb +20 -20
  54. data/lib/pubid/bsi/urn_generator.rb +28 -18
  55. data/lib/pubid/builder/base.rb +27 -0
  56. data/lib/pubid/calconnect/builder.rb +2 -0
  57. data/lib/pubid/calconnect/identifier.rb +4 -0
  58. data/lib/pubid/ccsds/builder.rb +2 -0
  59. data/lib/pubid/ccsds/identifier.rb +8 -0
  60. data/lib/pubid/cen_cenelec/CLAUDE.md +59 -0
  61. data/lib/pubid/cen_cenelec/builder.rb +6 -1
  62. data/lib/pubid/cen_cenelec/identifier.rb +10 -27
  63. data/lib/pubid/cen_cenelec/identifiers/amendment.rb +3 -10
  64. data/lib/pubid/cen_cenelec/identifiers/corrigendum.rb +3 -10
  65. data/lib/pubid/cen_cenelec/parser.rb +11 -3
  66. data/lib/pubid/cie/CLAUDE.md +58 -0
  67. data/lib/pubid/cie/builder.rb +2 -0
  68. data/lib/pubid/cie/components/language.rb +2 -0
  69. data/lib/pubid/components/adoption.rb +2 -0
  70. data/lib/pubid/components/code.rb +2 -0
  71. data/lib/pubid/components/date.rb +8 -6
  72. data/lib/pubid/components/edition.rb +2 -0
  73. data/lib/pubid/components/iteration.rb +2 -0
  74. data/lib/pubid/components/language.rb +2 -0
  75. data/lib/pubid/components/locality.rb +2 -0
  76. data/lib/pubid/components/publisher.rb +2 -0
  77. data/lib/pubid/components/relationship.rb +2 -0
  78. data/lib/pubid/components/stage.rb +2 -0
  79. data/lib/pubid/components/supplement.rb +2 -0
  80. data/lib/pubid/components/type.rb +2 -0
  81. data/lib/pubid/components/typed_stage.rb +8 -0
  82. data/lib/pubid/csa/CLAUDE.md +41 -0
  83. data/lib/pubid/csa/builder.rb +2 -0
  84. data/lib/pubid/csa/identifier.rb +13 -1
  85. data/lib/pubid/csa/renderer.rb +12 -12
  86. data/lib/pubid/csa/single_identifier.rb +17 -0
  87. data/lib/pubid/doi/builder.rb +2 -0
  88. data/lib/pubid/easc/builder.rb +2 -0
  89. data/lib/pubid/ecma/CLAUDE.md +28 -0
  90. data/lib/pubid/ecma/builder.rb +2 -0
  91. data/lib/pubid/ecma/identifier.rb +7 -0
  92. data/lib/pubid/etsi/CLAUDE.md +34 -0
  93. data/lib/pubid/etsi/builder.rb +2 -0
  94. data/lib/pubid/etsi/components/code.rb +6 -0
  95. data/lib/pubid/etsi/components/version.rb +2 -0
  96. data/lib/pubid/etsi/identifiers/etsi_standard.rb +7 -0
  97. data/lib/pubid/evs/CLAUDE.md +58 -0
  98. data/lib/pubid/evs/builder.rb +2 -0
  99. data/lib/pubid/gb/CLAUDE.md +140 -0
  100. data/lib/pubid/gb/builder.rb +7 -2
  101. data/lib/pubid/gb/identifier.rb +5 -3
  102. data/lib/pubid/gb/identifiers/all_parts.rb +17 -0
  103. data/lib/pubid/gb/identifiers.rb +1 -0
  104. data/lib/pubid/gb/renderer.rb +0 -1
  105. data/lib/pubid/gost/CLAUDE.md +64 -0
  106. data/lib/pubid/gost/builder.rb +3 -1
  107. data/lib/pubid/gost/identifier.rb +5 -0
  108. data/lib/pubid/iala/CLAUDE.md +82 -0
  109. data/lib/pubid/iala/builder.rb +2 -0
  110. data/lib/pubid/iana/CLAUDE.md +7 -0
  111. data/lib/pubid/iana/builder.rb +2 -0
  112. data/lib/pubid/identifier.rb +161 -17
  113. data/lib/pubid/idf/builder.rb +6 -1
  114. data/lib/pubid/idf/identifier.rb +5 -0
  115. data/lib/pubid/idf/identifiers/all_parts.rb +17 -0
  116. data/lib/pubid/idf/identifiers.rb +1 -0
  117. data/lib/pubid/iec/CLAUDE.md +31 -0
  118. data/lib/pubid/iec/builder.rb +7 -1
  119. data/lib/pubid/iec/components/consolidated_amendment.rb +4 -0
  120. data/lib/pubid/iec/components/sheet.rb +2 -0
  121. data/lib/pubid/iec/components/trf_info.rb +2 -0
  122. data/lib/pubid/iec/components/vap_suffix.rb +2 -0
  123. data/lib/pubid/iec/identifier.rb +7 -2
  124. data/lib/pubid/iec/identifiers/all_parts.rb +19 -0
  125. data/lib/pubid/iec/identifiers.rb +1 -0
  126. data/lib/pubid/iec/renderer.rb +0 -1
  127. data/lib/pubid/iec/urn_generator.rb +9 -1
  128. data/lib/pubid/iec/urn_parser.rb +3 -2
  129. data/lib/pubid/ieee/CLAUDE.md +97 -0
  130. data/lib/pubid/ieee/builder.rb +134 -5
  131. data/lib/pubid/ieee/components/code.rb +2 -0
  132. data/lib/pubid/ieee/components/draft.rb +35 -2
  133. data/lib/pubid/ieee/components/typed_stage.rb +2 -0
  134. data/lib/pubid/ieee/identifiers/base.rb +20 -0
  135. data/lib/pubid/ieee/identifiers/iec_ieee_copublished.rb +9 -0
  136. data/lib/pubid/ieee/identifiers/joint_development.rb +17 -10
  137. data/lib/pubid/ieee/identifiers/project_draft_identifier.rb +8 -1
  138. data/lib/pubid/ieee/parser.rb +134 -21
  139. data/lib/pubid/ieee/renderer.rb +40 -7
  140. data/lib/pubid/ieee/urn_generator.rb +31 -0
  141. data/lib/pubid/ietf/CLAUDE.md +7 -0
  142. data/lib/pubid/ietf/builder.rb +2 -0
  143. data/lib/pubid/iho/builder.rb +2 -0
  144. data/lib/pubid/isbn/builder.rb +2 -0
  145. data/lib/pubid/iso/CLAUDE.md +47 -0
  146. data/lib/pubid/iso/builder.rb +19 -5
  147. data/lib/pubid/iso/components/publisher.rb +2 -0
  148. data/lib/pubid/iso/identifier.rb +6 -14
  149. data/lib/pubid/iso/identifiers/all_parts.rb +19 -0
  150. data/lib/pubid/iso/identifiers/directives_supplement.rb +4 -2
  151. data/lib/pubid/iso/identifiers.rb +1 -0
  152. data/lib/pubid/iso/rendering_style.rb +0 -1
  153. data/lib/pubid/itu/CLAUDE.md +69 -0
  154. data/lib/pubid/itu/builder.rb +2 -0
  155. data/lib/pubid/itu/components/code.rb +2 -0
  156. data/lib/pubid/itu/components/designation.rb +2 -0
  157. data/lib/pubid/itu/components/sector.rb +2 -0
  158. data/lib/pubid/itu/components/series.rb +2 -0
  159. data/lib/pubid/itu/identifiers/supplement.rb +15 -0
  160. data/lib/pubid/jcgm/CLAUDE.md +7 -0
  161. data/lib/pubid/jcgm/builder.rb +2 -0
  162. data/lib/pubid/jcgm/components/publisher.rb +2 -0
  163. data/lib/pubid/jis/builder.rb +5 -1
  164. data/lib/pubid/jis/identifier.rb +5 -17
  165. data/lib/pubid/jis/identifiers/all_parts.rb +19 -0
  166. data/lib/pubid/jis/identifiers.rb +1 -0
  167. data/lib/pubid/jis/renderer.rb +0 -2
  168. data/lib/pubid/jis/urn_generator.rb +0 -1
  169. data/lib/pubid/nist/CLAUDE.md +56 -0
  170. data/lib/pubid/nist/builder.rb +2 -0
  171. data/lib/pubid/nist/components/edition.rb +2 -0
  172. data/lib/pubid/nist/components/issue_number.rb +2 -0
  173. data/lib/pubid/nist/components/part.rb +2 -0
  174. data/lib/pubid/nist/components/stage.rb +2 -0
  175. data/lib/pubid/nist/components/supplement.rb +2 -0
  176. data/lib/pubid/nist/components/translation.rb +2 -0
  177. data/lib/pubid/nist/components/update.rb +2 -0
  178. data/lib/pubid/nist/components/version.rb +2 -0
  179. data/lib/pubid/nist/components/volume.rb +2 -0
  180. data/lib/pubid/nist/identifiers/base.rb +24 -3
  181. data/lib/pubid/nist/parser.rb +15 -2
  182. data/lib/pubid/nist/preprocessor.rb +53 -2
  183. data/lib/pubid/oasis/CLAUDE.md +19 -0
  184. data/lib/pubid/oasis/builder.rb +2 -0
  185. data/lib/pubid/oasis/identifier.rb +19 -0
  186. data/lib/pubid/ogc/CLAUDE.md +34 -0
  187. data/lib/pubid/ogc/builder.rb +2 -0
  188. data/lib/pubid/ogc/identifier.rb +11 -0
  189. data/lib/pubid/oiml/CLAUDE.md +189 -0
  190. data/lib/pubid/oiml/builder.rb +20 -0
  191. data/lib/pubid/oiml/components/code.rb +6 -0
  192. data/lib/pubid/oiml/identifier.rb +13 -0
  193. data/lib/pubid/oiml/identifiers/annex.rb +4 -0
  194. data/lib/pubid/oiml/identifiers/certification_system.rb +34 -0
  195. data/lib/pubid/oiml/identifiers/code_number.rb +8 -0
  196. data/lib/pubid/oiml/identifiers/dual_published.rb +174 -0
  197. data/lib/pubid/oiml/identifiers.rb +2 -0
  198. data/lib/pubid/oiml/parser.rb +35 -4
  199. data/lib/pubid/oiml/renderer.rb +23 -1
  200. data/lib/pubid/oiml/single_identifier.rb +4 -0
  201. data/lib/pubid/oiml/supplement_identifier.rb +7 -0
  202. data/lib/pubid/oiml/urn_generator.rb +28 -0
  203. data/lib/pubid/oiml.rb +5 -0
  204. data/lib/pubid/omg/CLAUDE.md +15 -0
  205. data/lib/pubid/omg/builder.rb +2 -0
  206. data/lib/pubid/parser/grammar.rb +23 -0
  207. data/lib/pubid/plateau/builder.rb +2 -0
  208. data/lib/pubid/plateau/identifiers/base.rb +4 -0
  209. data/lib/pubid/plateau/supplement_identifier.rb +14 -2
  210. data/lib/pubid/plateau/urn_generator.rb +7 -1
  211. data/lib/pubid/renderers/human_readable.rb +0 -1
  212. data/lib/pubid/sae/builder.rb +2 -0
  213. data/lib/pubid/sae/components/date.rb +2 -0
  214. data/lib/pubid/sae/components/type.rb +2 -0
  215. data/lib/pubid/subset_match.rb +197 -0
  216. data/lib/pubid/tgpp/CLAUDE.md +43 -0
  217. data/lib/pubid/tgpp/builder.rb +2 -0
  218. data/lib/pubid/tgpp/identifier.rb +14 -0
  219. data/lib/pubid/type_resolver.rb +14 -2
  220. data/lib/pubid/un/builder.rb +2 -0
  221. data/lib/pubid/version.rb +1 -1
  222. data/lib/pubid/w3c/CLAUDE.md +7 -0
  223. data/lib/pubid/w3c/builder.rb +2 -0
  224. data/lib/pubid/xsf/CLAUDE.md +11 -0
  225. data/lib/pubid/xsf/builder.rb +2 -0
  226. data/lib/pubid.rb +16 -3
  227. metadata +44 -2
@@ -115,7 +115,7 @@ module Pubid
115
115
 
116
116
  # Reaffirmation - preserve original format and determine spacing
117
117
  if id.reaffirmation && !id.reaffirmation.to_s.empty?
118
- result += render_reaffirmation(id)
118
+ result += render_reaffirmation(id, year_prints_4digit: id.original_year_4digit == true)
119
119
  end
120
120
 
121
121
  # Package (already has leading space from parser)
@@ -225,11 +225,13 @@ module Pubid
225
225
 
226
226
  private
227
227
 
228
- # Reaffirmation rendering helper - shared across Base, Cec, and Series
229
- def render_reaffirmation(id)
230
- # Check if year was originally 2-digit (original_year_4digit flag)
231
- year_was_2digit = !id.original_year_4digit
232
-
228
+ # Reaffirmation rendering helper - shared across Base, Cec, and Series.
229
+ # The spacing follows the PRINTED year, not the parsed spelling: a
230
+ # 4-digit printed year glues ("C108.1.2-M1981(R2013)"), a 2-digit
231
+ # printed year takes a space ("C22.2 NO. 125-M84 (R2004)") — even
232
+ # when the input spelled the year 4-digit, since the CEC render
233
+ # always displays 2 digits.
234
+ def render_reaffirmation(id, year_prints_4digit: false)
233
235
  # Check if reaffirmation was originally 4-digit (original_reaffirmation_4digit flag)
234
236
  reaffirmation_was_4digit = id.original_reaffirmation_4digit
235
237
 
@@ -245,13 +247,11 @@ module Pubid
245
247
  id.reaffirmation.to_s
246
248
  end
247
249
 
248
- # Determine spacing based on original formats
249
- if year_was_2digit && reaffirmation_was_4digit
250
- # Year was 2-digit, reaffirmation was 4-digit → add space
251
- " (R#{reaffirmation_str})"
252
- else
253
- # Both 2-digit, both 4-digit, or other cases → no space
250
+ # Determine spacing based on the printed year
251
+ if year_prints_4digit || !reaffirmation_was_4digit
254
252
  "(R#{reaffirmation_str})"
253
+ else
254
+ " (R#{reaffirmation_str})"
255
255
  end
256
256
  end
257
257
 
@@ -33,6 +33,23 @@ module Pubid
33
33
  attribute :package, :string # Package portion (Code, Handbook, etc.)
34
34
  attribute :publisher_prefix, :string # Original prefix: "CAN/CSA-", "CSA", "CAN3-"
35
35
 
36
+ # Format flags that describe a value, keyed by that value. When the
37
+ # reference omits the value, a subset match skips its flags too: a bare
38
+ # `CSA C22.2 NO. 125` has `original_year_4digit` at its default `false`,
39
+ # which would otherwise refuse `CSA C22.2 NO. 125-M1984`.
40
+ SUBSET_FORMAT_FLAGS = {
41
+ year: %i[year_format year_prefix original_year_4digit french],
42
+ reaffirmation: %i[original_reaffirmation_4digit],
43
+ }.freeze
44
+
45
+ def subset_attribute_match?(name, mine, theirs)
46
+ return true if SUBSET_FORMAT_FLAGS.any? do |value, flags|
47
+ public_send(value).nil? && flags.include?(name)
48
+ end
49
+
50
+ super
51
+ end
52
+
36
53
  # True when the reference printed no publisher at all ("C22.1-15"), so
37
54
  # rendering must not supply the default "CSA".
38
55
  #
@@ -16,3 +16,5 @@ module Pubid
16
16
  end
17
17
  end
18
18
  end
19
+
20
+ Pubid::Doi::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -53,3 +53,5 @@ module Pubid
53
53
  end
54
54
  end
55
55
  end
56
+
57
+ Pubid::Easc::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -0,0 +1,28 @@
1
+ # ECMA flavor notes
2
+
3
+ ECMA edition and volume in the printed identifier, and the index key.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/ecma/` or `spec/pubid/ecma/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **ECMA edition and volume — the one flavor whose `to_s` deliberately renders MORE than the document number**: `Relaton::Index::Type#add_or_update` keys on a **bare** `id.to_s` and cannot pass render options, so the default rendering **is** the index key. ECMA's renderer used to say outright that "the edition is never rendered", and 740 of the 804 published `relaton-data-ecma` rows carry one — so all 804 rows collapsed onto **421** keys and **383 were dropped on every crawl, silently** (`ECMA-74` 22 rows to 1, `ECMA-262` 18 to 1). `edition` and `volume` are therefore rendered **by default**, with `to_s(with_edition: false, with_volume: false)` as the opt-out — the inverse of pubid's usual opt-in, and per-flavor divergence in exactly the way 3GPP (`with_publisher: false`) and W3C (`true`) already diverge. **`volume` is a new `attribute :volume, :string`** (relaton's `:vol:`), needed by exactly four rows: ECMA-269 ed3 vol1-4 share one docidentifier **and one title**, so the volume is the only thing separating those four index rows — it is an index discriminator, not something the document prints about itself. Both are plain `:string`s, so `#exclude`/`#matches?` already treat them as wildcards with no override (`ECMA-269` matches every edition under `ignore: %i[edition volume]`); ECMA's `partial_ref_spec.rb` entry moved from `omits: []` to `%i[edition volume]`.
8
+ **Grammar (`lib/pubid/ecma/parser.rb`)**: ` ed<N>` (dotted — `ed5.1` is real, ECMA-402) and ` vol<N>` attach as two independent `.maybe`s **after** the `tr | mem | standard` alternation, so a technical report and a memento carry them exactly as a standard does. The `standard` branch also gained a **space separator** (`ECMA 6`), which is a **NORMALIZING parse** — it renders back as `ECMA-6`, so it lives in `spec/pubid/ecma/edition_volume_spec.rb` and must **never** enter the byte-exact `pass` fixtures (the BIPM `CIPM/2005-06(REV)` precedent). **Two PEG facts are load-bearing and easy to lose.** (1) `standard` cannot shadow ` TR/`/` MEM/` because after the space it demands a **digit** — verified by inverting the alternation order, which still parses both correctly. Keeping `tr | mem` first is belt-and-braces; the **disjoint first character** is the real invariant. (2) Appending a sequence moved the alternation **out of tail position**, so Parslet no longer threads `consume_all` into it and **will not backtrack into an alternative that already succeeded** (`rule(:alt) { str("ab") | str("a") }; rule(:root) { alt >> str("bc") }` fails on `"abc"`). A future branch matching a proper prefix of another branch's input would now fail the whole parse where before it was rescued. `maybe` is `repeat(0,1)` and does not backtrack either, so a marker without its number (` ed`, ` ed5.`, ` vol`) fails atomically and then fails `consume_all` — which is what rejects `ECMA-6 ed` and pins the canonical suffix order (`ECMA-6 vol1 ed3` is rejected).
9
+ **Render-option threading — `:with_volume` rides the `:trademark` route, deliberately.** `Pubid::Identifier#render` `except`s a flag from the rendering context and `slice`s it to the renderer; `:with_edition` was already in both, and `:trademark` is the precedent for an **IEEE-only** flag carried the same way. So `:with_volume` was added to those two lines and **`build_rendering_context`'s signature was left alone** — widening it would touch `Pubid::Iso::SingleIdentifier`'s strict override. The payoff is that **ECMA's `to_s` needed no change at all** and the renderer owns the defaults: no second transient ivar beside the pre-existing `@with_publisher`, so rendering stays stateless and reentrant. Residual blast radius, accepted: passing `with_volume:` to a *non*-ECMA identifier now reaches that flavor's renderer instead of dying in the context builder — `Renderers::HumanReadable` absorbs unknown opts by design, while ISO's three strict renderers (`DirectivesRenderer`/`IwaRenderer`/`SupplementRenderer`) raise, which they did before too.
10
+ **All three identity surfaces, and the two defects found by measuring them.** Per the project rule, edition and volume reach `to_s`, `to_urn` **and** `to_mr_string`. **URN**: labelled segments after the existing `part-<n>` (`urn:ecma:269:ed-3:vol-2`), purely additive — `UrnParser` probes for a labelled prefix (a `shift_labelled` helper using `delete_prefix`, not `sub`), so a URN minted before the segments existed still reads back. The **dot of a decimal edition is KEPT** (`urn:ecma:402:ed-5.1`): `.` is `unreserved` in RFC 8141 and the URN has an inverse, so it must be lossless. **MR**: ECMA defined **no** `mr_*` hooks, so (a) the base `mr_edition` (`edition&.number`) **raised `NoMethodError` on all 740 edition-carrying rows** — the BIPM/ASTM/ASHRAE crash again — and (b) `mr_publisher` was nil (the publisher is the `PUBLISHER` constant, not the inherited attribute) and `mr_type` was nil (the Builder picks a class, it never sets a `typed_stage`), so **`ECMA-101` and `ECMA TR/101` both slugged to `"101"`** and one output filename overwrote the other. Four hooks fix it, sanitising **by charset** (`gsub(/[^a-z0-9]+/, "-")`, the BIPM `mr_slug` precedent) rather than by an escape list. **The MR is deliberately lossy where the URN is not** (`ed5-1` vs `ed-5.1`): `Renderers::MrString` joins **segments** with `.`, so a dot inside one breaks the documented structure, and nothing parses an ECMA MR back (`Parsers::MrString::FLAVOR_MAP` has no ECMA entry). Say so in the comment rather than leaving the asymmetry looking accidental.
11
+ **New shared `mr_volume` slot.** `Renderers::MrString#render_flat` had no volume segment, so it gained one plus a **literal-nil** `mr_volume` hook on `Pubid::Identifier` — literally nil, **not** `volume&.to_s`, because `Pubid::Nist::Identifiers::Base` declares its own `volume` attribute with different MR semantics and a generic reader would silently move NIST's slug. Inert by construction (no flavor defined `mr_volume`, there is no `method_missing` in `lib/pubid`, and NIST overrides `to_mr_string` wholesale), and verified rather than assumed: a `main` baseline replay of `to_mr_string` over all **98,217** identifiers in `spec/fixtures/*/identifiers/pass/*.txt` is **byte-identical**. `render_flat` was rewritten around a frozen `SEGMENTS` list of hook names while it was open, which also cleared a pre-existing ABC offence.
12
+ **The corpus is the gate.** `spec/fixtures/ecma/identifiers/pass/index_corpus.txt` is the complete published index (804 rows) rendered as `"<:id:>[ ed<:ed:>][ vol<:vol:>]"`, generated from `relaton-data-ecma/index-v1.yaml`; `spec/pubid/ecma/corpus_spec.rb` asserts over all of it that every row parses and renders byte-exactly, that `to_s`/`to_urn`/`to_mr_string` each give **804 distinct** keys, that every slug is filename-safe, that the URN and the hash both round-trip, that `from_hash(to_hash) == id` (stronger than hash equality — `#matches?` is built on `==`), and that `root.number` is non-empty. **ECMA has no `identifiers/full/` corpus**, so `rake "validation:classify[ecma]"` cannot run and cannot clobber these files — its fixtures are hand-maintained, unlike every generated flavor. Note ECMA's `fixtures_spec.rb` glob is **correct** (two `..`, lowercase dir) and is not one of the ten dead ones. **Two existing specs asserted the inverted contract** and had to flip (`serialization_spec.rb`'s "omits edition from the printed string"), so a green suite before the code change was the failure signal, not the success one. **relaton note**: this unblocks `relaton__relaton__ecma-consume-index-v2`; `relaton-data-ecma`'s `index-v1` is string-keyed and no ECMA pubid index is published yet, so nothing needs migrating — but the crawl must run after this lands, or the rows key on the collapsed form again. (hand-off: ecma-edition-and-volume.)
13
+
14
+ ## Subset match: strict attributes
15
+
16
+ Read `docs/SUBSET_MATCH.md` first. `===` reads a nil part of the reference as
17
+ a wildcard, which is wrong for the attributes below: the flavor models a nil
18
+ value as "this document has none". They are declared with `subset_strict`, so
19
+ `===` compares them exactly and a stated collection is not a prefix. A caller
20
+ that does want every part of a document sets `all_parts` on the reference, or
21
+ keeps `#matches?(other, ignore:)`.
22
+
23
+ - **`part` is strict** (`lib/pubid/ecma/identifier.rb`). `ECMA-418` is the
24
+ whole document, not part 1 of it, so `ECMA-418 === ECMA-418-1 ed1` is
25
+ false. relaton measured 5 spurious part rows for that one reference
26
+ before the declaration existed. `edition` and `volume` stay wildcards —
27
+ an omitted edition really does mean "any edition", which is why
28
+ `partial_ref_spec.rb` lists them under `omits`.
@@ -43,3 +43,5 @@ module Pubid
43
43
  end
44
44
  end
45
45
  end
46
+
47
+ Pubid::Ecma::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -20,6 +20,13 @@ module Pubid
20
20
  # is only present for standards that split into parts (e.g. ECMA-418-1).
21
21
  attribute :number, :string
22
22
  attribute :part, :string
23
+
24
+ # A nil `part` means the document has no part, not "any part":
25
+ # `ECMA-418` is not `ECMA-418-1`. relaton measured 5 spurious part rows
26
+ # for that one reference. A caller that wants every part of a document
27
+ # sets `all_parts` on the reference.
28
+ subset_strict :part
29
+
23
30
  # Edition is relaton's `:ed:` (the index stores {:id, :ed, :vol} and the
24
31
  # YAML has edition.content). It is part of the printed identifier: the
25
32
  # relaton index keys on a bare `to_s`, so without it all 22 editions of
@@ -0,0 +1,34 @@
1
+ # ETSI flavor notes
2
+
3
+ ETSI index key, part exclusion and MR slug.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/etsi/` or `spec/pubid/etsi/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ ## From the root note "ETSI and OIML index key (`root.number`): the `code` component became flat leaf columns"
8
+
9
+ **(3) `exclude(:part)` needed an override.** With `parts` a real collection attribute the base `#exclude` reaches it and nils it — but a part-less reference parses with `parts` defaulting to `[]`, so `nil != []` would break `==` and therefore `#matches?` against exactly the reference the exclusion exists to match. `Pubid::Etsi::Identifier#exclude` resets it to `[]` after `super` (the "reset the WHOLE cluster" rule from CSA's year and IEEE's year/month/day); this also covers supplements, since the base recurses into the nested `base` through `exclude_from_nested`. The old `exclude_from_nested` `Components::Code` special-case was deleted as dead. **(4) The ETSI MR slug was rewritten, and this is the loud part of the change.** ETSI defined no `mr_*` hooks, and the base ones read the inherited `number`/`part`/`typed_stage`/`edition` — all nil for ETSI — so **every** ETSI slug was `etsi.<date>`: **454 distinct slugs for 24,724 documents**, 794 of them sharing `etsi.2018-07`, and `to_slug` is what consumers use as an output **filename**. `EtsiStandard` now supplies `mr_number_with_part` (number + `parts`), `mr_type` (the ETSI type token) and `mr_edition` (the `version`) — all four fields ETSI's `==` compares, per the rule that an identity-bearing marker must reach **every** identity surface, not just `==`. `SupplementIdentifier` gained `mr_supplement_suffix` so the shared renderer **recurses into `base`** instead of slugging the supplement flat off its own ordinal (which made every `/C1` corrigendum of one month share `etsi.1.<date>`) — the ITU `AnnexOfRecommendation` precedent. A shared **`mr_sanitize`** filters **by charset** (`[^a-z0-9-]` → `-`), not by an enumerated escape list: an ETSI number legitimately contains a **space** (`300 175`, `GSM 02.01`), which is outside the `[a-z0-9.-]` charset `Renderers::MrString` documents, and a dot inside a segment would break that renderer's `.`-joined segment structure (the BIPM `bipm.si-brochure.9e-v3-01.e` precedent). Result: **24,724 distinct slugs for 24,724 ids, 0 collisions, 0 unsafe characters.** OIML's MR is **unchanged** — it already had its own `mr_number_with_part` reading through `code`, which the derived reader keeps working.
10
+
11
+ ## Subset match: strict attributes
12
+
13
+ Read `docs/SUBSET_MATCH.md` first. `===` reads a nil part of the reference as
14
+ a wildcard, which is wrong for the attributes below: the flavor models a nil
15
+ value as "this document has none". They are declared with `subset_strict`, so
16
+ `===` compares them exactly and a stated collection is not a prefix. A caller
17
+ that does want every part of a document sets `all_parts` on the reference, or
18
+ keeps `#matches?(other, ignore:)`.
19
+
20
+ - **`parts` is strict**, on the leaf `EtsiStandard` and on
21
+ `Etsi::Components::Code`. A stated part list is not a prefix, so
22
+ `ETSI TS 129 198-4 === ETSI TS 129 198-4-5` is false. This was the
23
+ largest case relaton measured: over 10,595 bare type+code queries the
24
+ matched set changed for 158 and the `best_match` winner for 122, and
25
+ `ETSI TS 129 198-4` resolved to `-4-5`.
26
+ - The declaration sits **on the leaf**, beside the flat columns and for the
27
+ same reason — `EtsiStandard` owns `number`/`minor`/`parts`, while the
28
+ shared `Pubid::Etsi::Identifier` is also `SupplementIdentifier`'s parent.
29
+ `Etsi::Components::Code` carries the same declaration although `===`
30
+ cannot reach it today (`#code` is a derived reader, not an attribute), so
31
+ the two shapes cannot drift.
32
+ - `SUBSET_PAIRS["etsi"]` in `spec/pubid/subset_match_spec.rb` moved from
33
+ `ETSI EN 300 175` to `ETSI EN 300 175-1`: the reference now states its
34
+ part, and the version and the date are what it omits.
@@ -140,3 +140,5 @@ module Pubid
140
140
  end
141
141
  end
142
142
  end
143
+
144
+ Pubid::Etsi::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -14,10 +14,16 @@ module Pubid
14
14
  # Stays independent of Pubid::Components::Code because ETSI uses
15
15
  # +minor+ (a flavor-specific sub-number) plus +parts+.
16
16
  class Code < Lutaml::Model::Serializable
17
+ include ::Pubid::SubsetMatch
18
+
17
19
  attribute :number, :string # Main number
18
20
  attribute :minor, :string # Optional minor part
19
21
  attribute :parts, :string, collection: true, default: [] # Parts array
20
22
 
23
+ # Mirrors the flat `parts` column of EtsiStandard, which owns the
24
+ # identity; this component is derived from it by `#code`.
25
+ subset_strict :parts
26
+
21
27
  # Render code with space for minor and dash-separated parts
22
28
  def to_s
23
29
  result = number.to_s
@@ -8,6 +8,8 @@ module Pubid
8
8
  # Represents an ETSI version string
9
9
  # Format: V1.2.3, V2.0.0, or ed.1
10
10
  class Version < Lutaml::Model::Serializable
11
+ include ::Pubid::SubsetMatch
12
+
11
13
  attribute :version, :string # e.g., "1.1.1", "2.0.0", "1"
12
14
  attribute :is_edition, :boolean, default: -> {
13
15
  false
@@ -44,6 +44,13 @@ module Pubid
44
44
  attribute :minor, :string
45
45
  attribute :parts, :string, collection: true, default: -> { [] }
46
46
 
47
+ # The reference always states its parts. An empty list means the
48
+ # document has no part, and a stated list is not a prefix:
49
+ # `ETSI TS 129 198-4` is not `ETSI TS 129 198-4-5`. Over 10,595 bare
50
+ # ETSI queries relaton measured 122 changed `best_match` winners
51
+ # without this. A caller that wants every part sets `all_parts`.
52
+ subset_strict :parts
53
+
47
54
  # Compact serialization (mirrors ISO/JCGM/OIML): the code fields are
48
55
  # bare scalars (`number`, a `parts` array and, rarely, a `minor`);
49
56
  # Version flattens to a scalar `version` string with an `is_edition`
@@ -0,0 +1,58 @@
1
+ # EVS flavor notes
2
+
3
+ - **The wrapped document is `base`, the uniform parent accessor**: EVS shipped
4
+ with `adopted_identifier`, the name the rest of the codebase already retired.
5
+ The rename covers the Ruby attribute **and** the serialization key, so an EVS
6
+ row now reads `{"_type" => "pubid:evs:national-adoption", "base" => {…}}`.
7
+ **The name is load-bearing, not cosmetic.** `Pubid::Identifier#root` walks
8
+ `base`, so every EVS identifier used to be its own root with a nil `number`,
9
+ and relaton keyed all of them under the empty string `""` — the index-key
10
+ defect this repository records for wrappers in many other flavors.
11
+ `root.number` is now the origin standard's number (`"9001"` for
12
+ `EVS-EN ISO 9001:2015/A1:2024`, through two wrapper layers, because `#root`
13
+ recurses). `Renderers::Annotator#emit_tokens` also walks `base`, so
14
+ `to_s(annotated: true)` now annotates the wrapped document instead of
15
+ returning a bare string.
16
+ **What moved and what did not**: `to_s`, `to_urn` and the identifier classes
17
+ are unchanged; `to_hash` changes one key name, and `to_s(annotated: true)`
18
+ gains spans. EVS has no published `relaton-data-evs` index, so nothing stored
19
+ needs a migration — but the **pubid-testsuite corpus rows carry the old key**
20
+ and report `canonical hash` for all 10 EVS cases until the flavor is
21
+ re-exported. `to_mr_string` is still `""` for EVS (pre-existing: EVS supplies
22
+ no `mr_*` hooks).
23
+ Locked by the "uniform parent accessor" block in `spec/pubid/evs/evs_spec.rb`.
24
+
25
+ - **`from_hash` raised on every EVS hash — a raw default, not load order
26
+ (pubid#383)**: `Pubid::Evs::Identifier.from_hash(id.to_hash)` raised
27
+ `Lutaml::Model::InvalidFormatError` (`undefined method 'key?' for an instance
28
+ of Symbol`). The cause was one line in
29
+ `lib/pubid/evs/identifiers/national_adoption.rb`:
30
+ `attribute :type, Components::Type, default: -> { self.class.type[:key] }`.
31
+ The default was the Symbol `:evs_en`, not a `Components::Type`. The canonical
32
+ `to_hash` drops a default-valued attribute, so no row carries `type`. On
33
+ `from_hash`, lutaml finds no `type` key, resolves the default, and casts it
34
+ into the component through `Components::Type.from_hash(:evs_en)`, which
35
+ raises. The nested cross-flavor wrapped identifier was never the problem:
36
+ it deserializes alone through `Pubid.from_hash`.
37
+ **The fix landed on `main` in `ea6cab30`**, which gives EVS and all six IDF
38
+ classes a `self.class.default_type` class method returning
39
+ `Components::Type.new(abbr: type[:short])` — the cen_cenelec shape. Nothing in
40
+ EVS reads `type` (builder, renderer, URN generator and URN parser), and `type`
41
+ is still absent from the hash, so nothing rendered or serialized moved.
42
+ **The issue thread called it process-shape dependent. It is not.** It raises
43
+ in every load order tried (plain `require`, `Pubid.eager_load_flavors!`, CEN
44
+ loaded first, BSI loaded first), and all 10 corpus cases raised on `main`,
45
+ not only the one row the testsuite ledger names. The exporter looked clean
46
+ because `Conformance::Generator#round_trips?` rescues `StandardError` and
47
+ returns `false`, so a raise there never reached the report.
48
+ **Do not read `self.class` inside the default lambda body.** lutaml calls the
49
+ lambda with `instance_exec` when it has an instance, but calls it plainly
50
+ (`options[:default].call`) when it has none, and `self.class` is then `Class`.
51
+ A `default_type` class method keeps that resolution in one place.
52
+ **Two specs guard the bug class, and they are complementary**:
53
+ `spec/pubid/evs/from_hash_spec.rb` greps `lib/` for the one raw spelling, so a
54
+ copy of that exact line fails fast; `spec/pubid/component_attribute_default_spec.rb`
55
+ **evaluates** every component-typed default of every identifier class of every
56
+ flavor, so a raw default of any spelling or component type fails, whatever
57
+ flavor introduces it. Its `PENDING_RAW_DEFAULTS` table is empty today; an
58
+ entry that starts to pass turns red, which is the signal to delete it.
@@ -24,3 +24,5 @@ module Pubid
24
24
  end
25
25
  end
26
26
  end
27
+
28
+ Pubid::Evs::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -0,0 +1,140 @@
1
+ # GB (Chinese Standard) flavor notes
2
+
3
+ `Pubid::Gb` covers national (`GB`), confidential national (`GBn`),
4
+ sector/industry (`JB`, `HB`, `NY`, …) and social-group (`T/{ORG}`) Chinese
5
+ standards. The mandate category follows a slash: `T` (recommended), `Z`
6
+ (guideline), or nothing (mandatory).
7
+
8
+ ## A flavor `key_value` block REPLACES the base maps — list every attribute
9
+
10
+ `Pubid::Gb::Identifier` declared its own `key_value` block and mapped
11
+ `publisher_code`, `mandate`, `number`, `part` and `all_parts` — but not
12
+ `date`. lutaml does not merge such a block with the maps of
13
+ `::Pubid::Identifier`; it replaces them. So `to_hash` silently dropped the
14
+ publication year of **every** dated GB identifier:
15
+
16
+ ```ruby
17
+ id = Pubid::Gb::Identifier.parse("GB/T 20223-2006")
18
+ id.to_s # => "GB/T 20223-2006"
19
+ id.year # => "2006"
20
+ id.to_hash # no year at all
21
+ Pubid::Gb::Identifier.from_hash(id.to_hash).to_s # => "GB/T 20223"
22
+ ```
23
+
24
+ The year survived `to_s`, `exclude` and `matches?`, so nothing in the flavor's
25
+ own behaviour looked wrong. Only `from_hash(to_hash) == parse` saw it — the
26
+ silent failure mode this repository records elsewhere: `#matches?` is
27
+ `exclude(*ignore) == other.exclude(*ignore)`, so a parsed reference never
28
+ matched a `from_hash`-ed index row, and the lookup returned nothing with no
29
+ error. Two editions of one document (`GB/T 20223-2006` and `GB/T 20223-2014`)
30
+ also collapsed onto one hash.
31
+
32
+ The fix is one line — `map "date", to: :date` — because the shared flat-scalar
33
+ rules do the rest: `flatten_scalar_components` (in `Identifier#to_hash`) writes
34
+ a bare `"year" => "2006"` instead of a nested `date` component, and
35
+ `inflate_scalar_components` reads it back. GB declares no `year` attribute, so
36
+ the `date` → `year` rename applies.
37
+
38
+ ```ruby
39
+ Pubid::Gb::Identifier.parse("GB/T 20223-2006").to_hash
40
+ # => {"_type" => "pubid:gb:standard", "publisher_code" => "GB",
41
+ # "mandate" => "T", "number" => "20223", "year" => "2006"}
42
+ ```
43
+
44
+ **Lesson for any flavor with its own `key_value` block**: an attribute absent
45
+ from the block does not serialize. Assert `from_hash(id.to_hash) == id`, not
46
+ only `to_s` and `to_hash` — the two shapes that were already correct here.
47
+
48
+ GB publishes no `relaton-data-gb` index, so no re-crawl follows.
49
+
50
+ ## `GBn` — a lowercase letter inside the publisher token
51
+
52
+ `GBn` is the confidential national series, and the only one of the 67 prefixes
53
+ the relaton GB flavor carries that did not parse. The publisher rule accepted
54
+ `[A-Z]{1,3}`, which consumed `GB` and then stopped at the `n`.
55
+
56
+ A PEG takes the first branch that matches and does not backtrack into the
57
+ alternatives, so the `str("GBn")` branch comes **before** the uppercase-only
58
+ branch in `rule(:publisher_code)`. `GBn`, `GBn/T` and `GBn/Z` now parse, and
59
+ `GBn GBn/T GBn/Z` are in `PREFIXES`, so `Pubid.parse` routes them.
60
+
61
+ All 67 relaton prefixes now round-trip a dated identifier.
62
+
63
+ ## Decision: the em dash normalizes to an ASCII hyphen
64
+
65
+ Chinese portals print an em dash before the year (`T/ZS 0467—2023`). The parser
66
+ accepts both spellings and the renderer prints an ASCII hyphen:
67
+
68
+ ```ruby
69
+ Pubid::Gb::Identifier.parse("T/ZS 0467—2023").to_s # => "T/ZS 0467-2023"
70
+ ```
71
+
72
+ This is deliberate, not a defect. The two spellings give **equal** identifiers,
73
+ so a reference in either form matches the document. The alternative — a stored
74
+ separator attribute, the CIE `date_separator` shape — would make the two
75
+ spellings unequal and would need an `exclude` override. The relaton fixture
76
+ `spec/gb/fixtures/tgzaepi_001_2018.xml` records the em-dash form and is the one
77
+ place that must follow this decision.
78
+
79
+ ## The series code lives in the inherited `publisher`
80
+
81
+ `GB`, `JB`, `GBn` and `T/GZAEPI` are stored in the `publisher` attribute
82
+ inherited from `::Pubid::Identifier`, a `Components::Publisher`. The flavor had
83
+ its own `publisher_code` string beside it while the inherited attribute stayed
84
+ nil, so two attributes described one value and the shared code that reads
85
+ `publisher` saw nothing.
86
+
87
+ The component serializes as a bare scalar, because the class adds itself to the
88
+ flat-scalar table — the CEN/CENELEC precedent, and for GB's own classes only:
89
+
90
+ ```ruby
91
+ Pubid::Gb::Identifier.flat_scalar_components # => {..., publisher: "publisher"}
92
+ Pubid::Gb::Identifier.flat_scalar_fields # => {..., publisher: :body}
93
+ ```
94
+
95
+ ```ruby
96
+ {"_type" => "pubid:gb:standard", "publisher" => "GB",
97
+ "mandate" => "T", "number" => "20223", "year" => "2006"}
98
+ ```
99
+
100
+ **The URN gained the series, which repairs a collision.** `GB 20223-2006` and
101
+ `GBn 20223-2006` are two documents and shared `urn:gb:20223:2006`; they now
102
+ give `urn:gb:gb:20223:2006` and `urn:gb:gbn:20223:2006`. The shared URN
103
+ generator lowercases the body on its own. The MR slug follows (`gb.20223.2006`,
104
+ `gbn.20223.2006`) and sanitizes the slash of a social-group code by itself
105
+ (`T/GZAEPI` → `t-gzaepi.001.2018`), so GB does not join the IEEE slash ledger.
106
+ Annotated rendering gained a `publisher` span for free.
107
+
108
+ **Note the parse-tree key keeps its name.** `rule(:publisher_code)` in the
109
+ parser still captures `:publisher_code`; parse-tree keys and attribute names
110
+ are different namespaces (the ASHRAE landmine). Only the attribute moved.
111
+
112
+ ## Known gaps
113
+
114
+ `spec/pubid/gb/fixtures_spec.rb` now reads `spec/fixtures/gb/`, which nothing
115
+ read before (the `ten-dead-fixture-specs` class). It carries a tripwire example
116
+ asserting both globs are non-empty, because a wrong glob reports 0 examples
117
+ instead of a failure. GB has no `identifiers/full/identifiers.txt`, so the
118
+ fixtures are hand-written and `rake validation:classify[gb]` does not drive
119
+ them; the reader accepts both the plain and the generated line shapes.
120
+
121
+ The URN carries the series, the number and the year, but **not the mandate**:
122
+ `GB 20223-2006` and `GB/T 20223-2006` both give `urn:gb:gb:20223:2006`, and
123
+ their MR slugs are equal too. The mandatory and the recommended standard are
124
+ two documents, so this is a real collision, narrower than the one the series
125
+ repaired. There is also no `Pubid::Gb::UrnParser`, so a GB URN cannot be read
126
+ back. Nothing consumes a GB URN today, so both are recorded, not fixed.
127
+
128
+ ## Not supported (deliberately)
129
+
130
+ These forms do not parse. Each is outside what the relaton GB flavor supports
131
+ today:
132
+
133
+ | form | what it is |
134
+ |---|---|
135
+ | `DB11/T 123-2020`, `DB37 1234-2020` | local standards; a province code follows `DB` |
136
+ | `Q/SY 123-2020` | enterprise standard |
137
+ | `GB/T 1.1-2020/XG1-2021` | amendment sheet (修改单) |
138
+
139
+ `PREFIXES` lists bare `DB`, `DB/T` and `DB/Z`, so the province-numbered form
140
+ was intended but is not parsed.
@@ -17,14 +17,17 @@ module Pubid
17
17
  def build(data)
18
18
  code, mandate = split_mandate(data[:publisher_code].to_s)
19
19
 
20
- Identifiers::Standard.new(
20
+ identifier = Identifiers::Standard.new(
21
21
  publisher: ::Pubid::Components::Publisher.new(body: code),
22
22
  mandate: mandate || data[:mandate]&.to_s,
23
23
  number: data[:number].to_s,
24
24
  part: data[:part]&.to_s,
25
25
  date: date_for(data[:year]),
26
- all_parts: !data[:all_parts].to_s.empty?,
27
26
  )
27
+
28
+ # "(all parts)" names every part of the document, so it wraps the
29
+ # document, which holds no mark itself.
30
+ data[:all_parts].to_s.empty? ? identifier : identifier.to_all_parts
28
31
  end
29
32
 
30
33
  private
@@ -48,3 +51,5 @@ module Pubid
48
51
  end
49
52
  end
50
53
  end
54
+
55
+ Pubid::Gb::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -7,6 +7,11 @@ module Pubid
7
7
  # descend from this class, so a parsed GB id is always an instance of
8
8
  # Pubid::Gb::Identifier.
9
9
  class Identifier < ::Pubid::Identifier
10
+ # The all-parts identifier of this flavor.
11
+ def self.all_parts_class
12
+ Identifiers::AllParts
13
+ end
14
+
10
15
  # The issuing body code as printed — "GB", "JB", "GBn", "T/GZAEPI" —
11
16
  # lives in the `publisher` attribute inherited from ::Pubid::Identifier.
12
17
  # The flat-scalar hooks below serialize it as a bare string, and the
@@ -25,8 +30,6 @@ module Pubid
25
30
  # uses the dotted form (e.g. "5606.1" => number "5606", part "1").
26
31
  attribute :part, :string
27
32
 
28
- # All-parts flag — true for "GB/T 5606 (all parts)" forms.
29
- attribute :all_parts, :boolean, default: -> { false }
30
33
 
31
34
  # Polymorphic type map for lutaml key_value (de)serialization.
32
35
  GB_TYPE_MAP = {
@@ -44,7 +47,6 @@ module Pubid
44
47
  map "number", to: :number
45
48
  map "part", to: :part
46
49
  map "date", to: :date
47
- map "all_parts", to: :all_parts, render_default: false
48
50
  end
49
51
 
50
52
  PUBLISHER = "CN"
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Gb
5
+ module Identifiers
6
+ # Every part of one GB document: "GB/T 5606 (all parts)".
7
+ class AllParts < ::Pubid::Gb::Identifier
8
+ include ::Pubid::AllParts
9
+
10
+ # The document URN; GB and IDF mark no series in the URN.
11
+ def to_urn
12
+ identity.to_urn
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -3,6 +3,7 @@
3
3
  module Pubid
4
4
  module Gb
5
5
  module Identifiers
6
+ autoload :AllParts, "#{__dir__}/identifiers/all_parts"
6
7
  autoload :Standard, "#{__dir__}/identifiers/standard"
7
8
  end
8
9
  end
@@ -13,7 +13,6 @@ module Pubid
13
13
  class Renderer < ::Pubid::Renderers::Base
14
14
  def render(**_opts)
15
15
  parts = [publisher_portion, " #{number_portion}"]
16
- parts << " (all parts)" if @id.all_parts
17
16
  parts.join
18
17
  end
19
18
 
@@ -0,0 +1,64 @@
1
+ # GOST flavor notes
2
+
3
+ Foreign-adoption routing: the slash-attached prefix boundary that let another
4
+ flavor win the routing race.
5
+
6
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/gost/` or `spec/pubid/gost/`. The root file keeps the cross-flavor contract that every flavor obeys.
7
+
8
+ ## Foreign-adoption routing must use a token-boundary prefix check, not a literal-space check
9
+
10
+ `Gost::Builder#parse_foreign` (`lib/pubid/gost/builder.rb`) resolves the
11
+ foreign standard a GOST document adopts (`"GOST 58904-2020/ISO/TR
12
+ 25901-1:2016"` → the adopted `"ISO/TR 25901-1:2016"`) by routing through
13
+ `Pubid.prefix_flavors` first — the same "registered prefix, not load order"
14
+ mechanism the earlier `gost-adoption-prefix-routing` fix introduced for
15
+ `"GOST 1437-2024 (ASTM D129-18)"` — and falling back to an exhaustive,
16
+ alphabetically-sorted try-every-flavor loop only when no registered prefix
17
+ matches.
18
+
19
+ `prefix_owner`'s boundary check used to require a **literal space** after the
20
+ matched prefix (`raw.start_with?("#{p} ")`). That misses a form where the
21
+ type token is attached to the publisher with a **slash** instead of a space —
22
+ `"ISO/TR 25901-1:2016"`, `"ISO/TS 10303-1:2014"` — because the character
23
+ after `"ISO"` is `/`, not a space. Those strings therefore matched no
24
+ registered owner and fell through to the exhaustive fallback loop, racing
25
+ every registered flavor's grammar alphabetically.
26
+
27
+ **The race was real, not theoretical: `Pubid::Iec`'s own grammar also accepts
28
+ a bare `"ISO/TR 25901-1:2016"`** (as its own identifier, not as a delegation
29
+ to ISO), and renders it `"ISO TR 25901-1:2016"` — a space, IEC's own
30
+ convention, not ISO's. `Pubid::Bsi` also accepts the string and correctly
31
+ delegates to `Pubid::Iso::Identifiers::TechnicalReport`, and normally wins the
32
+ race because `"bsi"` sorts alphabetically before `"iec"` — which is exactly
33
+ why the wrong render (`GOST R 58904-2020/ISO TR 25901-1:2016` instead of
34
+ `.../ISO/TR 25901-1:2016`) was rare and reproduced only intermittently, and
35
+ only under full-suite load (hand-off
36
+ `metanorma__pubid__gost-idt-order-dependent-render.md`; the hand-off chased
37
+ GC/allocation timing and `RenderingContext` memoization as candidate causes —
38
+ neither was it, and neither needed to be pinned down, since routing this
39
+ deterministically by prefix removes the race regardless of what let `bsi`
40
+ occasionally lose it).
41
+
42
+ **Fix**: `prefix_owner` now uses the same token-boundary rule the top-level
43
+ router already uses (`Pubid.prefix_match?` in `lib/pubid.rb` — a non-word
44
+ character, not specifically a space, ends the prefix). That method used to be
45
+ `@api private`; it is now public for exactly this reason — a second,
46
+ hand-copied boundary check in `Gost::Builder` would be the same
47
+ duplicated-logic-drifts-out-of-sync hazard this file documents elsewhere for
48
+ other flavors (found in code review, not the first pass: the initial fix
49
+ duplicated the regex locally instead of calling the shared method). **Do
50
+ not** re-duplicate this boundary check in a flavor's own builder; call
51
+ `::Pubid.prefix_match?(string, prefix)`.
52
+
53
+ **Not affected**: a jointly-owned prefix (`"ISO/IEC"`, `"ISO/IEC TR"`) already
54
+ falls through to the exhaustive fallback by design (`owners.one?` is false),
55
+ and every owner renders it identically — verified for both the bare and the
56
+ type-attached compound forms. Only the single-owner, slash-attached-type
57
+ shape (`"ISO/TR"`, `"ISO/TS"`, and by the same rule `"IEC/TR"` etc.) was
58
+ broken.
59
+
60
+ Locked by `spec/pubid/gost/foreign_adoption_routing_spec.rb`, which includes
61
+ a deterministic reproduction of the race (not a reliance on catching the rare
62
+ full-suite failure): it temporarily `Registry.unregister(:bsi)`s the usual
63
+ race winner and asserts routing still resolves to `Pubid::Iso` correctly —
64
+ proving the fix is prefix routing, not a lucky alphabetical ordering.
@@ -166,7 +166,7 @@ module Pubid
166
166
  def prefix_owner(raw)
167
167
  index = ::Pubid.prefix_flavors
168
168
  match = index.keys
169
- .select { |p| raw.start_with?("#{p} ") || raw == p }
169
+ .select { |p| ::Pubid.prefix_match?(raw, p) }
170
170
  .max_by(&:length)
171
171
  return nil unless match
172
172
 
@@ -183,3 +183,5 @@ module Pubid
183
183
  end
184
184
  end
185
185
  end
186
+
187
+ Pubid::Gost::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -23,6 +23,11 @@ module Pubid
23
23
  attribute :number, :string
24
24
  attribute :year, :string
25
25
 
26
+ # A nil `copublisher` means GOST alone: `GOST R 27001` is not
27
+ # `GOST R ISO/IEC 27001`, which is an adoption of another body's
28
+ # document.
29
+ subset_strict :copublisher
30
+
26
31
  GOST_TYPE_MAP = {
27
32
  "pubid:gost:interstate-standard" => "Pubid::Gost::Identifiers::InterstateStandard",
28
33
  "pubid:gost:national-standard" => "Pubid::Gost::Identifiers::NationalStandard",