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
@@ -105,14 +105,18 @@ module Pubid
105
105
  # BPVC COMPLETE CODE BIND
106
106
  designator_str = "BPVC COMPLETE CODE BIND"
107
107
  elsif bpvc_data[:subdivision] && bpvc_data[:subdivision][:ssc_code]
108
- # BPVC.SSC.XI.II.V.IX pattern
109
- ssc_sections = bpvc_data[:subdivision][:ssc_sections]
110
- sections_str = if ssc_sections.is_a?(Array)
111
- ssc_sections.join(".")
112
- else
113
- ssc_sections.to_s
114
- end
115
- designator_str = "BPVC.SSC.#{sections_str}"
108
+ # BPVC.SSC.XI.II.V.IX pattern. The sections sit under `ssc_code`;
109
+ # reading them one level up rendered every SSC id as "BPVC.SSC.".
110
+ # A bare "BPVC.SSC." (the catalogue's series identity) parses
111
+ # with no inner capture, so `ssc_code` is the matched Slice.
112
+ ssc_code = bpvc_data[:subdivision][:ssc_code]
113
+ sections = ssc_code.is_a?(Hash) ? ssc_code[:ssc_sections] : nil
114
+ designator_str =
115
+ if sections.nil? || sections.to_s.empty?
116
+ "BPVC.SSC."
117
+ else
118
+ "BPVC.SSC.#{sections}"
119
+ end
116
120
  elsif bpvc_data[:subdivision] && bpvc_data[:subdivision][:case_code]
117
121
  # BPVC.CC.BPV or BPVC.CC.NC.XI - extract from subdivision hash
118
122
  cc = bpvc_data[:subdivision][:case_code].to_s
@@ -120,7 +124,8 @@ module Pubid
120
124
 
121
125
  designator_str = case_sub && !case_sub.empty? ? "BPVC.CC.#{cc}.#{case_sub}" : "BPVC.CC.#{cc}"
122
126
  elsif bpvc_data[:case_code]
123
- # Dash notation: BPVC-CC-BPV
127
+ # Dash notation: BPVC-CC-BPV. Keep the dashes: the ASME catalogue
128
+ # lists BPVC-CC-BPV and BPVC.CC.BPV as two separate documents.
124
129
  cc = bpvc_data[:case_code].to_s
125
130
  designator_str = "BPVC-CC-#{cc}"
126
131
  elsif bpvc_data[:subdivision]
@@ -160,3 +165,5 @@ module Pubid
160
165
  end
161
166
  end
162
167
  end
168
+
169
+ Pubid::Asme::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -10,6 +10,8 @@ module Pubid
10
10
  # Stays independent of Pubid::Components::Code because ASME uses
11
11
  # +designator+ (a flavor-specific letter prefix) plus +number+.
12
12
  class Code < Lutaml::Model::Serializable
13
+ include ::Pubid::SubsetMatch
14
+
13
15
  attribute :designator, :string
14
16
  attribute :number, :string
15
17
 
@@ -83,10 +83,15 @@ module Pubid
83
83
  # enumerated escape list. The output emptiness check matters: a value
84
84
  # made only of out-of-charset characters collapses to "", which is
85
85
  # truthy in Ruby and would reach the renderer as a blank segment.
86
+ #
87
+ # A literal "-" is written "--", because "." and "-" otherwise meet:
88
+ # ASME sells BPVC-CC-BPV (the 2019 code-case supplements) and
89
+ # BPVC.CC.BPV (the code-case book) as different documents. "_" is not
90
+ # an option — the MR format reserves it for supplement layers.
86
91
  def mr_sanitize(value)
87
92
  return nil if value.nil? || value.to_s.empty?
88
93
 
89
- sanitized = value.to_s.downcase
94
+ sanitized = value.to_s.downcase.gsub("-", "--")
90
95
  .gsub(/[^a-z0-9-]+/, "-").gsub(/\A-+|-+\z/, "")
91
96
  sanitized.empty? ? nil : sanitized
92
97
  end
