pubid 2.0.0.pre.alpha.12 → 2.0.0.pre.alpha.14

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 (285) 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/data/parg/tables/bipm_groups.yaml +14 -0
  6. data/data/parg/tables/bipm_type_codes.yaml +5 -0
  7. data/data/parg/tables/bipm_type_names_en.yaml +6 -0
  8. data/data/parg/tables/bipm_type_names_fr.yaml +6 -0
  9. data/data/parg/tables/directives_supplements_typed_stages.yaml +3 -0
  10. data/data/parg/tables/directives_typed_stages.yaml +5 -0
  11. data/data/parg/tables/idf_typed_stages.yaml +27 -0
  12. data/data/parg/tables/idf_typed_stages_supplements.yaml +2 -0
  13. data/data/parg/tables/iec_typed_stages.yaml +130 -0
  14. data/data/parg/tables/iso_publishers.yaml +4 -0
  15. data/data/parg/tables/organizations.yaml +12 -0
  16. data/data/parg/tables/tc_types.yaml +42 -0
  17. data/data/parg/tables/typed_stages.yaml +114 -0
  18. data/data/parg/tables/typed_stages_supplements.yaml +64 -0
  19. data/data/parg/tables/wg_types.yaml +21 -0
  20. data/lib/pubid/adobe/builder.rb +2 -0
  21. data/lib/pubid/adobe/identifier.rb +11 -1
  22. data/lib/pubid/all_parts.rb +201 -0
  23. data/lib/pubid/all_parts_identifier.rb +19 -0
  24. data/lib/pubid/amca/CLAUDE.md +47 -0
  25. data/lib/pubid/amca/builder.rb +3 -5
  26. data/lib/pubid/amca/identifiers/base.rb +11 -1
  27. data/lib/pubid/amca/identifiers/publication.rb +13 -0
  28. data/lib/pubid/amca/parser.rb +2 -1
  29. data/lib/pubid/amca/renderer.rb +22 -33
  30. data/lib/pubid/amca/urn_generator.rb +21 -2
  31. data/lib/pubid/amca/urn_parser.rb +36 -10
  32. data/lib/pubid/ansi/builder.rb +6 -0
  33. data/lib/pubid/ansi/identifier.rb +1 -1
  34. data/lib/pubid/api/CLAUDE.md +23 -0
  35. data/lib/pubid/api/builder.rb +2 -0
  36. data/lib/pubid/api/identifier.rb +1 -1
  37. data/lib/pubid/api/parser.rb +8 -4
  38. data/lib/pubid/ashrae/CLAUDE.md +13 -0
  39. data/lib/pubid/ashrae/builder.rb +58 -14
  40. data/lib/pubid/ashrae/identifiers/base.rb +10 -1
  41. data/lib/pubid/ashrae/identifiers/errata.rb +14 -2
  42. data/lib/pubid/ashrae/identifiers/interpretation.rb +2 -10
  43. data/lib/pubid/ashrae/parser.rb +80 -39
  44. data/lib/pubid/ashrae/renderer.rb +32 -1
  45. data/lib/pubid/ashrae/urn_generator.rb +32 -9
  46. data/lib/pubid/asme/CLAUDE.md +25 -0
  47. data/lib/pubid/asme/builder.rb +16 -9
  48. data/lib/pubid/asme/components/code.rb +2 -0
  49. data/lib/pubid/asme/identifier.rb +1 -1
  50. data/lib/pubid/asme/identifiers/standard.rb +6 -1
  51. data/lib/pubid/asme/parser.rb +41 -14
  52. data/lib/pubid/astm/CLAUDE.md +9 -0
  53. data/lib/pubid/astm/builder.rb +2 -0
  54. data/lib/pubid/astm/components/code.rb +2 -0
  55. data/lib/pubid/astm/identifier.rb +1 -1
  56. data/lib/pubid/astm/parser.rb +4 -1
  57. data/lib/pubid/bipm/CLAUDE.md +11 -0
  58. data/lib/pubid/bipm/builder.rb +2 -0
  59. data/lib/pubid/bipm/identifier.rb +1 -1
  60. data/lib/pubid/bsi/CLAUDE.md +93 -0
  61. data/lib/pubid/bsi/builder.rb +13 -11
  62. data/lib/pubid/bsi/identifiers/addendum_document.rb +2 -0
  63. data/lib/pubid/bsi/identifiers/adopted_european_norm.rb +6 -54
  64. data/lib/pubid/bsi/identifiers/adopted_international_standard.rb +5 -22
  65. data/lib/pubid/bsi/identifiers/amendment.rb +36 -12
  66. data/lib/pubid/bsi/identifiers/bundled_identifier.rb +2 -0
  67. data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +23 -26
  68. data/lib/pubid/bsi/identifiers/corrigendum.rb +29 -12
  69. data/lib/pubid/bsi/identifiers/expert_commentary.rb +6 -7
  70. data/lib/pubid/bsi/identifiers/national_annex.rb +18 -20
  71. data/lib/pubid/bsi/identifiers/root_identity.rb +31 -0
  72. data/lib/pubid/bsi/identifiers/set.rb +2 -0
  73. data/lib/pubid/bsi/identifiers/supplement_document.rb +2 -0
  74. data/lib/pubid/bsi/identifiers.rb +1 -0
  75. data/lib/pubid/bsi/parser.rb +8 -8
  76. data/lib/pubid/bsi/renderer.rb +20 -20
  77. data/lib/pubid/bsi/single_identifier.rb +1 -3
  78. data/lib/pubid/bsi/urn_generator.rb +28 -18
  79. data/lib/pubid/builder/base.rb +27 -0
  80. data/lib/pubid/calconnect/builder.rb +2 -0
  81. data/lib/pubid/calconnect/identifier.rb +5 -1
  82. data/lib/pubid/ccsds/builder.rb +2 -0
  83. data/lib/pubid/ccsds/identifier.rb +9 -1
  84. data/lib/pubid/cen_cenelec/CLAUDE.md +59 -0
  85. data/lib/pubid/cen_cenelec/builder.rb +6 -1
  86. data/lib/pubid/cen_cenelec/identifier.rb +12 -28
  87. data/lib/pubid/cen_cenelec/identifiers/amendment.rb +3 -10
  88. data/lib/pubid/cen_cenelec/identifiers/corrigendum.rb +3 -10
  89. data/lib/pubid/cen_cenelec/parser.rb +20 -5
  90. data/lib/pubid/cie/CLAUDE.md +58 -0
  91. data/lib/pubid/cie/builder.rb +2 -0
  92. data/lib/pubid/cie/components/language.rb +2 -0
  93. data/lib/pubid/cie/identifier.rb +1 -1
  94. data/lib/pubid/cie/parser.rb +9 -2
  95. data/lib/pubid/components/adoption.rb +2 -0
  96. data/lib/pubid/components/code.rb +2 -0
  97. data/lib/pubid/components/date.rb +8 -6
  98. data/lib/pubid/components/edition.rb +2 -0
  99. data/lib/pubid/components/iteration.rb +2 -0
  100. data/lib/pubid/components/language.rb +2 -0
  101. data/lib/pubid/components/locality.rb +2 -0
  102. data/lib/pubid/components/publisher.rb +2 -0
  103. data/lib/pubid/components/relationship.rb +2 -0
  104. data/lib/pubid/components/stage.rb +2 -0
  105. data/lib/pubid/components/supplement.rb +2 -0
  106. data/lib/pubid/components/type.rb +2 -0
  107. data/lib/pubid/components/typed_stage.rb +8 -0
  108. data/lib/pubid/conformance/checks.rb +1 -1
  109. data/lib/pubid/csa/CLAUDE.md +41 -0
  110. data/lib/pubid/csa/builder.rb +2 -0
  111. data/lib/pubid/csa/identifier.rb +19 -3
  112. data/lib/pubid/csa/parser.rb +25 -8
  113. data/lib/pubid/csa/renderer.rb +12 -12
  114. data/lib/pubid/csa/single_identifier.rb +17 -0
  115. data/lib/pubid/doi/builder.rb +2 -0
  116. data/lib/pubid/doi/identifier.rb +1 -1
  117. data/lib/pubid/easc/builder.rb +2 -0
  118. data/lib/pubid/easc/identifier.rb +10 -1
  119. data/lib/pubid/ecma/CLAUDE.md +28 -0
  120. data/lib/pubid/ecma/builder.rb +2 -0
  121. data/lib/pubid/ecma/identifier.rb +8 -1
  122. data/lib/pubid/etsi/CLAUDE.md +34 -0
  123. data/lib/pubid/etsi/builder.rb +2 -0
  124. data/lib/pubid/etsi/components/code.rb +6 -0
  125. data/lib/pubid/etsi/components/version.rb +2 -0
  126. data/lib/pubid/etsi/identifiers/base.rb +1 -1
  127. data/lib/pubid/etsi/identifiers/etsi_standard.rb +7 -0
  128. data/lib/pubid/evs/CLAUDE.md +58 -0
  129. data/lib/pubid/evs/builder.rb +2 -0
  130. data/lib/pubid/evs.rb +1 -1
  131. data/lib/pubid/gb/CLAUDE.md +140 -0
  132. data/lib/pubid/gb/builder.rb +7 -2
  133. data/lib/pubid/gb/identifier.rb +6 -4
  134. data/lib/pubid/gb/identifiers/all_parts.rb +17 -0
  135. data/lib/pubid/gb/identifiers.rb +1 -0
  136. data/lib/pubid/gb/renderer.rb +0 -1
  137. data/lib/pubid/gost/CLAUDE.md +64 -0
  138. data/lib/pubid/gost/builder.rb +3 -1
  139. data/lib/pubid/gost/identifier.rb +16 -1
  140. data/lib/pubid/gost/parser.rb +8 -1
  141. data/lib/pubid/iala/CLAUDE.md +82 -0
  142. data/lib/pubid/iala/builder.rb +2 -0
  143. data/lib/pubid/iala/identifier.rb +10 -1
  144. data/lib/pubid/iana/CLAUDE.md +7 -0
  145. data/lib/pubid/iana/builder.rb +2 -0
  146. data/lib/pubid/iana/identifier.rb +1 -1
  147. data/lib/pubid/identifier.rb +161 -17
  148. data/lib/pubid/idf/builder.rb +11 -1
  149. data/lib/pubid/idf/identifier.rb +5 -0
  150. data/lib/pubid/idf/identifiers/all_parts.rb +17 -0
  151. data/lib/pubid/idf/identifiers.rb +1 -0
  152. data/lib/pubid/iec/CLAUDE.md +31 -0
  153. data/lib/pubid/iec/builder.rb +7 -1
  154. data/lib/pubid/iec/components/consolidated_amendment.rb +4 -0
  155. data/lib/pubid/iec/components/sheet.rb +2 -0
  156. data/lib/pubid/iec/components/trf_info.rb +2 -0
  157. data/lib/pubid/iec/components/vap_suffix.rb +2 -0
  158. data/lib/pubid/iec/identifier.rb +8 -3
  159. data/lib/pubid/iec/identifiers/all_parts.rb +19 -0
  160. data/lib/pubid/iec/identifiers.rb +1 -0
  161. data/lib/pubid/iec/parser.rb +9 -4
  162. data/lib/pubid/iec/renderer.rb +0 -1
  163. data/lib/pubid/iec/urn_generator.rb +9 -1
  164. data/lib/pubid/iec/urn_parser.rb +3 -2
  165. data/lib/pubid/ieee/CLAUDE.md +97 -0
  166. data/lib/pubid/ieee/builder.rb +194 -27
  167. data/lib/pubid/ieee/components/code.rb +2 -0
  168. data/lib/pubid/ieee/components/draft.rb +35 -2
  169. data/lib/pubid/ieee/components/typed_stage.rb +2 -0
  170. data/lib/pubid/ieee/identifiers/base.rb +21 -1
  171. data/lib/pubid/ieee/identifiers/iec_ieee_copublished.rb +9 -0
  172. data/lib/pubid/ieee/identifiers/joint_development.rb +66 -23
  173. data/lib/pubid/ieee/identifiers/project_draft_identifier.rb +8 -1
  174. data/lib/pubid/ieee/parser.rb +156 -29
  175. data/lib/pubid/ieee/renderer.rb +42 -7
  176. data/lib/pubid/ieee/urn_generator.rb +31 -0
  177. data/lib/pubid/ietf/CLAUDE.md +7 -0
  178. data/lib/pubid/ietf/builder.rb +2 -0
  179. data/lib/pubid/ietf/identifiers/base.rb +1 -1
  180. data/lib/pubid/iho/builder.rb +2 -0
  181. data/lib/pubid/isbn/builder.rb +2 -0
  182. data/lib/pubid/isbn/identifier.rb +1 -1
  183. data/lib/pubid/iso/CLAUDE.md +47 -0
  184. data/lib/pubid/iso/builder.rb +19 -5
  185. data/lib/pubid/iso/components/publisher.rb +2 -0
  186. data/lib/pubid/iso/identifier.rb +10 -15
  187. data/lib/pubid/iso/identifiers/all_parts.rb +19 -0
  188. data/lib/pubid/iso/identifiers/directives_supplement.rb +4 -2
  189. data/lib/pubid/iso/identifiers.rb +1 -0
  190. data/lib/pubid/iso/normalizer.rb +4 -1
  191. data/lib/pubid/iso/rendering_style.rb +0 -1
  192. data/lib/pubid/itu/CLAUDE.md +115 -0
  193. data/lib/pubid/itu/builder.rb +26 -4
  194. data/lib/pubid/itu/components/code.rb +2 -0
  195. data/lib/pubid/itu/components/designation.rb +2 -0
  196. data/lib/pubid/itu/components/sector.rb +2 -0
  197. data/lib/pubid/itu/components/series.rb +2 -0
  198. data/lib/pubid/itu/identifiers/base.rb +11 -18
  199. data/lib/pubid/itu/identifiers/radio_regulations.rb +27 -0
  200. data/lib/pubid/itu/identifiers/special_publication.rb +48 -14
  201. data/lib/pubid/itu/identifiers/standard_serialization.rb +2 -0
  202. data/lib/pubid/itu/identifiers/supplement.rb +15 -0
  203. data/lib/pubid/itu/identifiers.rb +1 -0
  204. data/lib/pubid/itu/parser.rb +108 -22
  205. data/lib/pubid/itu/urn_generator.rb +9 -2
  206. data/lib/pubid/jcgm/CLAUDE.md +7 -0
  207. data/lib/pubid/jcgm/builder.rb +2 -0
  208. data/lib/pubid/jcgm/components/publisher.rb +2 -0
  209. data/lib/pubid/jcgm.rb +1 -1
  210. data/lib/pubid/jis/builder.rb +5 -1
  211. data/lib/pubid/jis/identifier.rb +6 -18
  212. data/lib/pubid/jis/identifiers/all_parts.rb +19 -0
  213. data/lib/pubid/jis/identifiers.rb +1 -0
  214. data/lib/pubid/jis/renderer.rb +0 -2
  215. data/lib/pubid/jis/urn_generator.rb +0 -1
  216. data/lib/pubid/nist/CLAUDE.md +56 -0
  217. data/lib/pubid/nist/builder.rb +3 -0
  218. data/lib/pubid/nist/components/edition.rb +2 -0
  219. data/lib/pubid/nist/components/issue_number.rb +2 -0
  220. data/lib/pubid/nist/components/part.rb +2 -0
  221. data/lib/pubid/nist/components/stage.rb +2 -0
  222. data/lib/pubid/nist/components/supplement.rb +2 -0
  223. data/lib/pubid/nist/components/translation.rb +2 -0
  224. data/lib/pubid/nist/components/update.rb +2 -0
  225. data/lib/pubid/nist/components/version.rb +2 -0
  226. data/lib/pubid/nist/components/volume.rb +2 -0
  227. data/lib/pubid/nist/identifiers/base.rb +39 -7
  228. data/lib/pubid/nist/parser.rb +24 -2
  229. data/lib/pubid/nist/preprocessor.rb +53 -2
  230. data/lib/pubid/nist/urn_parser.rb +10 -1
  231. data/lib/pubid/oasis/CLAUDE.md +19 -0
  232. data/lib/pubid/oasis/builder.rb +2 -0
  233. data/lib/pubid/oasis/identifier.rb +20 -1
  234. data/lib/pubid/ogc/CLAUDE.md +34 -0
  235. data/lib/pubid/ogc/builder.rb +2 -0
  236. data/lib/pubid/ogc/identifier.rb +12 -1
  237. data/lib/pubid/oiml/CLAUDE.md +189 -0
  238. data/lib/pubid/oiml/builder.rb +20 -0
  239. data/lib/pubid/oiml/components/code.rb +6 -0
  240. data/lib/pubid/oiml/identifier.rb +13 -0
  241. data/lib/pubid/oiml/identifiers/annex.rb +4 -0
  242. data/lib/pubid/oiml/identifiers/certification_system.rb +34 -0
  243. data/lib/pubid/oiml/identifiers/code_number.rb +8 -0
  244. data/lib/pubid/oiml/identifiers/dual_published.rb +174 -0
  245. data/lib/pubid/oiml/identifiers.rb +2 -0
  246. data/lib/pubid/oiml/parser.rb +35 -4
  247. data/lib/pubid/oiml/renderer.rb +23 -1
  248. data/lib/pubid/oiml/single_identifier.rb +4 -0
  249. data/lib/pubid/oiml/supplement_identifier.rb +7 -0
  250. data/lib/pubid/oiml/urn_generator.rb +28 -0
  251. data/lib/pubid/oiml.rb +6 -1
  252. data/lib/pubid/omg/CLAUDE.md +15 -0
  253. data/lib/pubid/omg/builder.rb +2 -0
  254. data/lib/pubid/omg/identifier.rb +1 -1
  255. data/lib/pubid/parg/artifact.rb +46 -0
  256. data/lib/pubid/parg/backend.rb +92 -0
  257. data/lib/pubid/parg.rb +8 -0
  258. data/lib/pubid/parser/grammar.rb +23 -0
  259. data/lib/pubid/pg.rb +8 -0
  260. data/lib/pubid/plateau/builder.rb +2 -0
  261. data/lib/pubid/plateau/identifiers/base.rb +4 -0
  262. data/lib/pubid/plateau/supplement_identifier.rb +14 -2
  263. data/lib/pubid/plateau/urn_generator.rb +7 -1
  264. data/lib/pubid/plateau.rb +1 -2
  265. data/lib/pubid/renderers/human_readable.rb +0 -1
  266. data/lib/pubid/sae/builder.rb +2 -0
  267. data/lib/pubid/sae/components/date.rb +2 -0
  268. data/lib/pubid/sae/components/type.rb +2 -0
  269. data/lib/pubid/sae/identifiers/base.rb +1 -1
  270. data/lib/pubid/subset_match.rb +197 -0
  271. data/lib/pubid/tgpp/CLAUDE.md +43 -0
  272. data/lib/pubid/tgpp/builder.rb +2 -0
  273. data/lib/pubid/tgpp/identifier.rb +15 -1
  274. data/lib/pubid/type_resolver.rb +14 -2
  275. data/lib/pubid/un/builder.rb +2 -0
  276. data/lib/pubid/un/identifier.rb +1 -1
  277. data/lib/pubid/version.rb +1 -1
  278. data/lib/pubid/w3c/CLAUDE.md +7 -0
  279. data/lib/pubid/w3c/builder.rb +2 -0
  280. data/lib/pubid/w3c/identifier.rb +1 -1
  281. data/lib/pubid/xsf/CLAUDE.md +11 -0
  282. data/lib/pubid/xsf/builder.rb +2 -0
  283. data/lib/pubid/xsf/identifier.rb +1 -1
  284. data/lib/pubid.rb +17 -3
  285. metadata +78 -2
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pubid"
4
+ module Pubid
5
+ # Every part of one document, e.g. "ISO 9000 (all parts)".
6
+ #
7
+ # The class that includes this module holds part identifiers of one
8
+ # document in `identifiers`, in natural order and without duplicates. A
9
+ # member with no part stands for the whole document; it is permitted only
10
+ # as the sole member.
11
+ #
12
+ # A flavor includes it in a subclass of its own `Identifier`, so the
13
+ # all-parts identifier of a flavor is an identifier of that flavor
14
+ # (`Pubid::Iso::Identifiers::AllParts < Pubid::Iso::Identifier`). The
15
+ # subclass names its own suffix or URN; everything else comes from here.
16
+ # A flavor with no such class uses {Pubid::AllPartsIdentifier}.
17
+ #
18
+ # `==` compares the members. `===` compares the document only: the first
19
+ # member without its part and its edition (see #identity).
20
+ module AllParts
21
+ SUFFIX = " (all parts)"
22
+
23
+ # The members hold the whole identity, so the hash carries nothing else.
24
+ # Without the key_value block the serializer reads every inherited
25
+ # attribute, and the derived #number below would be written too.
26
+ def self.included(base)
27
+ base.attribute :identifiers, ::Pubid::Identifier, polymorphic: true,
28
+ collection: true,
29
+ initialize_empty: true
30
+ declare_key_value(base)
31
+ base.extend(ClassMethods)
32
+ end
33
+
34
+ # lutaml MERGES a subclass block onto the flavor's own block, so the
35
+ # derived #number below would be written as a top-level key. A no-op
36
+ # converter keeps it out.
37
+ def self.declare_key_value(base)
38
+ base.key_value do
39
+ map "_type", to: :_type
40
+ map "identifiers", to: :identifiers
41
+ map "number", with: { to: :number_to_kv, from: :number_from_kv }
42
+ end
43
+ end
44
+
45
+ module ClassMethods
46
+ # lutaml's from_hash builds the object with no attributes and assigns
47
+ # `identifiers` after #initialize, so it normalizes here too.
48
+ def from_hash(data, options = {})
49
+ super.tap do |id|
50
+ id.send(:normalize_identifiers!) if id.is_a?(::Pubid::AllParts)
51
+ end
52
+ end
53
+ end
54
+
55
+ def initialize(attrs = {}, options = {})
56
+ super
57
+ normalize_identifiers!
58
+ end
59
+
60
+ # The members hold the number, so it is neither written nor read.
61
+ def number_to_kv(_model, _doc); end
62
+
63
+ def number_from_kv(_model, _value); end
64
+
65
+ # @param other [Object] a candidate identifier
66
+ # @return [Boolean] true when +other+ is any part of the same document,
67
+ # in any edition
68
+ def ===(other)
69
+ return false unless other.is_a?(::Pubid::Identifier) && identity
70
+
71
+ identity === document_of(other)
72
+ end
73
+
74
+ # Another all-parts identifier with +other+ added. The receiver does not
75
+ # change.
76
+ # @param other [Pubid::Identifier] a part of the same document, or
77
+ # another all-parts identifier of it
78
+ # @return [Pubid::Identifier] an all-parts identifier of the same class
79
+ # @raise [ArgumentError] for a different document, an identifier with no
80
+ # part, or a part that is already a member
81
+ def +(other)
82
+ members = other.is_a?(::Pubid::AllParts) ? other.identifiers : [other]
83
+ members.each { |member| validate_member!(member) }
84
+ self.class.new(identifiers: identifiers + members)
85
+ end
86
+
87
+ def to_s(**opts)
88
+ return "" unless identity
89
+
90
+ annotate_plain_render(
91
+ "#{identity.to_s(**opts.except(:annotated))}#{self.class::SUFFIX}",
92
+ **opts,
93
+ )
94
+ end
95
+
96
+ # The all-parts attributes are empty, so the flavor renderers cannot
97
+ # print it. Only the human form exists yet; `to_mr_string` and `to_slug`
98
+ # come through here and raise too.
99
+ def render(format: :human, **opts)
100
+ return to_s(**opts) if format == :human
101
+
102
+ raise NotImplementedError,
103
+ "an all-parts identifier has no #{format} form yet"
104
+ end
105
+
106
+ def to_urn
107
+ raise NotImplementedError, "an all-parts identifier has no URN yet"
108
+ end
109
+
110
+ def to_all_parts
111
+ self
112
+ end
113
+
114
+ def root
115
+ identifiers.first&.root
116
+ end
117
+
118
+ # The document number, e.g. "9000". The index keys on it.
119
+ def number
120
+ identity&.number
121
+ end
122
+
123
+ # True here and false for every other identifier, so a caller that reads
124
+ # the old flag still works.
125
+ # rubocop:disable Naming/PredicateMethod
126
+ def all_parts
127
+ true
128
+ end
129
+ # rubocop:enable Naming/PredicateMethod
130
+
131
+ def all_parts?
132
+ true
133
+ end
134
+
135
+ def base_document
136
+ identifiers.first&.base_document
137
+ end
138
+
139
+ protected
140
+
141
+ # The document: the first member without its part, its subpart and its
142
+ # edition. #document_of reads it from another all-parts identifier, so it
143
+ # is protected and not private.
144
+ # @return [Pubid::Identifier, nil]
145
+ def identity
146
+ first = identifiers.first
147
+ first && document_of(first)
148
+ end
149
+
150
+ private
151
+
152
+ # The document of +id+: +id+ without its part and its edition.
153
+ def document_of(id)
154
+ return id.identity if id.is_a?(::Pubid::AllParts)
155
+
156
+ id.without_parts(*id.class.all_parts_edition_keys)
157
+ end
158
+
159
+ def validate_member!(id)
160
+ unless id.is_a?(::Pubid::Identifier)
161
+ raise ArgumentError, "not an identifier: #{id.inspect}"
162
+ end
163
+
164
+ raise ArgumentError, "#{id} is not a part of #{self}" unless self === id
165
+ raise ArgumentError, "#{id} has no part" unless id.part?
166
+ return unless identifiers.any? { |member| same_member?(member, id) }
167
+
168
+ raise ArgumentError, "#{id} is already a member of #{self}"
169
+ end
170
+
171
+ def same_member?(one, other)
172
+ one.normalized_copy == other.normalized_copy
173
+ end
174
+
175
+ # Sort, remove duplicates, and drop the whole-document member when a
176
+ # part is present. `==` and `to_hash` depend on this order.
177
+ def normalize_identifiers!
178
+ members = unique_members
179
+ parts = members.select(&:part?)
180
+ self.identifiers = sorted(parts.empty? ? members : parts)
181
+ end
182
+
183
+ def unique_members
184
+ identifiers.each_with_object([]) do |member, acc|
185
+ acc << member unless acc.any? { |kept| same_member?(kept, member) }
186
+ end
187
+ end
188
+
189
+ # One member needs no sort, and #to_s raises on an abstract class.
190
+ def sorted(members)
191
+ return members if members.size < 2
192
+
193
+ members.sort_by { |member| natural_key(member.to_s) }
194
+ end
195
+
196
+ # "ISO 9000-10" sorts after "ISO 9000-2".
197
+ def natural_key(str)
198
+ str.split(/(\d+)/).map { |s| s.match?(/\A\d+\z/) ? [0, s.to_i] : [1, s] }
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pubid"
4
+ module Pubid
5
+ # The all-parts identifier of a flavor that has no class of its own.
6
+ # A flavor that prints "(all parts)" or has a series URN gives its own
7
+ # class instead: see {Pubid::AllParts} and `Identifier.all_parts_class`.
8
+ class AllPartsIdentifier < Identifier
9
+ include AllParts
10
+
11
+ # This class belongs to no flavor, so its name carries no flavor segment
12
+ # and {TypeResolver} resolves it directly.
13
+ def self.polymorphic_name
14
+ return super unless self == ::Pubid::AllPartsIdentifier
15
+
16
+ "pubid:all-parts"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,47 @@
1
+ # AMCA flavor notes
2
+
3
+ AMCA index key and MR slug.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/amca/` or `spec/pubid/amca/`. 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
+ **(1) AMCA — flat `:string` columns on the leaves, and a latent `==` bug fixed with them.** `code` is deleted and each of the three leaves declares `attribute :number, :string`. An earlier draft kept the inherited `Components::Code` instead (AMCA's own `Components::Code` resolved to `Pubid::Components::Code` — there is no `lib/pubid/amca/components/code.rb` — so nothing needed retyping, the CSA case); that works too, and the serialized bytes are identical either way, but the flat string matches the other flavors here and lets the custom `key_value` converters go entirely. **The converters were what hid a real bug.** `year` was declared `Components::Date`, yet the builder assigns a String and lutaml does not cast it — so the **parse** path held a String while **from_hash** produced a `Components::Date`. `to_hash` and `to_s` agreed, because `year_to_kv` normalised both shapes to a scalar, so the relaton index gate (`from_hash(to_hash) == to_hash`) passed — while **`parse(x) == from_hash(parse(x).to_hash)` was false for every AMCA id**, breaking `#matches?`, which is built on `==`. `year` is now `:string` too, both `with:` converters are gone (every mapped attribute is a plain scalar, and the canonical `to_hash` already drops empty and default values), and a spec asserts round-trip **equality**, not just hash equality — the stronger check that would have caught it. **Lesson: a `with:` converter that normalises two possible runtime shapes is a signal that the attribute type is wrong, not a fix.** The serialized key moved `code` → `number` (no consumer). **AMCA's `Publication` and `Interpretation` also lost hand-written keyword `initialize` methods** that assigned ivars directly and bypassed lutaml — costing them their `_type` (so `from_hash` could not route back), their `publisher` default, and the serialization of `revision`/`interpretation_code`, which were `attr_reader`s `to_hash` dropped. Both are now ordinary lutaml attributes; the builder already called `new(**attributes)` exactly as it does for `Standard`, which never had the problem. This is the AIEE/IRE/NESC migration applied again.
10
+
11
+ ## Revision, rendering and URN (hand-off `asme-bpvc-and-amca-residue`)
12
+
13
+ **The publication revision never reached the object.** The grammar captured only `01` of `Rev. 01-23`, and `Builder#build_publication` read it under `parsed[:revision]`, where the grammar did not put it. The grammar now captures the whole `01-23` as `:revision`, so `revision`, `to_hash`, `to_s` and the URN (`rev.01-23`) carry it.
14
+
15
+ **The renderer lost text.** It printed `AMCA Publication 211 -22` (a space before the year), `AMCA 99 – JW` (no `Interp`), and `(2010)` for `(R2010)`. Every parseable AMCA fixture id now renders as it was written, except the real normalizations (`ANSI/AMCA 220-21` → `ANSI/AMCA Standard 220-21`).
16
+
17
+ **Every AMCA URN held a Ruby Hash.** `urn_type` interpolated the `type` metadata Hash (`copub.amca:{key: :publication, ...}`); it now reads `type[:key]`, the source `mr_type` reads. The URN also carries the interpretation code (`interp.jw`), so the interpretations of one standard no longer share a URN. `UrnParser` reads every token back (type, copublisher, interpretation code, revision, reaffirmation), so all 50 parseable fixture ids come back `==` from their URN; before, it rebuilt a bare `AMCA <number>-<year>` Standard.
18
+
19
+ **Not fixed, pre-existing:** `AMCA 204 – 1` and `ANSI/AMCA Standard 210-25 / ASHRAE 51-25` do not parse. `ANSI/AMCA 210-16 /ASHRAE 51-16` parses but silently drops `/ASHRAE 51-16`, because `additional_copublisher` has no capture. The `suffix` rule of `standard_identifier` has no capture either, so `suffix` is never set on a `Standard`.
20
+
21
+ ## `all_parts_edition_keys`: Publication's `revision`, then the shared `reaffirmed`
22
+
23
+ `Identifier.all_parts_edition_keys` defaults to `%i[date year edition
24
+ version]`. A first pass fixed `Identifiers::Publication#revision` (the
25
+ `Rev. 01-23` token), a separate discriminator that list never covered, so
26
+ `"AMCA Publication 211-22 (Rev. 01-23)".to_all_parts` did not collapse
27
+ onto another revision of the same publication under `#to_all_parts`/`#===`.
28
+ Fixed with `Identifiers::Publication.all_parts_edition_keys` (`super +
29
+ %i[revision]`), declared on `Publication` itself rather than the shared
30
+ `Pubid::Amca::Identifier` base — `Standard`/`Interpretation` don't carry
31
+ `revision`.
32
+
33
+ Code review of that same change found a second, wider gap it had missed:
34
+ `reaffirmed` (the `"(R2010)"` year) is declared on the shared
35
+ `Pubid::Amca::Identifier` base (`identifiers/base.rb`), not on
36
+ `Publication`, and `Renderer#render_base` reads it for **every** leaf —
37
+ `Standard` as well as `Publication` — so `"AMCA 210-16 (R2010)"` failed to
38
+ collapse onto `"AMCA 210-16 (R2015)"` even though neither is a
39
+ `Publication`. Fixed on the shared base itself:
40
+ `Pubid::Amca::Identifier.all_parts_edition_keys` is `super +
41
+ %i[reaffirmed]`; `Publication`'s own override chains through `super` and
42
+ picks it up automatically. **Lesson**: a discriminator declared on a
43
+ shared base needs the override on that base, not repeated per leaf — and,
44
+ per the IEEE entry above, finding one missed attribute is not proof the
45
+ audit is complete. Not part of the original hand-off's audit list (which
46
+ didn't cover AMCA); found during code review of the OGC/3GPP/NIST fix.
47
+ Locked by `spec/pubid/all_parts_edition_keys_audit_spec.rb`.
@@ -154,11 +154,7 @@ module Pubid
154
154
 
155
155
  # Extract revision (Rev. 01-23)
156
156
  if parsed[:revision]
157
- revision_data = parsed[:revision]
158
- if revision_data && revision_data[:revision_year]
159
- attributes[:revision] =
160
- extract_value(revision_data[:revision_year])
161
- end
157
+ attributes[:revision] = extract_value(parsed[:revision])
162
158
  end
163
159
 
164
160
  Identifiers::Publication.new(**attributes)
@@ -174,3 +170,5 @@ module Pubid
174
170
  end
175
171
  end
176
172
  end
173
+
174
+ Pubid::Amca::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -17,7 +17,7 @@ module Pubid
17
17
  raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
18
18
  end
19
19
 
20
- parsed = Parser.parse(identifier)
20
+ parsed = Pubid::Parg::Backend.parse(:amca, identifier)
21
21
  Builder.build(parsed)
22
22
  end
23
23
 
@@ -87,6 +87,16 @@ module Pubid
87
87
  def mr_year
88
88
  year&.to_s
89
89
  end
90
+
91
+ # `reaffirmed` (the "(R2010)" year, read by Renderer#render_base — used
92
+ # for Standard as well as Publication) is a discriminator the default
93
+ # %i[date year edition version] list misses, shared by every leaf that
94
+ # inherits this class. Declared here, not per-leaf, so Standard's own
95
+ # reaffirmations collapse too; Publication's revision (see
96
+ # publication.rb) is layered on top via `super`.
97
+ def self.all_parts_edition_keys
98
+ super + %i[reaffirmed]
99
+ end
90
100
  end