@@ -45,11 +45,19 @@ module Pubid
45
45
  (
46
46
  dot >>
47
47
  (
48
- # SSC with complex subdivision: BPVC.SSC.XI.II.V.IX
49
- (str("SSC") >> (dot >> roman_numeral).repeat(1).as(:ssc_sections)).as(:ssc_code) |
50
- # CC = Case Code: BPVC.CC.BPV or BPVC.CC.NC.XI
48
+ # SSC with complex subdivision: BPVC.SSC.XI.II.V.IX,
49
+ # or the bare catalogue form BPVC.SSC.
50
+ (str("SSC") >>
51
+ (dot >>
52
+ (roman_numeral >> (dot >> roman_numeral).repeat)
53
+ .as(:ssc_sections)).maybe >>
54
+ dot.maybe).as(:ssc_code) |
55
+ # CC = Case Code: BPVC.CC.BPV or BPVC.CC.NC.XI; the
56
+ # catalogue prints the sub-code with its own leading dot
57
+ # (BPVC.CC.BPV..I)
51
58
  (str("CC") >> dot >> bpvc_letter_code.as(:case_code) >>
52
- (dot >> (roman_numeral | bpvc_letter_code)).maybe.as(:case_sub)) |
59
+ (dot >> dot.maybe >>
60
+ (roman_numeral | bpvc_letter_code).as(:case_sub)).maybe) |
53
61
  # Standard roman numeral subdivision: BPVC.I or BPVC.III.1.NB
54
62
  (roman_numeral.as(:section) >>
55
63
  (dot >> (digits | bpvc_letter_code).as(:subsection)).maybe >>
@@ -94,7 +102,7 @@ module Pubid
94
102
 
95
103
  # Joint publisher patterns
96
104
  rule(:iso_asme_publisher) do
97
- str("ISO/ASME").as(:joint_publisher) >> space
105
+ str("ISO/ASME").as(:joint_publisher) >> space.maybe
98
106
  end
99
107
 
100
108
  rule(:asme_ans_publisher) do
@@ -104,13 +112,13 @@ module Pubid
104
112
  rule(:csa_asme_publisher) do
105
113
  csa_publisher.as(:first_publisher) >> space >>
106
114
  match("[A-Z0-9.]").repeat(1).as(:first_code) >>
107
- slash >> asme_publisher.as(:second_publisher) >> space
115
+ space.maybe >> slash >> asme_publisher.as(:second_publisher) >> space
108
116
  end
109
117
 
110
118
  rule(:api_asme_publisher) do
111
119
  api_publisher.as(:first_publisher) >> space >>
112
120
  match("[0-9-]").repeat(1).as(:first_code) >>
113
- slash >> asme_publisher.as(:second_publisher) >> space
121
+ space.maybe >> slash >> asme_publisher.as(:second_publisher) >> space
114
122
  end
115
123
 
116
124
  # Standard ASME publisher
@@ -129,6 +137,20 @@ module Pubid
129
137
  ).as(:designator)
130
138
  end
131
139
 
140
+ # A trailing edition year ("-2021", "-20XX") and nothing after it.
141
+ # `number_part` must not read it as a dash-separated number: a
142
+ # designator with no number ("BPVC.I-2021", "BPE-2012") then stored the
143
+ # year as its number, and the year itself was lost.
144
+ #
145
+ # The grammar cannot tell a year from a final 4-digit number: a code
146
+ # whose whole number is "-1234" would parse as year 1234. No ASME
147
+ # corpus id has that shape; spec/pubid/asme/root_number_spec.rb pins
148
+ # the choice.
149
+ rule(:trailing_year) do
150
+ dash >> (str("20XX") | str("202X") | (str("20") >> digit >> str("X")) |
151
+ digit.repeat(4, 4)) >> match("[0-9A-Z.]").absent?
152
+ end
153
+
132
154
  # Number part - can start with dot (NM.1), be dotted (16.5), OR dash-separated (BTH-1)
133
155
  rule(:number_part) do
134
156
  (
@@ -136,7 +158,7 @@ module Pubid
136
158
  (dot >> match("[0-9A-Z]").repeat(1) >>
137
159
  (dot >> match("[0-9A-Z]").repeat(1)).repeat) |
138
160
  # Dash-separated first (for BTH-1, CA-1 patterns)
139
- (dash >> match("[0-9A-Z]").repeat(1) >>
161
+ (trailing_year.absent? >> dash >> match("[0-9A-Z]").repeat(1) >>
140
162
  (dot >> match("[0-9A-Z]").repeat(1)).repeat) |
141
163
  # Regular dotted numbers
142
164
  (match("[0-9A-Z]").repeat(1) >>
@@ -144,9 +166,11 @@ module Pubid
144
166
  ).as(:number)
145
167
  end
146
168
 
147
- # PTC special: space-separated number with optional suffix
169
+ # PTC special: space-separated number with optional suffix; the
170
+ # renderer glues the designator to the number (PTC 19.3 TW and
171
+ # PTC19.3 TW are the same document)
148
172
  rule(:ptc_number) do
149
- space >>
173
+ space.maybe >>
150
174
  (
151
175
  match("[0-9]").repeat(1) >>
152
176
  (dot >> match("[0-9]").repeat(1)).repeat
@@ -155,9 +179,10 @@ module Pubid
155
179
  (space >> letters.as(:ptc_suffix)).maybe
156
180
  end
157
181
 
158
- # TR special: space-separated number (like "ASME TR A17.1-8.4-2013")
182
+ # TR special: space-separated number (like "ASME TR A17.1-8.4-2013");
183
+ # the rendered form glues the designator (TRA17.1-8.4)
159
184
  rule(:tr_number) do
160
- space >>
185
+ space.maybe >>
161
186
  (
162
187
  match("[A-Z0-9]").repeat(1) >>
163
188
  (dot >> match("[0-9A-Z]").repeat(1)).repeat >>
@@ -250,10 +275,12 @@ module Pubid
250
275
  reaffirmation.maybe
251
276
  end
252
277
 
253
- # Joint published identifier - ISO/ASME
278
+ # Joint published identifier - ISO/ASME. The catalogue also lists
279
+ # the numberless series identity "ISO/ASME-2015" beside the
280
+ # numbered adoptions.
254
281
  rule(:iso_asme_identifier) do
255
282
  iso_asme_publisher >>
256
- number_part >>
283
+ number_part.maybe >>
257
284
  (dash >> (draft_year | year_4digit)).maybe >>
258
285
  language.maybe >>
259
286
  reaffirmation.maybe
@@ -0,0 +1,9 @@
1
+ # ASTM flavor notes
2
+
3
+ ASTM index key and MR slug.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/astm/` or `spec/pubid/astm/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ ## From the root note "AMCA / ASME / ASTM index key (`root.number`): three flavors, three different shapes"
8
+
9
+ **(3) ASTM — the mixin, on EVERY concrete class.** `Astm::Components::Code` is a five-field taxonomy (`letter`/`number`/`suffix`/`subseries`/`dual_m`) that the renderer reads field-by-field, so it keeps its structure: `Identifiers::CodeNumber` installs the five columns plus a derived `#code`. **It is included by `Standard` AND by `IsoDualPublished`, which inherits `Standard` — deliberately, not redundantly**: a class that is itself inherited from must still declare the columns so its subclass holds its own snapshot rather than relying on a parent table. `Adjunct` has no code at all, so its designation IS its number: the `designation` attribute was **removed** and the value stored in `number` directly, because keeping both made `to_hash` emit the same string twice and a `def number` shadowing the attribute is the construct CLAUDE.md forbids.
@@ -164,3 +164,5 @@ module Pubid
164
164
  end
165
165
  end
166
166
  end
167
+
168
+ Pubid::Astm::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -11,6 +11,8 @@ module Pubid
11
11
  # rich taxonomy: +letter+ (A-G for standards), +suffix+ (A/B/C for
12
12
  # data series), +subseries+ (S1/S4/S10), and +dual_m+ (metric flag).
13
13
  class Code < Lutaml::Model::Serializable
14
+ include ::Pubid::SubsetMatch
15
+
14
16
  attribute :letter, :string # A-G for standards
15
17
  attribute :number, :string # Main number
16
18
  attribute :suffix, :string # A, B, C for data series
@@ -110,7 +110,10 @@ module Pubid
110
110
  digits.as(:number) >>
111
111
  (
112
112
  str("HOL").as(:hol_suffix) |
113
- (data_series_suffix >> data_series_subseries_no_dash.maybe) |
113
+ # A letter suffix may carry its subseries with the dash
114
+ # spelled out too ("DS55S-S1"), not only glued ("DS55S1").
115
+ (data_series_suffix >>
116
+ (data_series_subseries_no_dash | data_series_subseries_with_dash).maybe) |
114
117
  data_series_subseries_with_dash
115
118
  ).maybe
116
119
  end
@@ -0,0 +1,11 @@
1
+ # BIPM flavor notes
2
+
3
+ BIPM update codes, index key, MR slug and loose consumer forms.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/bipm/` or `spec/pubid/bipm/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **BIPM `update_codes` + bare MRA form**: `Pubid::Bipm::Identifier.parse` (`lib/pubid/bipm/identifier.rb`) applies `Core::UpdateCodes.apply(identifier, :bipm)` **after** the ReDoS length guard, **before** `Parser.parse` — mirroring ccsds/plateau, and the single entry point (`lib/pubid/bipm.rb#self.parse` delegates here). `data/bipm/update_codes.yaml` normalizes the CIPM MRA:2005 interpretation doc's docnumber-style spellings (`CIPM/2005-06(REV)`, `CIPM 2005-06(REV)`) → the parseable `CIPM 2005-06`. That **bare `<GROUP> <number>` form (no type word)** is a first-class `Identifiers::CommitteeDocument` with **`type_code` nil**: the `committee_bare` parser rule (`group space number (year).maybe`) is placed **last** in `group_leading` so typed committee docs and meetings always win — it only fires on strings that fail every earlier rule and are fully consumed, so it never reroutes a valid form (it only newly-accepts previously-rejected `GROUP number` strings). The builder routes it through the existing `build_committee` (a bare node has no `type_word` → `TYPE_WORD_TO_CODE[""]` → nil `type_code`, never `""`), so `to_hash` drops the nil `type_code` and `root.number` is the non-empty document number (`"2005-06"`). The nil `type_code` is handled at every render site: the renderer omits the type segment (no double space), and the URN gen/parser use an **empty type segment** (`urn:bipm:cipm::2005-06`) symmetric with the documented empty-number-segment convention.
8
+
9
+ - **BIPM index key (`root.number`) + MR slug**: relaton-index narrows on `id.root.number.to_s` (`Relaton::Index::Type#get_id_number` → `bsearch_left`/`bsearch_right`), and the same `number` feeds `to_mr_string`/`to_slug`, which consumers use as an output **filename**. `Pubid::Bipm::Builder` used to set `number` only in `build_committee`/`build_meeting`/`build_guide`, so **6,213 of the 7,922 published `relaton-data-bipm` index-v2 rows (78%) shared the empty key `""`** and the binary search silently degenerated to a linear scan. The four number-less builders now derive one: **Metrologia → the volume** (`Metrologia 55 1A 06007` → `"55"`), **SI Brochure → `edition`** for the brochure and **`variant`** for the derived products (`"9e"`, `"Appendix 3"`, `"Concise"`, `"FAQ"`), **MEP → `mep_code || report_code`** (`"S1"`, `"KUPRTM"`, `"BIPM-2019/05"`). The Metrologia key is deliberately the **coarse, clustering** one (all articles of a volume share a bucket — 61 buckets, median 81), matching the IETF draft-slug and IANA registry-slug convention; **per-article distinctness comes from the MR string, not from `number`**. Two groups stay `number`-less **on purpose** (7 rows of 7,922, so 99.91% coverage): the ordinal-less committee declarations (`CGPM DECL (1889)`, upstream `1889-00.yaml`) and the journal-level `Metrologia` record — filling them would move `urn:bipm:cgpm:decl::1889`'s documented **empty number segment**; both are pinned by `spec/pubid/bipm/root_number_spec.rb` so the gap stays visible. **`number` is not stored twice where it would merely duplicate another key.** A Metrologia article's `volume` is a **derived reader** (`def volume; number&.to_i; end` on the `MetrologiaArticle` leaf), not an attribute — the IANA `registry` / IETF `series` pattern. That one case is essentially the whole duplication: 6,204 of the 6,206 published rows carrying a derived `number` are articles, so the change shrinks the serialized ids by **9.6%** and was verified to reproduce the published Integer `volume` for all 6,205 article rows. It is safe as a plain method only because `volume` is no longer a lutaml attribute **anywhere** in the hierarchy (a method shadowing a generated accessor corrupts attribute resolution). `:volume` stays listed in `NUMBER_SOURCE_ATTRIBUTES` — the base `#exclude` loop skips a non-attribute name, but `exclude(:volume)` must still clear the key it derives from. **`edition`/`variant` and `mep_code`/`report_code` DO stay**, and that is not an oversight: each pair shares one class, so the renderer needs to know *which* of the two a value is (`9e v3.01 (2019/2024, E)` vs `Concise`; `SI MEP S1` vs `Rapport BIPM-2019/05`). Deriving those would trade a duplicated key for a subtler discriminator rule across 5 corpus rows. The redundancy is **one-way, unlike the IEEE `CodeNumber` mixin** it superficially resembles: `Builder` is the **constructor of record**, and nothing rebuilds `number` afterwards — so `MetrologiaArticle.new(volume: 51, …)` has an empty key, and a legacy pre-`number` row deserializes with `number` nil (lutaml assigns attributes *after* `initialize`, so an `initialize` hook would not heal it either — and worse, `#exclude` rebuilds through `self.class.new`, so such a hook would silently re-fill a `number` the caller just excluded). What **is** handled is the exclusion direction: `NUMBER_SOURCE_ATTRIBUTES` (`volume edition variant mep_code report_code`) drives `#exclude` to clear `number` whenever the attribute it derives from is excluded — the "reset the WHOLE cluster" lesson recorded for CSA's year and IEEE's year/month/day. **Nothing about rendering changed**: no renderer or URN branch reads `number` for these families, verified by a corpus diff — `to_s` and `to_urn` are byte-identical before/after for all 7,920 parseable ids. **MR**: BIPM had **no** `mr_*` override at all and inherits none of the base hooks usefully — it keeps its publisher in the `PUBLISHER` constant (so `mr_publisher` was nil), its year in an `:integer` (not a `Components::Date`, so `mr_year` was nil), its edition in a `:string` (not a `Components::Edition`, so the base `mr_edition`'s `edition.number` **raised `NoMethodError`** on every SI Brochure) and its own `type_code` (not a `typed_stage`, so `mr_type` was nil). Result: 90 fixture ids collapsed onto **21** slugs, `""` for 41 of them. The base now overrides those four plus `mr_type`, and adds an `mr_slug(*parts)` helper; each **leaf** supplies `mr_type` + `mr_number_with_part`. `mr_slug` sanitizes **by charset** (`gsub(/[^a-z0-9]+/, "-")`), not by an enumerated escape list, so a field added later cannot leak an unsafe character — BIPM needs three today: a `/` (the MEP report code `BIPM-2019/05`), a space (the SI Brochure variant `Appendix 3`) and a **`.`** (the brochure version `v3.01`). The dot matters beyond filename safety: `Renderers::MrString` joins **segments** with `.`, so a dot inside one would break the documented segment structure (hence `bipm.si-brochure.9e-v3-01.e`, not `…9e-v3.01.e`). Caveat pinned in the code: `-` is both the intra-segment join and the substitute, which is unambiguous only because no field feeding `mr_slug` admits a `-` today (`number`'s own internal `-` in `10-1` is safe because the `group` before it cannot contain one). The **group is load-bearing in the number segment** for CommitteeDocument/Meeting/Guide (`CCTF REC 2` vs `CCEM REC 2`; the 17th CGPM vs the 17th CIPM meeting; `CCL-GD-MeP-1` vs `CCEM-GD-RSI-1` — the guide sequence restarts per committee, so both carry number `"1"`). Verified over the corpus: **7,920 distinct slugs for 7,920 ids, zero collisions, zero non-filename-safe slugs.** Three **deliberate collapses**, pinned by `spec/pubid/bipm/mr_string_spec.rb`: `form` (short vs long committee spelling) and the MEP/Guide `appendix`/`annex`/`part` full-content tail are omitted, because MR is a canonical *document* slug and the URN already collapses them; `language` **is** kept, because the E and F records are distinct files upstream. **Watch out**: the long French spelling is *not* merely another rendering — `Builder#build_committee`/`build_meeting` record it as `language: "F"`, so `Résolution 1 de la CGPM (1927)` names the **French record** and correctly differs from the language-neutral `CGPM RES 1 (1927)`; only a same-language pair collapses. **Note on the determinism landmine**: BIPM declares `attribute :number, :string` on the **shared** `Pubid::Bipm::Identifier` (the IHO/JIS/GOST pattern), not on the leaves as IEEE/IETF/IANA/CIE do — it predates that lesson and is unchanged here, so `spec/pubid/bipm/root_number_spec.rb` carries the structural tripwire (the base *and* all six leaves must resolve `number` to `Lutaml::Model::Type::String`) and **is only meaningful under the full `bundle exec rake`**. **relaton note**: `relaton-data-bipm` must regenerate `index-v2.yaml` to gain the key (and to drop the now-unstored `volume`; `Relaton::Bipm::Bibliography#id_hash` reads `pubid.volume`, which the derived reader keeps working unchanged). A pre-`number` row degrades **silently, not loudly** — lutaml ignores unknown keys and `Relaton::Index::FileIO#id_supported?` skips its round-trip check for concrete subclasses (every BIPM id is a leaf) — so no `require_number!`-style raise is added (rendering does not depend on `number`, and `CGPM DECL` is legitimately number-less). `Relaton::Bipm::Bibliography#get_bipm` also still calls the **block** form `index.search { |r| … }`, which passes `id = nil` and skips `candidates_by_number` entirely, so nothing narrows until it adopts the id form.
10
+
11
+ - **BIPM loose consumer forms (retiring `Relaton::Bipm::Id`)**: `Relaton::Bipm::Bibliography` could not use pubid as its **query** parser — it kept a bespoke regex grammar (`Relaton::Bipm::Id`) because `Pubid::Bipm.parse` rejected the spellings real references use. The grammar now accepts them, so relaton can match pubid-to-pubid like every other flavor. **All of these are NORMALIZING parses — the identifier renders back in BIPM's canonical spelling, never the input** — which is the whole point (a loose query must produce an identifier equal to the index row's), and which is exactly why they must **never** go into the byte-exact `spec/fixtures/bipm/identifiers/pass/` (the `CIPM/2005-06(REV)` precedent); their expectations live in `spec/pubid/bipm/loose_consumer_forms_spec.rb`. Six constructs: **(1) Meeting word order** — `meeting_en` gained a `<group> Meeting <n>` branch and `meeting_fr` gained a plain-`e` ordinal branch (`CIPM 111e Réunion (2022)`) and a `<group> Réunion <n>` branch, all normalizing to `CCTF 14th Meeting (1999)` / `CIPM 111<sup>e</sup> réunion (2022)`. **(2) `committee_group_fr`** — a French type name in the English (group-leading) word order (`CIPM Décision 101-1 (2012)` → `Décision 101-1 du CIPM (2012)`). Its slot in `group_leading` is **after `committee_long_en`**, which is load-bearing: `Action` and `Statement` are in *both* `TYPE_NAME_EN` and `TYPE_NAME_FR`, so the English rule must claim them first or `CIPM Action 5 (2010)` would flip to French. Like the existing French rules it sets `language: "F"` — information the user typed, which a consumer can always widen away but could never recover. **(3) `GROUP_ALIASES`** (`CCDS`→`CCTF`) and **(4) `LANGUAGE_ALIASES`** (`EN`→`E`, `FR`→`F`, longest-first in the `lang` rule so `EN` is never read as a bare `E`): both are resolved in the **builder**, so an alias never reaches an attribute and the index stores only the current name and the one-letter code. `PARSEABLE_GROUPS` (= `GROUPS + GROUP_ALIASES.keys`) is what the `group` rule alternates over; `GROUPS` itself is unchanged, so nothing else sees the alias. **(5) `si_brochure_section`** — the bare `SI Brochure` and `SI Brochure Part 1`. Neither names an edition, so **both are partial references with a nil `number`** that wildcard every edition (the date-less `CCTF REC 2` reading). `Part N` points at a section *inside* the brochure — there is no `si-brochure-part-1.yaml` upstream and relaton's `Id` deletes `:part` before comparing — so it rides in the shared `part` attribute and stays **out** of the index key, exactly as the MEP/guide `Part N.M` tail does. **A Parslet fact worth recording, because it contradicts what several comments in this repo imply**: Parslet threads `consume_all` down through an alternation, so an alternative matching only a **prefix** counts as a failure and the next one is tried — verified by reordering. `si_brochure_section` therefore cannot truncate `SI Brochure Concise` whatever its slot, and `committee_bare` could not shadow a typed form even if it came first. Alternation order decides **only** between two alternatives that both consume the *whole* input — which is exactly the `committee_long_en`-before-`committee_group_fr` case above, and the only ordering here that is genuinely load-bearing (verified: flipping it renders `CIPM Action 5 (2010)` as the French `Action 5 du CIPM (2010)`, a different record). The `BIPM ` prefix was also made **optional on the main `si_brochure` rule** (it already was on the variant rule): `si-brochure.yaml` stores its docnumber *without* it, and `Relaton::Bipm::Bibliography.search` strips a leading `BIPM ` before pubid ever sees the string — so `SI Brochure 9e v3.01 (2019/2024, E)` used to be a hard parse failure. **(6) `TYPE_WORD_ALIASES`** — a **data-corruption fix**: `Declaration`/`Déclaration` were in the `type_name_en`/`type_name_fr` alternations but **not** in `TYPE_WORD_TO_CODE` (which is built from `TYPE_NAME_EN`/`TYPE_NAME_FR`, where DECL's name is `Statement` in both languages), so `CGPM Declaration 1 (1971)` parsed with `type_code` **nil** and rendered as the corrupt `"CGPM 1 (1971)"` (double space), `Déclaration 1 du CGPM (1971)` as `" 1 de la CGPM (1971)"`. Both now map to DECL and render `Statement`. **The matching contract relaton needs** (pinned by the spec against real `index-v2` rows): committee-document and meeting rows are stored **language- and form-neutral** — verified, all 1348 + 367 — so the BIPM stem is **`pubid.exclude(:language, :form)`**, and every loose form reduces under it to the identifier rebuilt from its row. The SI Brochure is the exception: a nil-`edition` query must match *any* `SiBrochure` row, which is what `Id#id_hash`'s `{group: "SI", type: "Brochure"}` collapse already meant. **Also fixed here**: `UrnParser#build_metrologia`/`#build_si_brochure` never set the derived `number`, so a URN round-trip silently emptied the relaton index key (the shared `"flavor URN round-trip"` example only compares `to_urn`, so it was invisible) — now set from the same source the builder uses, guarded by a `presence` helper because an unfilled URN segment is `""`, not nil. **Verified**: all 7,922 published `relaton-data-bipm` rows re-parse from their own `to_s` with an identical hash, class and MR slug (0 mismatches, 0 MR collisions), and all 90 pass fixtures stay byte-exact. **Deliberately NOT changed:** `CCTF Recommendation 2009-02` still reads as the literal number `2009-02` while the index keys that document under `2` — genuinely ambiguous, since `NNNN-NN` is a real BIPM number form (`CIPM 2005-06`), so the consumer keeps a fallback. **Known pre-existing gap, deliberately untouched:** the SI Brochure URN carries only language/edition/version/years, so `Concise`, `FAQ` and `Appendix 3` — three distinct documents — already share `urn:bipm:si-brochure::::`, and the new bare/section forms join that bucket (defensibly, being partial references rather than distinct documents). Fixing it needs a URN-shape decision plus a matching `UrnParser` branch. `Pubid.parse` auto-routing does not resolve **any** BIPM form today (also pre-existing), so `PREFIXES` was left alone — adding `CCDS` there would change nothing observable. (hand-off: bipm-loose-consumer-forms; consumer follow-up: relaton__relaton__bipm-drop-id-parser-for-pubid.)
@@ -161,3 +161,5 @@ module Pubid
161
161
  end
162
162
  end
163
163
  end
164
+
165
+ Pubid::Bipm::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -0,0 +1,93 @@
1
+ # BSI flavor notes
2
+
3
+ BSI wrappers, adopted norms and cross-flavor sets.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/bsi/` or `spec/pubid/bsi/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **One EC representation**: BSI's Expert-commentary suffix is modelled *only* as an outer `Identifiers::ExpertCommentary` wrapper (never a boolean on the inner adopted norm), so `#base_document`/`#base` peel cleanly. The builder's adopted path (`build_adopted_identifier`) is the single place that wraps supplements + EC; `Builder#build` must **not** re-wrap the adopted branch (that caused a double-`ExpertCommentary` that broke `base`).
8
+
9
+ ## From the root note "`number`/`part`/`subpart` retyped to `:string` — tranche 1 of 3 (ansi, api, bsi, cen_cenelec, idf, jcgm)"
10
+
11
+ **Hand-off `bsi-set-cross-flavor-type` is closed as a side effect, and a pin on `main` is what proved it.** A `BS ISO 20400 + …` set holds *ISO* identifiers, and BSI declared `number`/`part`/`subpart` as its **own** `Bsi::Components::Code`, so `to_hash` raised `IncorrectModelError` — the cross-flavor attribute-type bug already fixed once on `Ieee::Identifiers::AdoptedStandard`. Retyping the three attributes to `:string` **removes the offending type outright**: there is no longer a BSI-specific component for a foreign identifier to mismatch, so no widening was needed. **74 BSI ids that previously raised on `to_hash` now serialize**, and the set round-trips through `from_hash` to the same class, hash and `to_s`. Merging `main` turned its `expect { id.to_hash }.to raise_error` pin red, which is how this surfaced; it now asserts the repair. Note the set's `#root` still reaches an `Iso::Components::Code` until tranche 3 — `root.number.to_s` is what relaton keys on, so the index contract holds either way.
12
+
13
+ ## From the root note "Wrapper index keys (`root.number`) — the number was already there, one level down"
14
+
15
+ **The second half of the `number` landmine is a plain method shadowing a real attribute reader**, and `Bsi::Identifiers::AdoptedEuropeanNorm` still has it (`#number`, `#date`, `#part`, `#subpart` all delegate to `adopted`, the attribute named `adopted_identifier` before the rename recorded in `lib/pubid/cen_cenelec/CLAUDE.md`) — pre-existing, deliberately not removed here because `bsi/urn_generator.rb` reads `identifier.number` generically and had no other source. It now has one: that generator falls back to `identifier.root.number`, which recurses where the one-level delegation did not. That alone repaired **80 identity-free BSI URNs** — every `AddendumDocument`, `SupplementDocument`, `BundledIdentifier`, `Set`, `CommitteeDocument`, `StandaloneAmendment` and `AdoptedEuropeanNorm` in the corpus emitted the bare `urn:bsi:bs` or `urn:bsi:dd`, i.e. one URN per publisher for hundreds of documents. **0 URNs got shorter**; a non-wrapper's `root` is `self`, so ordinary identifiers are untouched. Removing the shadowing delegations themselves is now unblocked but is left to a follow-up (hand-off `bsi-set-cross-flavor-type`), which also records that `AdoptedEuropeanNorm` fails the round-trip on *values* rather than keys.
16
+
17
+ ## From the root note "Parse-failure error contract — uniform across every flavor"
18
+
19
+ **The unanchored/anchored mismatch that silently returned nil for 49 identifiers.** `Builder#build_adopted_identifier` (`lib/pubid/bsi/builder.rb`) chooses among adoption shapes. Its first branch tested
20
+
21
+ ```ruby
22
+ if adopted_str_clean.match?(/EN\s+(ISO\/IEC|IEC|ISO)/) # UNANCHORED
23
+ iso_iec_str = adopted_str_clean.sub(/^EN\s+/, "") # ANCHORED
24
+ ```
25
+
26
+ `"CEN ISO/TS 12180-1:2007"` **matches** the test — the substring `"EN ISO"` sits inside `"CEN ISO"` — but the anchored `sub` finds no `^EN ` and changes nothing. The branch then asks whether the (unchanged) string starts with `ISO`/`IEC`, which `"CEN …"` does not, so `adopted_id` stayed `nil` and `build` returned `nil` for the whole identifier. Anchoring the test to `/\AEN\s+(ISO\/IEC|IEC|ISO)/` lets a `"CEN …"` string fall through to the `start_with?("EN", "CEN", "CLC", …)` branch, which hands it to `Pubid::CenCenelec.parse` — where it belongs, and which parses it correctly.
27
+
28
+ **Measured**: 49 `DD CEN ISO/…` / `PD CEN ISO/…` identifiers in BSI's own `pass/` fixtures returned `nil` on `main`. **48 now build**, with `to_s` byte-exact against the input, real URNs (`urn:bsi:dd:12180:-1:2007`), MR slugs and a non-empty `root.number` — so they now key correctly in relaton-index instead of keying `""`. The 49th, `PD CEN ISO/TS 19166:2025 - TC`, now **raises** `Parslet::ParseFailed`; that is the honest outcome, since it never parsed, and reclassification moved it from `pass/` to `fail/`.
29
+
30
+ **Two lessons worth more than the fix.** (1) An **unanchored test paired with an anchored repair** is a silent-nil generator: the test admits inputs the repair cannot handle, and every branch below then misses. Check that a guard and its remedy use the same anchoring. (2) The classifier had bucketed all 49 into **`spec/fixtures/bsi/identifiers/pass/nil_class.txt`** — a `pass/` file named after the *failure mode*, holding `!input!` lines with an empty rendered half. A fixture bucket named for a failure is a defect report that has been filed and ignored; the file no longer exists after reclassification.
31
+
32
+ Note the 48 recovered ids raise `IncorrectModelError` on `to_hash` — pre-existing and unrelated: **565 of BSI's 1497 ids already did on `main`**. They join that group rather than forming a new one.
33
+
34
+ ## The wrapper-slot survey: which wrappers do not name their parent `base`
35
+
36
+ Moved here from the root CLAUDE.md's parent-accessor bullet, which keeps a one-line pointer (the root file is size-bounded by `spec/pubid/flavor_notes_spec.rb`). It is recorded under BSI because BSI is the flavor the survey led to converting; the CEN, IEEE and IEC halves are in their own notes.
37
+
38
+ **The root CLAUDE.md's uniform-`base` rule overstated its coverage, and the survey that measured the gap is worth keeping.** The `base_identifier` → `base` sweep did not reach the **adoption** wrappers: a later branch renamed `adopted_identifier` → **`adopted`** in CEN/CENELEC and BSI, a *second* name rather than the uniform one. `base_identifier` is genuinely gone (0 occurrences in `lib/`), but three camps remain, and telling them apart is the useful part. **Camp A — `adopted*` IS the parent slot**: `CenCenelec::Identifiers::{AdoptedEuropeanNorm,EuropeanPrestandard}` and the legacy second root `CenCenelec::Identifiers::Base` (whose `adopted` is typed narrowly to CEN's own `Base`, not `::Pubid::Identifier`); `Evs::Identifier#adopted_identifier`; and `Ieee::Identifiers::AdoptedStandard`, where `ieee_identifier` is the parent and `adopted_identifiers` the collection. **Camp B — `adopted*` is a sibling cross-reference and `base` is present, so it is NOT a divergence**: `Gost::Identifiers::IdenticalAdoption` (`base` = the GOST document, `adopted` = the foreign one) and `Gost::Identifiers::Harmonized` (`adopted_identifiers`); both define `def number; base&.number; end`, reading `base`. **Camp C — a parent slot under some third name**: `Pubid::BundledIdentifier#base_document` and `Iso::BundledIdentifier#base_document` (the **attribute shadows the `#base_document` method** of the matching primitives, and neither defines `#root`), `Ieee::Identifiers::{DualIdentifier,IecIeeeCopublished}`, `Iec::Identifiers::TestReportForm#cispr_identifier` — none of those four has `#root` — plus `Csa::Identifiers::Bundled#bundled_with`, `Cie::Identifiers::Bundle#ids` and the `identifiers` collections, which do. `Itu::Identifier#common_text_twin` is a co-published twin, not a parent.
39
+
40
+ **What the divergence costs, measured rather than assumed.** `BS ISO 8601:2019` and `NA to BS EN 1991-1-1:2002` kept a correct index key while `root` returned the *wrapper*, because a hand-written `#number` delegation repaired the one symptom the index looks at; `EVS-EN 18216:2026` keyed the **empty string** (fixed separately by renaming EVS's slot to `base`). So a `#number` delegation on a wrapper is not a fix — it hides the missing `#root` from the only test anybody runs. **BSI is now converted** — see the sections below, including why the delegations were removed even after pubid#379 had fixed the `to_hash` raise they caused, and the `Identifiers::RootIdentity` mixin that replaced them. **`Components::Adoption` (`lib/pubid/components/adoption.rb`) already models an adoption with a `base` slot** and documents itself as the union of the BSI/CEN/CSA/IEEE shapes — it is autoloaded and used **nowhere**, so the unification was designed and never wired up. Finally, `Iec::Identifiers::ConsolidatedIdentifier#base_document` returns `identifiers&.first` with no recursive `&.base_document`, unlike its BSI, CSA and CEN counterparts — a one-level peel where the others peel fully.
41
+
42
+ ## Cross-flavor adoption `to_hash` (pubid#379)
43
+
44
+ **Cross-flavor adoption `from_hash` (pubid#379) — the empty-subclass disease, third occurrence.** `Pubid.from_hash(parse("BS EN 10077-1:2006").to_hash)` raised `IncorrectModelError: attribute 'date' value is a 'Pubid::Components::Date' but should be a 'Pubid::Bsi::Components::Date'` — on **to_hash**, before rehydration even began. The adoption wrappers (`AdoptedEuropeanNorm`, `AdoptedInternationalStandard`) delegate their `date` reader to the foreign identifier they wrap, whose date is the shared component; the outer attribute table demanded `Bsi::Components::Date`, an **empty subclass that adds nothing** (`class Date < Pubid::Components::Date; end`). The fix is the `bsi-set-cross-flavor-type` medicine verbatim — retype `date` on `Pubid::Bsi::Identifier` to `::Pubid::Components::Date` — which repaired every `BS EN …`/`BS ISO …`/`BS IEC …`/`BS EN ISO …` adoption AND the EVS national adoptions of the same family (the nested CEN dispatch itself was already correct: `apply_mappings` → `concrete_class_for` → `TypeResolver` routes the inner `_type` to CEN's class). The same mismatch on `type` broke the **BIP/PP/HB leaves** ("BIP 2225:2022" failed to_hash pre-existing on main) — their builders materialize a shared `Components::Type` — so `type` took the same retype, and the three now-redundant `attribute :date, Bsi::Components::Date` redeclarations on `BritishIndustrialPractice`/`PracticeGuide`/`Handbook` were deleted (the base now carries the declaration). `publisher`/`common_year` keep the BSI subclasses: nothing foreign ever flows into them, and the builder constructs the BSI types. **The pattern to remember for this flavor**: an empty `Bsi::Components::*` subclass on an attribute is a REJECTION RULE for foreign values, not a behavior — any attribute a wrapper delegates to a nested cross-flavor identifier must be typed with the shared component.
45
+
46
+ ## The adoption wrappers hold the adopted document in `base`
47
+
48
+ This closes part 2 of hand-off `bsi-set-cross-flavor-type` — the shadowing delegations that branch deliberately left in place.
49
+
50
+ **Three names became one.** `AdoptedEuropeanNorm#adopted`, `AdoptedInternationalStandard#adopted` and `NationalAnnex#base_doc` are now all **`base`**, the uniform parent accessor. Nothing inherits from those three classes, so the redeclaration is not the determinism landmine; `base` was free on all of them, and `Identifiers::Amendment` had already declared `attribute :base` over the nil-returning method on `::Pubid::Identifier`. The wire key follows the attribute name (BSI declares no `key_value` block), so a stored row's `adopted:` / `base_doc:` becomes `base:` — there is no `relaton-data-bsi`, and no relaton gem reads `.adopted`, so nothing published needs regenerating.
51
+
52
+ **Everything else spelled `adopted_*` in `parser.rb` and `builder.rb` is a parse-tree key** (`adopted_string`, `adopted_org`), a different namespace, and stays. Two reads also stay because they touch **CEN's** attribute, not BSI's: `builder.rb` constructing a `CenCenelec::Identifiers::AdoptedEuropeanNorm`, and the old `delegate_target` that unwrapped one CEN layer.
53
+
54
+ **The delegations were the actual defect, not the name — but not the `to_hash` raise.** All three classes defined `#number`, `#date`, `#part` and `#subpart` as plain methods reading one level down, each shadowing a real lutaml accessor. `#date` returned the *adopted flavor's* `Pubid::Components::Date`, and that is what made `to_hash` raise — but **pubid#379 (above) had already fixed the raise by widening the attribute type**, and kept the delegations. This branch removes the delegations for the reasons that remained after #379: they made `id.base` nil and `#root` need overrides, they answered with a value for a plain member and nil for a wrapped one, and they re-emitted the nested document's `number`, `part` and `year` at the top level of every wrapper row. The #379 type widening stays necessary regardless — the BIP/PP/HB leaves still materialize a shared `Components::Type`.
55
+
56
+ **Deleting them moved identity onto `#root`, and that had to be done at every surface.** `to_s` was safe from the start — `render_adopted_european_norm` and `render_adopted_international_standard` read only `base`, `edition`, `reaffirmation_year` and the translation flags, never `number` or `date`. Two other surfaces did read them:
57
+
58
+ - **`urn_generator.rb`** already had `identifier.number || identifier.root.number`. A private `identity(attr)` now applies the same fallback to `part`, `subpart`, `date` and `year`. `#root` is `self` for a non-wrapper, so an ordinary identifier reads its own value and nothing moves.
59
+ - **the MR slug**: the base `mr_number_with_part` and `mr_year` read the receiver's own attributes, which a wrapper leaves nil. `Identifiers::RootIdentity` (`lib/pubid/bsi/identifiers/root_identity.rb`) is a two-method mixin — `root.equal?(self) ? super : root.mr_number_with_part` — included by the nine wrapper classes. It is a **mixin in nine named classes, not a prepend into every chain**: CLAUDE.md's annotated-rendering bullet records that inserting a module into every identifier's ancestors caused order-dependent nondeterminism, and this does not touch an unrelated chain.
60
+
61
+ **The four classes that needed it beyond the three renamed ones were found by measuring, not by reading.** Deleting the delegations first made `ConsolidatedIdentifier` (75 ids) and `ExpertCommentary` (17) slug the bare `bs`, because their own one-level `#number` delegations had been landing on the adoption's delegation. The mixin restored **the MR slug** for them exactly — and only that; `#number` and `#date` themselves are dealt with below. That in turn exposed a **pre-existing** collapse of the same shape — `AddendumDocument` (22), `BundledIdentifier` (27), `SupplementDocument` (18) and `Set` (1) already slugged the bare `bs` on `main`, i.e. **68 documents sharing one output FILENAME**; the mixin repairs those too.
62
+
63
+ **A one-level delegation is worse than none, so `ConsolidatedIdentifier` and `ExpertCommentary` lost theirs as well.** Their `#number` / `#part` / `#parts` / `#date` / `#year` read `identifiers.first` / `base` — one level. So the *same accessor* answered with the member's value when that member was a plain standard and with nil when it was itself an adoption. A code review caught that the mixin left this half untouched. Re-delegating through `#root` was rejected: for `date` it hands lutaml a foreign `Pubid::Components::Date` and reinstates the very raise this branch removes, and for `number` it puts back the duplicate top-level key. So the rule is now uniform and stated: **a BSI wrapper owns no identity; `#root` carries it, for every layer.** Measured consequence on the wire: 33 consolidated and commentary rows lose a top-level `number` and 25 lose a `part` — **every one a duplicate of a value still present in the nested member**, and `root.number` is unchanged for all 1501 ids, so no identity and no index key is lost. `spec/pubid/bsi/adoption_base_spec.rb` locks the rule over all nine wrapper classes: none may own an identity accessor, and each must resolve a non-empty `root.number`.
64
+
65
+ **One `collection: true` defect fell out, and it is the documented one.** `NationalAnnex#na_supplements` was `collection: true` with no default: the builder always passes an array, so a **parsed** NA held `[]`, while the serialized hash omits an empty collection, so a **deserialized** one held `nil` — and the two were not `==`. That is the 3GPP `parts` lesson in CLAUDE.md verbatim, with the same one-line remedy (`initialize_empty: true`) and the same blast radius: `#matches?` is `exclude(*ignore) == other.exclude(*ignore)`, so every index lookup of a supplement-less NA returned nothing, silently. `from_hash(to_hash) == id` rose by a further 10 ids.
66
+
67
+ **Verified by replaying a baseline captured from `main` after pubid#379, over all 1501 pass-fixture ids.** `to_s`, the identifier class and `root.number` are **byte-identical for every one**. URNs: 56 changed, **0 shorter**, distinct 1458 → 1463. MR slugs: 116 changed, **0 shorter**, distinct 1342 → **1402**, and the bare-`bs` bucket is empty. `urn:bsi:dd:11079` — the identity-free URN every `DD` adoption shared — is now `urn:bsi:dd:11079:1999`. `to_hash` raises stay at 0 (#379's work) and `from_hash(to_hash).to_hash == to_hash` stays at 1499; `from_hash(to_hash) == id` goes **1451 → 1461**, the ten `NationalAnnex` ids the `na_supplements` fix repaired. **The wire shape changes for 491 rows, all in six wrapper classes**: `adopted` (543) and `base_doc` (18) become exactly 561 `base`; `amendment_number`/`amendment_year` become `number`/`year`; and the wrappers' duplicated top-level `number`/`part`/`year` go. **No information is lost, and that was checked rather than inferred from the key tallies** (which net the removals against the renames): rebuilding every identifier from its new hash reproduces `to_urn` and `root.number` for all 1501 and `to_s` for 1500 — the one miss, `BS 2000-0:Addendum 1:1983`, rebuilds as `Addendum No.` on `main` too (an empty `addendum_type` that `to_hash` drops and `from_hash` restores as its default).
68
+
69
+ **One MR gap stays, pinned rather than fixed.** `mr_edition` (`lib/pubid/identifier.rb`) reads `edition.number`, but BSI declares `edition` as a plain `:string`, so `to_mr_string` raises `NoMethodError` for the **40** pass-fixture ids that carry an edition (`BS HD IEC 60364-8-81 ED1`). Unchanged by this branch, and the same crash CLAUDE.md records for BIPM, ASTM and IEEE. `spec/pubid/bsi/adoption_base_spec.rb` asserts the raise, so a fix turns it red.
70
+
71
+ **`KNOWN_ROUND_TRIP_FAILURES["bsi"]` does not move: it stays at 2**, where pubid#379 left it, in `spec/pubid/number_string_retype_spec.rb`. Earlier in this branch, measured against the pre-#379 `main`, it read 597 → 191; the merge replaced both numbers, and the exact-count convention of that file is what made the drift visible instead of silent.
72
+
73
+ ## The attached amendment keys its ordinal as `number` and its year as `year`
74
+
75
+ `Identifiers::Amendment` held `amendment_number` / `amendment_year` while the `number` it inherits from `SingleIdentifier` stayed nil — the same private-name shape that `StandaloneAmendment` and `CommitteeDocument` were already converted out of. An amendment row now reads `number:` / `year:` like every other BSI row. `to_s`, `to_urn` and `root.number` are byte-identical for all 1501 pass fixtures.
76
+
77
+ **This does not make the ordinal a document number**, which was the objection to the change and is worth recording because it is nearly true. `number` holds `"1"` (or `"11"`, or `"AA"`), and the amended standard is reached through `base`, which `#root` walks — so `root.number` is `4592`, not `1`. The failure mode being avoided is real and this branch measured it elsewhere: `BS EN IEC 61535/AMD1/FRAG1` slugged `bs.1` on `main`, an ordinal read where a document number belongs. The guard is that **8 of the 114 attached Amendment objects in the corpus have a nil `base`** — the supplements of a national annex, built with `base: nil` — so for those, and only those, `root` is the amendment itself. They are never a top-level parse result (an attached amendment is always nested), so no index row is keyed from one; if that ever changes, those 8 are where to look.
78
+
79
+ **The year was originally moved into a declared `year` attribute, NOT the inherited `date` — since reverted, see "The `exclude` recursion boundary" below.** Both spellings produce the `year:` key — a `Bsi::Components::Date` flattens to `year` under the canonical scalar rule — but `#exclude` recurses into nested identifiers, so with the year in `date`, `exclude(:date)` on the consolidated identifier dropped the amendment's year too: `BS 7273-4:2015+A1:2021` excluded to `BS 7273-4+A1` instead of `BS 7273-4+A1:2021`. An amendment's year belongs to the supplement, not to the standard, and `spec/pubid/bsi/parse_gaps_spec.rb` had pinned exactly that. A declared `year` (the shape ashrae, bipm, gost, ieee, jis, nist and ogc use for their own edition year — unrelated to this fix, and unaffected by it) only ever solved half the problem: it protected against a bare `exclude(:date)`, not `exclude(:year)`, since the flat-scalar rule skips the `date` → `year` rename precisely when `year` is a real attribute. The general fix protects the *inherited* `date` directly, so `Amendment`/`Corrigendum` no longer need a bespoke `year` attribute at all.
80
+
81
+ **`supplement_number` / `supplement_year` were then deleted.** Once both supplement classes declared `number` and `year` under those names, the two methods were one-line aliases in every implementation — BSI and CEN alike. `supplement_type` stays: nothing else names the class, and relaton detects a supplement with `respond_to?(:supplement_type)`. This **is** a consumer break: relaton's BSI matching (`lib/relaton/bsi/bibliography.rb`) and CEN hit key (`lib/relaton/cen/hit_collection.rb`) called both aliases, so they must read `number` / `year` — hand-off `relaton__relaton__pubid-supplement-number-year`. **`Corrigendum` took the same conversion**, so `number` / `year` is now uniform across both supplement classes. It has **0 objects in the BSI pass-fixture corpus**, so the corpus replay proves nothing about it — the four forms that parse (`+C1:2016`, the year-less `+C1`, a corrigendum and an amendment together, and the `NA+C1:…` form) are exercised directly in `spec/pubid/bsi/adoption_base_spec.rb` instead, each asserting `to_s`, the URN and `from_hash(to_hash) == id`. One belief corrected while writing them: the unnumbered `+C:2016` form that CEN spells `AC` does **not** parse in BSI, so a nil ordinal is not a case to design for here; a nil `year` is.
82
+
83
+ **The year is a `:string`, and that settled a cross-flavor disagreement rather than only a cosmetic one.** The old `amendment_year` was an `:integer`, so an amendment row read `year: 2012` where a standard row reads `year: '2006'` — the same key name, two types, inside one consolidated identifier. Worse, relaton compares a supplement's year across flavors, and CEN's was the String `"2005"` while BSI's was the Integer `2012`. The base `#year` reader is `date&.year&.to_s`, and 6 of the 9 flavors with a real `year` attribute declare `:string`, so String is the contract. `Corrigendum` took the same `:string` `year`, so both supplement classes now agree with each other and with CEN. **This still holds after the `year` attribute was reverted to the inherited `date`**: the reader `date&.year&.to_s` still returns a String, so `amendment.year`/`corrigendum.year` and the `to_hash` `"year"` key are unchanged.
84
+
85
+ ## The `exclude` recursion boundary: `supplement_date_attributes`
86
+
87
+ `Pubid::Identifier#exclude` recurses into every nested identifier (`base`, `identifiers`, …) at unlimited depth, which is right for a wrapper (its own date is nil; the real date lives on the document it wraps) and wrong for a supplement, which owns its own date. The `year`-not-`date` attribute described above was a partial dodge of this — it protected against a bare `exclude(:date)` but not `exclude(:year)`, because the top-level nil-ing loop matches by attribute *name*.
88
+
89
+ The general fix (`metanorma__pubid__exclude-recursion-depth` hand-off) promotes CEN/CENELEC's pre-existing `#exclude` override — which ran `super` then "put back" the supplement's own date unless `:supplement_year` was explicitly passed — into `::Pubid::Identifier` itself, as a class hook: `self.supplement_date_attributes` (default `[]`, meaning "protect nothing, recurse exactly as before"). `Amendment`/`Corrigendum` now declare `supplement_date_attributes => %i[date]` and use the **inherited** `date` component — no bespoke `year` attribute needed, and no per-flavor `#exclude` override either. `CenCenelec::Identifier#exclude` was deleted for the same reason: the base class now does what it did.
90
+
91
+ Verified: `spec/pubid/supplement_date_attributes_spec.rb` exercises the mechanism directly; `spec/pubid/bsi/parse_gaps_spec.rb`, `match_primitives_spec.rb` and `adoption_base_spec.rb`, and `spec/pubid/cen_cenelec/relaton_blockers_spec.rb`'s 10 pinned `:supplement_year` cases, all pass unchanged. Because the new logic only fires when `supplement_date_attributes` is non-empty, every other flavor (including ISO/IEC, which deliberately keep a supplement's year in `date` with no protection — see `lib/pubid/cen_cenelec/CLAUDE.md`) is untouched.
92
+
93
+ **Open question, not resolved here: `#dated_version_of?`/`#draft_of?` on a bare BSI supplement.** Both build on `matches?(other, ignore: [:date, :year, …])`, which — like every other bare `:date`/`:year` exclusion — now protects a supplement's own date unless `:supplement_year` is also passed. Two standalone `BS 7273-4:2015+A1:2021`/`...+A1:2022` objects were `dated_version_of?` each other before this branch (BSI's old `year`-attribute dodge never protected against `exclude(:year)`) and are not after. **Whether that's the right answer is genuinely unclear and deliberately left open pending feedback** — one reading is that this just makes BSI consistent with CEN/CENELEC, which already behaved this way before this branch (its pre-existing per-flavor `#exclude` override had the same protection); the other is that these two generic relational predicates should be widened to accept a `:supplement_year`-aware `ignore` set so they can still recognize two differently-dated editions of the *same supplement* as related. Neither predicate was touched in this branch — this is recorded, not decided. Locked (as current behavior, not as a verdict) by `spec/pubid/supplement_date_attributes_spec.rb`.
@@ -887,15 +887,15 @@ module Pubid
887
887
  if supp[:type] == :amendment
888
888
  Identifiers::Amendment.new(
889
889
  base: nil, # NA supplements don't wrap base
890
- amendment_number: supp[:number],
891
- amendment_year: year_val&.to_i,
890
+ number: supp[:number]&.to_s,
891
+ year: year_val&.to_s,
892
892
  separator: supp[:separator] || "+",
893
893
  )
894
894
  else
895
895
  Identifiers::Corrigendum.new(
896
896
  base: nil,
897
- corrigendum_number: supp[:number],
898
- corrigendum_year: year_val&.to_i,
897
+ number: supp[:number]&.to_s,
898
+ year: year_val&.to_s,
899
899
  separator: supp[:separator] || "+",
900
900
  )
901
901
  end
@@ -903,7 +903,7 @@ module Pubid
903
903
 
904
904
  Identifiers::NationalAnnex.new(
905
905
  na_supplements: na_supps,
906
- base_doc: base_id,
906
+ base: base_id,
907
907
  )
908
908
  end
909
909
 
@@ -1383,7 +1383,7 @@ module Pubid
1383
1383
  identifier = if adopted_id.class.name.start_with?("Pubid::CenCenelec::")
1384
1384
  Identifiers::AdoptedEuropeanNorm.new(
1385
1385
  publisher: Components::Publisher.new(body: bsi_prefix),
1386
- adopted: adopted_id,
1386
+ base: adopted_id,
1387
1387
  edition: final_edition&.to_s,
1388
1388
  translation_lang: data[:translation_lang]&.to_s,
1389
1389
  translation_upper: data[:translation_upper]&.to_s,
@@ -1396,7 +1396,7 @@ module Pubid
1396
1396
  # Otherwise it's ISO/IEC, use AdoptedInternationalStandard
1397
1397
  Identifiers::AdoptedInternationalStandard.new(
1398
1398
  publisher: Components::Publisher.new(body: bsi_prefix),
1399
- adopted: adopted_id,
1399
+ base: adopted_id,
1400
1400
  edition: final_edition&.to_s,
1401
1401
  translation_lang: data[:translation_lang]&.to_s,
1402
1402
  translation_upper: data[:translation_upper]&.to_s,
@@ -1447,16 +1447,16 @@ expert_commentary: nil, expert_commentary_topic: nil)
1447
1447
  if supp[:type] == :amendment
1448
1448
  Identifiers::Amendment.new(
1449
1449
  base: base,
1450
- amendment_number: supp[:number],
1451
- amendment_year: year_val&.to_i,
1450
+ number: supp[:number]&.to_s,
1451
+ year: year_val&.to_s,
1452
1452
  separator: supp[:separator] || "+",
1453
1453
  amd_suffix_form: supp[:amd_suffix_form] ? true : false,
1454
1454
  )
1455
1455
  else
1456
1456
  Identifiers::Corrigendum.new(
1457
1457
  base: base,
1458
- corrigendum_number: supp[:number],
1459
- corrigendum_year: year_val&.to_i,
1458
+ number: supp[:number]&.to_s,
1459
+ year: year_val&.to_s,
1460
1460
  separator: supp[:separator] || "+",
1461
1461
  )
1462
1462
  end
@@ -1493,3 +1493,5 @@ expert_commentary: nil, expert_commentary_topic: nil)
1493
1493
  end
1494
1494
  end
1495
1495
  end
1496
+
1497
+ Pubid::Bsi::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -11,6 +11,8 @@ module Pubid
11
11
  # "BS 2000-0:Addendum 1:1983"
12
12
  # "BS 6034:1981:Addendum No. 1:1986"
13
13
  class AddendumDocument < SingleIdentifier
14
+ include RootIdentity
15
+
14
16
  attribute :base, ::Pubid::Identifier, polymorphic: true
15
17
  attribute :addendum_number, :string
16
18
  attribute :addendum_year, :integer
@@ -7,7 +7,12 @@ module Pubid
7
7
  # Example: "BS EN 10077-1:2006" where EN 10077-1:2006 is a CEN identifier object
8
8
  # Example: "BS EN ISO 8601:2019" where EN ISO 8601:2019 is a CEN AdoptedEuropeanNorm wrapping ISO
9
9
  class AdoptedEuropeanNorm < BritishStandard
10
- attribute :adopted, ::Pubid::Identifier, polymorphic: true # CEN object
10
+ include RootIdentity
11
+
12
+ # The adopted CEN document, under the uniform parent accessor. `#root`
13
+ # and `#base_document` are inherited and walk it, so this class needs
14
+ # neither an override nor the delegating readers it used to carry.
15
+ attribute :base, ::Pubid::Identifier, polymorphic: true
11
16
  attribute :edition, :string
12
17
  attribute :translation_lang, :string
13
18
  attribute :translation_upper, :string
@@ -25,59 +30,6 @@ module Pubid
25
30
  nil
26
31
  end
27
32
 
28
- # Walk to the adopted document for the relaton-index key.
29
- #
30
- # The `#number` delegation below is not enough on its own: a
31
- # "DD ENV ISO 11079:1999" adopts a CenCenelec EuropeanPrestandard,
32
- # which is ITSELF a wrapper around the ISO standard, so the delegation
33
- # returned that wrapper's own (nil) number and the chain died one level
34
- # short. `#root` recurses, so it reaches the ISO standard however many
35
- # adoption layers sit in between.
36
- def root
37
- adopted ? adopted.root : self
38
- end
39
-
40
- # Delegate common methods to adopted identifier
41
- def number
42
- delegate_target&.number
43
- end
44
-
45
- def year
46
- delegate_target&.year if delegate_target&.methods&.include?(:year)
47
- end
48
-
49
- def date
50
- delegate_target&.date if delegate_target&.methods&.include?(:date)
51
- end
52
-
53
- def parts
54
- delegate_target&.parts if delegate_target&.methods&.include?(:parts)
55
- end
56
-
57
- def part
58
- delegate_target&.part if delegate_target&.methods&.include?(:part)
59
- end
60
-
61
- def subpart
62
- adopted&.subpart if adopted&.methods&.include?(:subpart)
63
- end
64
-
65
- private
66
-
67
- # The identifier that holds the number, part and date. For
68
- # "BS EN ISO 8848:2021" the adopted document is a CEN adoption of an
69
- # ISO standard, and the CEN adoption keeps no number of its own, so
70
- # read through it to the ISO standard. The CEN class used to do this
71
- # with delegating readers, which broke its serialization. `subpart`
72
- # keeps reading the CEN object, as it did before.
73
- def delegate_target
74
- target = adopted
75
- if target.is_a?(::Pubid::CenCenelec::Identifiers::AdoptedEuropeanNorm)
76
- target.adopted
77
- else
78
- target
79
- end
80
- end
81
33
  end
82
34
  end
83
35
  end
@@ -7,8 +7,11 @@ module Pubid
7
7
  # Example: "BS ISO 8601:2019" where ISO 8601:2019 is an ISO identifier object
8
8
  # Example: "BS IEC 62600:2020" where IEC 62600:2020 is an IEC identifier object
9
9
  class AdoptedInternationalStandard < BritishStandard
10
- # The adopted ISO/IEC identifier object
11
- attribute :adopted, ::Pubid::Identifier, polymorphic: true
10
+ include RootIdentity
11
+
12
+ # The adopted ISO/IEC document, under the uniform parent accessor.
13
+ # `#root` and `#base_document` are inherited and walk it.
14
+ attribute :base, ::Pubid::Identifier, polymorphic: true
12
15
  attribute :edition, :string
13
16
  attribute :translation_lang, :string
14
17
  attribute :translation_upper, :string
@@ -26,26 +29,6 @@ module Pubid
26
29
  nil
27
30
  end
28
31
 
29
- # Delegate common methods to adopted identifier
30
- def number
31
- adopted&.number
32
- end
33
-
34
- def year
35
- adopted&.year if adopted&.methods&.include?(:year)
36
- end
37
-
38
- def date
39
- adopted&.date if adopted&.methods&.include?(:date)
40
- end
41
-
42
- def parts
43
- adopted&.parts if adopted&.methods&.include?(:parts)
44
- end
45
-
46
- def part
47
- adopted&.part if adopted&.methods&.include?(:part)
48
- end
49
32
  end
50
33
  end
51
34
  end