91
101
  end
92
102
  end
@@ -28,6 +28,19 @@ module Pubid
28
28
  def self.type
29
29
  { key: :publication, title: "Publication", short: nil }
30
30
  end
31
+
32
+ # `revision` is Publication's edition/version discriminator and is
33
+ # not in the default %i[date year edition version] list, so
34
+ # #to_all_parts/#=== failed to collapse two revisions of the same
35
+ # publication (e.g. "AMCA Publication 211-22 (Rev. 01-23)" vs
36
+ # "... (Rev. 02-23)"). Declared here, not on the shared
37
+ # Pubid::Amca::Identifier base, because `revision` is
38
+ # Publication-only — Standard and Interpretation don't carry it.
39
+ # `super` also picks up `reaffirmed`, added on the shared base
40
+ # (identifiers/base.rb) since Standard carries that one too.
41
+ def self.all_parts_edition_keys
42
+ super + %i[revision]
43
+ end
31
44
  end
32
45
  end
33
46
  end
@@ -62,7 +62,8 @@ module Pubid
62
62
 
63
63
  # Publication revision (Rev. 01-23)
64
64
  rule(:revision) do
65
- lparen >> str("Rev") >> dot.maybe >> space >> digits.as(:revision_year) >> dash >> digits >> rparen
65
+ lparen >> str("Rev") >> dot.maybe >> space >>
66
+ (digits >> dash >> digits).as(:revision) >> rparen
66
67
  end
67
68
 
68
69
  # Interpretation code (JW, KB, RG, AW, AH, or just a number)
@@ -29,53 +29,42 @@ module Pubid
29
29
  private
30
30
 
31
31
  def render_base(id)
32
- parts = []
33
- parts << id.copublisher if id.copublisher
34
32
  t = id.class.respond_to?(:type) ? id.class.type : nil
35
- if t.is_a?(Hash) && t[:title]
36
- parts << t[:title].to_s
37
- end
38
- parts << id.number.to_s
39
- parts << "-#{id.year}" if id.year
40
-
41
- result = parts.compact.join(" ")
42
-
43
- if id.copublisher&.include?("/") && id.year
44
- type_title = t.is_a?(Hash) ? t[:title].to_s : ""
45
- result = "#{id.copublisher} #{type_title} #{id.number}-#{id.year}"
46
- end
47
-
48
- result += " (#{id.reaffirmed})" if id.reaffirmed
49
-
33
+ title = t[:title].to_s if t.is_a?(Hash) && t[:title]
34
+ result = document(id, title)
35
+ result += " (R#{id.reaffirmed})" if id.reaffirmed
50
36
  result
51
37
  end
52
38
 
39
+ # The revision and the reaffirmation are separate optional groups in
40
+ # the grammar, so either or both can appear.
53
41
  def render_publication(id)
54
- parts = []
55
- parts << id.copublisher if id.copublisher
56
- parts << "Publication"
57
- parts << id.number.to_s
58
- parts << "-#{id.year}" if id.year
59
- parts << " (Rev. #{id.revision})" if id.revision
60
- parts << " (#{id.reaffirmed})" if id.reaffirmed && !id.revision
61
-
62
- parts.join(" ").squeeze(" ")
42
+ result = document(id, "Publication")
43
+ result += " (Rev. #{id.revision})" if id.revision
44
+ result += " (R#{id.reaffirmed})" if id.reaffirmed
45
+ result
63
46
  end
64
47
 
65
48
  def render_interpretation(id)
66
- parts = []
67
- parts << id.copublisher if id.copublisher
68
- parts << id.number.to_s
49
+ result = [id.copublisher, id.number.to_s].compact.join(" ")
69
50
 
70
51
  if id.interpretation_code
71
- parts << "– #{id.interpretation_code}"
52
+ result += " #{id.interpretation_code} Interp"
72
53
  elsif id.year
73
- parts << "-#{id.year}"
54
+ result += " – #{id.year}"
55
+ else
56
+ result += " Interp"
74
57
  end
75
58
 
76
- parts << " #{id.suffix}" if id.suffix
59
+ result += " #{id.suffix}" if id.suffix
60
+ result
61
+ end
77
62
 
78
- parts.join(" ").squeeze(" ")
63
+ # "AMCA Standard 803-02": the year joins the number with a bare dash.
64
+ def document(id, title)
65
+ result = [id.copublisher, title, id.number.to_s].compact.join(" ")
66
+ result += "-#{id.year}" if id.year
67
+ result
79
68
  end
80
69
  end
81
70
  end
@@ -27,9 +27,26 @@ module Pubid
27
27
  "copub.#{identifier.copublisher.to_s.downcase}" if identifier.copublisher
28
28
  end
29
29
 
30
+ # Identity-bearing: "AMCA 99 JW Interp" and "AMCA 99 KB Interp" are
31
+ # different documents.
32
+ def urn_interpretation
33
+ return nil unless identifier.respond_to?(:interpretation_code)
34
+
35
+ code = identifier.interpretation_code
36
+ "interp.#{code.downcase}" if code
37
+ end
38
+
39
+ def urn_revision
40
+ revision = identifier.revision if identifier.respond_to?(:revision)
41
+ "rev.#{revision}" if revision
42
+ end
43
+
44
+ # `type` is a metadata Hash; interpolating it put a Ruby Hash literal
45
+ # into every AMCA URN. The key is the source Identifier#mr_type reads.
30
46
  def urn_type
31
- (identifier.class.respond_to?(:type) ? identifier.class.type : nil)
32
- &.to_s&.downcase
47
+ return nil unless identifier.class.respond_to?(:type)
48
+
49
+ identifier.class.type[:key]&.to_s
33
50
  end
34
51
 
35
52
  def generate
@@ -37,6 +54,8 @@ module Pubid
37
54
  parts << urn_number if urn_number
38
55
  parts << urn_year if urn_year
39
56
  parts << urn_suffix if urn_suffix
57
+ parts << urn_interpretation if urn_interpretation
58
+ parts << urn_revision if urn_revision
40
59
  parts << urn_reaffirmed if urn_reaffirmed
41
60
  parts << urn_copublisher if urn_copublisher
42
61
 
@@ -5,23 +5,49 @@ module Pubid
5
5
  # Parses AMCA URNs back into identifiers.
6
6
  #
7
7
  # UrnGenerator emits:
8
- # urn:amca:{number}:{year}[:{copub.publisher}[:{other tokens}]]
8
+ # urn:amca:{number}[:{year}][:{keyed tokens}][:{type}]
9
9
  #
10
- # The current generator can leak the Type Hash literal into the URN
11
- # (e.g., `copub.amca:{:key => :standard, ...}`). This parser filters
12
- # any token containing `{` so round-trip works for the common case.
10
+ # The keyed tokens are `interp.<code>`, `rev.<revision>`,
11
+ # `reaff.<year>` and `copub.<publisher>`; the type is the key of the
12
+ # identifier class. The parser rebuilds the printed form from them and
13
+ # parses that, so the identifier comes back as the same class.
13
14
  #
14
15
  # Examples:
15
- # - urn:amca:210:08 → AMCA 210-08
16
+ # - urn:amca:210:08 → AMCA 210-08
17
+ # - urn:amca:211:22:rev.01-23:copub.amca:publication
18
+ # → AMCA Publication 211-22 (Rev. 01-23)
19
+ # - urn:amca:99:interp.jw:copub.amca:interpretation → AMCA 99 JW Interp
16
20
  class UrnParser < Pubid::UrnParser::Base
21
+ TYPE_TITLES = {
22
+ "standard" => "Standard",
23
+ "publication" => "Publication",
24
+ }.freeze
25
+
17
26
  def parse_urn(urn)
18
- body = strip_namespace(urn)
19
- parts = split_parts(body).reject { |p| p.include?("{") }
27
+ number, *rest = split_parts(strip_namespace(urn))
28
+ year = rest.shift if rest.first&.match?(/\A\d+\z/)
29
+ keyed = rest.grep(/\./).to_h { |token| token.split(".", 2) }
30
+ type = (rest - keyed.map { |k, v| "#{k}.#{v}" }).last
31
+
32
+ flavor_parse(printed(number, year, keyed, type))
33
+ end
34
+
35
+ private
20
36
 
21
- number, year = parts
22
- text = "AMCA #{number}"
37
+ def printed(number, year, keyed, type)
38
+ publisher = keyed.fetch("copub", "amca").upcase
39
+ return interpretation(publisher, number, keyed) if type == "interpretation"
40
+
41
+ text = [publisher, TYPE_TITLES[type], number].compact.join(" ")
23
42
  text += "-#{year}" if year
24
- flavor_parse(text)
43
+ text += " (Rev. #{keyed['rev']})" if keyed["rev"]
44
+ text += " (R#{keyed['reaff']})" if keyed["reaff"]
45
+ text
46
+ end
47
+
48
+ def interpretation(publisher, number, keyed)
49
+ code = keyed["interp"]&.upcase
50
+ [publisher, number, code, "Interp"].compact.join(" ")
25
51
  end
26
52
  end
27
53
  end
@@ -45,6 +45,10 @@ module Pubid
45
45
  value.to_s.split(",").map do |lang|
46
46
  Components::Language.new(code: lang.strip)
47
47
  end
48
+ when :all_parts
49
+ # The shared Grammar marks the tree; the prepended AllPartsWrap
50
+ # routes the wrap, so the marker itself carries no component.
51
+ nil
48
52
  else
49
53
  raise ArgumentError, "Unknown parameter type: #{type}"
50
54
  end
@@ -52,3 +56,5 @@ module Pubid
52
56
  end
53
57
  end
54
58
  end
59
+
60
+ Pubid::Ansi::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -23,7 +23,7 @@ module Pubid
23
23
  raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
24
24
  end
25
25
 
26
- parsed = Pubid::Ansi::Parser.new.parse(string)
26
+ parsed = Pubid::Parg::Backend.parse(:ansi, string)
27
27
  Pubid::Ansi::Builder.new.build(parsed)
28
28
  end
29
29
  end
@@ -0,0 +1,23 @@
1
+ # API flavor notes
2
+
3
+ API rendering repairs and the `number` retype residue.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/api/` or `spec/pubid/api/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ ## From the root note "`number`/`part`/`subpart` retyped to `:string` — tranche 1 of 3 (ansi, api, bsi, cen_cenelec, idf, jcgm)"
8
+
9
+ **The six flavors have no fixture net of their own**: every one of their `fixtures_spec.rb` files reports **0 examples** (the `../../../fixtures` glob has one `..` too many; api and idf also use an uppercase directory — hand-off `ten-dead-fixture-specs`), and ANSI's sole assertion is `expect(fixtures.size).to be > 0`. Fixing those globs would drag in unrelated stale-fixture failures, so the new spec's corpus sweep reads the same files directly — the ashrae/csa/etsi/oiml precedent. **One pre-existing defect repaired in passing**: `API COS 1-07/RP 75, 4th edition` put a raw `Parslet::Slice` into `root.number` (the hand-off warned it would look like a migration failure); `Api::Builder#cast` now returns `value.to_s`, so it is a real String. **One severe API defect repaired here — the ONLY deliberate `to_s` change in this branch.** `Pubid::Api::Identifier.parse("API RP 500").to_s` was `"API"`: **163 of API's 193 corpus ids rendered as the bare publisher**, and since `to_s` is the document number *and* the output filename, all 163 collapsed onto one another. Two independent **dead reads**, both found by pulling on the `code` thread this retype exposed: (1) `Api::Renderer#code_portion` read an `id.code` attribute that **nothing ever assigns** — the parser emits no `:code` key and `Builder#cast` never sets one — while the number the builder does populate sits in `number`; (2) the type token was gated on `id.class.attributes.key?(:type_string)`, but `type_string` is a plain **method** on each leaf, never a lutaml attribute, so that guard was always false. The fix is two reads (`number`, and `respond_to?(:type_string)`), plus deleting the `code` attribute and the three dead `code_portion` methods (`SingleIdentifier`'s and `Mpms`'s were never called at all — the renderer has its own). **`render_mpms` never consulted `code_portion`, which is exactly why MPMS was the one form that always rendered correctly.** Byte-exact `to_s` goes from **29/193 to 190/193**, and the 3 residuals are precisely the normalizing parses the generated fixtures already mark `!input!rendered`. **The strongest evidence that this restores intent rather than inventing output: regenerating the fixtures (`rake "validation:classify[api]"`) changed exactly ONE line** — a stale `!… !API` corrected to `!… !API 75-07`. The corpus had recorded the correct renderings all along; the code had drifted away from them silently, because API's `fixtures_spec.rb` matches no files and reports 0 examples and its other specs are `respond_to?(:parse)` stubs. Re-adding a `code` attribute would silently reopen the defect, so `spec/pubid/api/rendering_gaps_spec.rb` asserts it is absent from the whole hierarchy. **Note `maybe(:code)` in `urn_generator/base.rb#urn_number` is safe** — `maybe` guards on `attributes.key?`, so the removed attribute simply never matches. Measured over the corpus, the repair is **purely additive**: of 193 ids, 163 grew and **0 shrank**, and every new rendering *extends* the old one (`to_urn`, `to_hash` and `root.number` are untouched) — the ASHRAE invariant, which is stronger than byte-identity for a fix. **An MR gap this branch pinned was closed mid-flight by PR #354, and the pin is what caught it** — a worked example of why these "assert the CURRENT behaviour" blocks earn their place. When written, all **30** MPMS ids slugged to the bare `"api"` because MPMS kept its locator in a `chapter` attribute and had no `number` at all. #354 then moved the chapter into the inherited `number` (it *is* the document number) and this branch retyped that `number` to `:string`; **composed, they close the collapse** — 0 ids now slug to bare `"api"`, and `API MPMS CH 12.2` keys `"12"` as a String. Merging `main` turned the pin red exactly as designed, and it was rewritten to assert the repair. **A narrower gap remains and is re-pinned**: the MR slug is built from `number` alone, so the sections of one chapter still share it (`CH 14.4` and `CH 14.6` both → `api.14`, 11 colliding slugs corpus-wide, and `to_slug` is a filename). Closing that needs an `Mpms#mr_number_with_part` appending section/subsection (the BIPM/OIML shape). **Merge note for whoever does tranche 2/3**: #354's `Builder#handle_key` routes the MPMS `:chapter` parse key into the `number` *attribute*, and `assign_attributes` hands `handle_key` the **already-cast** value — so the `cast` arm must be `when :number, :chapter then value.to_s`, not a `Components::Code`, or MPMS silently regains a boxed number. (hand-off: retype-number-to-string; remaining: tranche 2 = CSA, tranche 3 = ISO and NIST on separate branches, then flip `identifier.rb:136-138` and delete the leaf `:string` redeclarations and the twelve tripwire blocks.)
10
+
11
+ ## From the root note "Parse-failure error contract — uniform across every flavor"
12
+
13
+ **`Identifier.parse` was the one public parse in the gem that could return `nil`.** `lib/pubid/api/identifier.rb` opened with
14
+
15
+ ```ruby
16
+ return nil if input.start_with?("#") # "Filter out comments"
17
+ ```
18
+
19
+ a **fixture-file concern living in a public API**. A caller passing a `#`-leading string got `nil` back and discovered it as a `NoMethodError` on `.to_s`, far from the cause. `lib/pubid/csa/identifier.rb` even carried a comment noting that every flavor "but `api`" honoured the raise-or-return-an-identifier contract, so this was known and unfixed.
20
+
21
+ Deleting it cost nothing: **both** API fixture loaders already reject `#` lines themselves (`spec/pubid/api/fixtures_spec.rb`, `spec/pubid/api/root_number_spec.rb`), so nothing depended on the nil. A comment line is not an identifier and now raises `Parslet::ParseFailed` like any other unparseable string. The `rescue Parslet::ParseFailed => e; raise e` that followed it was a no-op and went too, and API gained the input guards it had never had (it was one of the 14 flavors an over-long string reached the parser in).
22
+
23
+ **Rule this illustrates:** a filter that exists for *how the test corpus is stored* belongs in the corpus loader, never in `parse`.
@@ -56,3 +56,5 @@ module Pubid
56
56
  end
57
57
  end
58
58
  end
59
+
60
+ Pubid::Api::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -34,7 +34,7 @@ module Pubid
34
34
  # this was the one public parse in the gem that could return nil, which
35
35
  # a caller reading `id.to_s` sees as a NoMethodError far from the cause.
36
36
  # Both API fixture loaders already drop `#` lines themselves.
37
- tree = Parser.new.parse(input)
37
+ tree = Pubid::Parg::Backend.parse(:api, Api::Parser.normalize_input(input))
38
38
  Builder.new.build(tree)
39
39
  end
40
40
  end
@@ -129,11 +129,15 @@ module Pubid
129
129
 
130
130
  root(:identifier)
131
131
 
132
- # Preprocessing to normalize common typos
133
- def parse(input)
132
+ # Pre-parse ingestion normalization (R2): every parse path feeds
133
+ # the grammar the same normalized string.
134
+ def self.normalize_input(input)
134
135
  # Normalize MPMP typo to MPMS
135
- normalized = input.gsub("API MPMP", "API MPMS")
136
- super(normalized)
136
+ input.gsub("API MPMP", "API MPMS")
137
+ end
138
+
139
+ def parse(input)
140
+ super(self.class.normalize_input(input))
137
141
  end
138
142
  end
139
143
  end