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
@@ -8,6 +8,8 @@ module Pubid
8
8
  # Type component for SAE document types
9
9
  # AMS, AIR, ARP, AS, MA
10
10
  class Type < Lutaml::Model::Serializable
11
+ include ::Pubid::SubsetMatch
12
+
11
13
  attribute :abbr, :string
12
14
 
13
15
  def to_s
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ # Subset match: `reference === candidate`.
5
+ #
6
+ # The match is true when every part that +reference+ states matches
7
+ # +candidate+. A part that +reference+ leaves nil or empty is a wildcard.
8
+ # relaton uses it to match a partial user reference against index rows, so
9
+ # the caller does not have to name the parts to ignore, as `#matches?`
10
+ # requires.
11
+ #
12
+ # reference = Pubid::Iso.parse("ISO 9001")
13
+ # dated = Pubid::Iso.parse("ISO 9001:2015")
14
+ # reference === dated # => true
15
+ # dated === reference # => false
16
+ #
17
+ # The operator is NOT symmetric: the receiver is the reference. Ruby calls
18
+ # `===` for `case` and `Enumerable#grep`, so `catalogue.grep(reference)`
19
+ # returns the entries that match the reference. RSpec calls it too: the
20
+ # fuzzy matchers (`include`, `match`, `contain_exactly`, `have_attributes`)
21
+ # and mock argument matchers (`with`) try `expected === actual` when `==` is
22
+ # false. So an expectation with a partial identifier on the expected side
23
+ # passes against a fuller one. Use `eq` when a spec needs exact equality.
24
+ #
25
+ # Rules:
26
+ # - The two objects must be instances of the same class. A reference never
27
+ # falls back to the base document of a wrapper: `BS 7273-4` does not match
28
+ # `BS 7273-4:2015+A1:2021`.
29
+ # - A default value is stated. `ISO 9001` means the published stage, so it
30
+ # does not match `ISO/DIS 9001`.
31
+ # - A collection matches by position, and the reference can be shorter:
32
+ # `ISO/IEC 9001` matches `ISO/IEC/IEEE 9001`, `ISO/IEEE 9001` does not.
33
+ # - A STRICT attribute is exempt from both wildcards: the reference always
34
+ # states it, so a nil value means "this document has none" and a stated
35
+ # collection is not a prefix. A class declares one with `subset_strict`.
36
+ # - A reference that sets +all_parts+ matches every part of the document:
37
+ # `part`, `parts`, `subpart` and `all_parts` itself are skipped.
38
+ # - A nested identifier or component that includes this module is compared
39
+ # with its own `===`. Any other value is compared with `==`.
40
+ # - `#==` does not change. An error from an attribute reader propagates.
41
+ #
42
+ # The match walks the attributes of the objects, not their `to_hash`, so a
43
+ # class can change the rule for its own attributes:
44
+ # - `subset_strict` names attributes the reference always states, for
45
+ # example an ECMA `part` or an ETSI `parts` list.
46
+ # - `self.subset_ignored_attributes` names attributes that `===` skips, for
47
+ # example a value that `from_hash` cannot restore (NIST build artifacts).
48
+ # - `#subset_attribute_match?` decides one attribute; an override calls
49
+ # `super` for the attributes it does not handle.
50
+ #
51
+ # Every identifier and every component class includes this module. A new
52
+ # component class must include it too; `spec/pubid/subset_match_spec.rb`
53
+ # fails otherwise.
54
+ module SubsetMatch
55
+ # The parts of a document, which a reference with +all_parts+ does not
56
+ # restrict. `all_parts` itself is here so that an all-parts reference
57
+ # matches a candidate that states one part.
58
+ ALL_PARTS_ATTRIBUTES = %i[part parts subpart all_parts].freeze
59
+
60
+ def self.included(base)
61
+ base.extend(ClassMethods)
62
+ end
63
+
64
+ # Class-level hooks.
65
+ module ClassMethods
66
+ # Declare attributes that the reference always states. A nil or empty
67
+ # value then means "this document has none", and a stated collection
68
+ # must match in full instead of as a prefix.
69
+ #
70
+ # class Pubid::Ecma::Identifier < Pubid::Identifier
71
+ # subset_strict :part
72
+ # end
73
+ #
74
+ # A caller that does want every part of a document sets `all_parts` on
75
+ # the reference, or keeps using `#matches?(other, ignore:)`.
76
+ #
77
+ # @param names [Array<Symbol>] the attributes of this class
78
+ # @return [Array<Symbol>] the attributes this class itself declares
79
+ def subset_strict(*names)
80
+ @subset_strict_own = subset_strict_own | names.map(&:to_sym)
81
+ end
82
+
83
+ # @return [Array<Symbol>] the attributes that `===` compares exactly,
84
+ # this class's own declarations and every one it inherits
85
+ def subset_strict_attributes
86
+ inherited = if superclass.respond_to?(:subset_strict_attributes)
87
+ superclass.subset_strict_attributes
88
+ else
89
+ []
90
+ end
91
+ inherited | subset_strict_own
92
+ end
93
+
94
+ # @return [Array<Symbol>] the attributes that `===` skips
95
+ def subset_ignored_attributes
96
+ []
97
+ end
98
+
99
+ private
100
+
101
+ def subset_strict_own
102
+ @subset_strict_own ||= []
103
+ end
104
+ end
105
+
106
+ # @param other [Object] the candidate
107
+ # @return [Boolean] true when +other+ holds every part that self states
108
+ def ===(other)
109
+ return true if equal?(other)
110
+ return false unless other.instance_of?(self.class)
111
+
112
+ ignored = subset_skipped_attributes
113
+ self.class.attributes.each_key.all? do |name|
114
+ ignored.include?(name) || subset_attribute_match?(
115
+ name, public_send(name), other.public_send(name)
116
+ )
117
+ end
118
+ end
119
+
120
+ # @return [Array<Symbol>] the attributes this match does not compare
121
+ def subset_skipped_attributes
122
+ ignored = self.class.subset_ignored_attributes
123
+ return ignored unless subset_all_parts_wildcard?
124
+
125
+ ignored | ALL_PARTS_ATTRIBUTES
126
+ end
127
+
128
+ # True when the reference asks for every part of the document.
129
+ # `Pubid::Identifier` overrides it to read its +all_parts+ attribute; a
130
+ # component never holds a document's parts.
131
+ # @return [Boolean]
132
+ def subset_all_parts_wildcard?
133
+ false
134
+ end
135
+
136
+ # @param name [Symbol] the attribute name
137
+ # @param mine [Object] the value of the reference
138
+ # @param theirs [Object] the value of the candidate
139
+ # @return [Boolean] true when +theirs+ satisfies +mine+
140
+ def subset_attribute_match?(name, mine, theirs)
141
+ return SubsetMatch.exact_match?(mine, theirs) if
142
+ self.class.subset_strict_attributes.include?(name)
143
+
144
+ SubsetMatch.value_match?(mine, theirs)
145
+ end
146
+
147
+ # @return [Boolean] true when +theirs+ satisfies the reference value +mine+
148
+ def self.value_match?(mine, theirs)
149
+ return true if blank?(mine)
150
+
151
+ case mine
152
+ when ::Array then collection_match?(mine, theirs)
153
+ when SubsetMatch then mine === theirs
154
+ else mine == theirs
155
+ end
156
+ end
157
+
158
+ # A strict attribute is compared exactly: nil and empty are the same
159
+ # value, so a parsed identifier still matches the index row that
160
+ # `from_hash` rebuilds from a hash that dropped the empty attribute; a
161
+ # collection must match in full, element by element.
162
+ #
163
+ # Exactness composes rather than flattening: a nested value that
164
+ # includes this module is matched in BOTH directions with its own
165
+ # `===`, so the class that owns it keeps its rule. A plain `==` would
166
+ # override it — `Components::TypedStage` ignores `original_abbr`
167
+ # (the input spelling, `Amd` against `AMD`), and CEN/CENELEC declares
168
+ # `typed_stage` strict.
169
+ # @return [Boolean]
170
+ def self.exact_match?(mine, theirs)
171
+ return true if blank?(mine) && blank?(theirs)
172
+
173
+ case mine
174
+ when ::Array then exact_collection_match?(mine, theirs)
175
+ when SubsetMatch then mine === theirs && theirs === mine
176
+ else mine == theirs
177
+ end
178
+ end
179
+
180
+ # A strict collection matches in full, never as a prefix.
181
+ def self.exact_collection_match?(mine, theirs)
182
+ theirs.is_a?(::Array) && mine.size == theirs.size &&
183
+ mine.each_with_index.all? { |value, i| exact_match?(value, theirs[i]) }
184
+ end
185
+
186
+ # A shorter reference collection matches the leading elements.
187
+ def self.collection_match?(mine, theirs)
188
+ theirs.is_a?(::Array) && mine.size <= theirs.size &&
189
+ mine.each_with_index.all? { |value, i| value_match?(value, theirs[i]) }
190
+ end
191
+
192
+ # @return [Boolean] true for nil and for an empty string or collection
193
+ def self.blank?(value)
194
+ value.nil? || Lutaml::Model::Utils.empty?(value)
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,43 @@
1
+ # 3GPP flavor notes
2
+
3
+ 3GPP (`Pubid::Tgpp`) module naming and partial references.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/tgpp/` or `spec/pubid/tgpp/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **Digit-leading flavor names (`3gpp`/`Pubid::Tgpp`)**: a relaton flavor whose name starts with a digit can't be a Ruby constant, so the module is spelled out (`Pubid::Tgpp`, files under `lib/pubid/tgpp/`) while the **external contract stays the digit form** everywhere it's observed: the registry key (`Pubid::Registry.register(:"3gpp", …)`), the polymorphic `_type` (`pubid:3gpp:…`), and the URN namespace (`urn:3gpp:…`). Three overrides bridge the gap — (1) `Identifier.polymorphic_name` post-processes the auto-derived name (`super&.sub(/\Apubid:tgpp:/, "pubid:3gpp:")`) so `_type`/`TGPP_TYPE_MAP` agree; (2) `UrnParser#flavor_name` returns `"3gpp"` so `strip_namespace` accepts `urn:3gpp:`; (3) the export layer registers `:tgpp` (NOT `:"3gpp"`) in `Export::Exporter::FLAVORS` because `FlavorExporter` maps the symbol via `Pubid.const_get(camelize(sym))`, and it reads fixtures from `spec/fixtures/tgpp/` — so this flavor's on-disk paths (`spec/pubid/tgpp/`, `spec/fixtures/tgpp/`) follow the module, not the digit name. `PREFIXES = ["3GPP"]` (the printed docidentifier is `3GPP TS …`; the parser also accepts the bare index form). Any future digit-leading flavor mirrors this split.
8
+
9
+ - **3GPP partial references — both trailing qualifiers are optional**: a 3GPP identifier is `[3GPP ]<TR|TS> <NN.NNN>[suffix][-part…][:<release>][/<version>]`. The release segment was already `.maybe` (a handful of legacy records omit it, e.g. `TS 29.215/2.0.0`) but the **version was mandatory**, so the bare reference a person actually types — `3GPP TS 23.207` — failed to parse. Every one of the 88,464 published `relaton-data-3gpp` index rows carries both, so the corpus never exercised the gap; it blocks the *consumer*, which must parse a user query to search that index. The fix is the ETSI three-step pattern verbatim — parser `(str("/") >> version).maybe`, builder `data[:version]&.to_s` (never `""`, so the no-defaults `to_hash` drops it), renderer appending each qualifier only when present — plus the URN pair. `type` and `number_core` stay required, and `version` still demands three dotted digit groups, so `TS`, `TS foo`, `TS 23.207/4.0`, `TS 23.207/` and `TS 23.207:` are all still rejected. **URN decision: only TRAILING empty segments are dropped, never interior ones.** A bare reference gets the clean `urn:3gpp:ts:23.207` instead of a malformed `urn:3gpp:ts:23.207::`, while the pre-existing release-less form keeps the URN it has always published (`TS 29.215/2.0.0` → `urn:3gpp:ts:29.215::2.0.0`, an interior empty segment `UrnParser` already reads back). Generator and parser are exact inverses for all four present/absent combinations. **One guard the optionality forced**: the `release` rule is deliberately generic (`match["^/:"].repeat(1)`), so once the version became optional a mistyped separator — `TS 23.207:2.0.0` — silently parsed with the *version* misfiled as the release, where it was structurally impossible before. `release` now carries a `(version_core >> any.absent?).absent?` lookahead, rejecting a trailing segment shaped exactly like a version. It is anchored at **end of input**, so a real release still wins whenever a version follows (`TS 23.207:Release 2000/9.0.0`); the empirical warrant is that **none** of the 27 distinct releases in the 88,464 published rows contains a dot at all. **Landmine in the trailing-strip loop**: a bare `Array#any?` tests element *truthiness*, so `[nil, nil].any?` is `false` and strips nothing — the loop guard must be `until segments.empty? || !blank?(segments.last)`. **Nothing about the stored shape changed**: `attribute :version, :string` and the `key_value` map stay as they are, only `nil` becomes reachable, so no index migration follows and no relaton index needs regenerating. `number` was already a plain `:string` and already the relaton bsearch key — there is no IANA/BIPM/W3C-style `root.number` defect here. Verified over the whole published corpus: `to_s`, `to_urn` and the URN round-trip are **byte-identical on all 88,464 rows** before and after, with 0 empty `root.number`. 3GPP's `spec/pubid/partial_ref_spec.rb` entry moved from the laggard `omits: []` to `{ ref: "3GPP TS 23.207", omits: %i[release version] }`, leaving **AMCA as the only remaining laggard**. The new bare/partial forms are NORMALIZING for the `3GPP ` prefix (`3GPP TS 23.207` renders back as `TS 23.207`, since `with_publisher` defaults to false to match the relaton index id), so per the `CIPM/2005-06(REV)` precedent they live in `spec/pubid/tgpp/identifier_spec.rb`, **not** in the byte-exact `spec/fixtures/tgpp/identifiers/pass/`. **Pre-existing and out of scope:** `Pubid.parse` resolves only `:urn` and `:mr_string` inputs and raises for a human string in *every* flavor, so `Pubid.parse("3GPP TS 23.207")` does not route — consumers call `Pubid::Tgpp::Identifier.parse` (or pass `pubid_class:`) directly. (hand-off: tgpp-bare-reference-parse; consumer follow-up: relaton__relaton__3gpp-consume-index-v2.)
10
+
11
+ ## `all_parts_edition_keys` missed `release`
12
+
13
+ `Identifier.all_parts_edition_keys` defaults to `%i[date year edition
14
+ version]`. 3GPP's edition/version identity is split across two
15
+ attributes: `version` is in the default list, but `release` (e.g.
16
+ `"REL-4"`) is not, so `"3GPP TS 23.207:REL-4/4.0.0".to_all_parts` kept the
17
+ release token and failed to match `"3GPP TS 23.207:REL-5/5.0.0"` under
18
+ `#===`. Fixed with `Pubid::Tgpp::Identifier.all_parts_edition_keys` (`super
19
+ + %i[release]`) — unlike OGC's `year`, nothing else 3GPP declares needs to
20
+ come OUT of the default list, so this is a pure addition. Locked by
21
+ `spec/pubid/tgpp/all_parts_spec.rb` and
22
+ `spec/pubid/all_parts_edition_keys_audit_spec.rb`.
23
+
24
+ ## Related rule in the root file
25
+
26
+ 3GPP's `parts` attribute produced the rule that a `collection: true` attribute needs `initialize_empty: true`. That rule applies to every flavor, so it stays in the root `CLAUDE.md`, not here.
27
+
28
+ ## Subset match: strict attributes
29
+
30
+ Read `docs/SUBSET_MATCH.md` first. `===` reads a nil part of the reference as
31
+ a wildcard, which is wrong for the attributes below: the flavor models a nil
32
+ value as "this document has none". They are declared with `subset_strict`, so
33
+ `===` compares them exactly and a stated collection is not a prefix. A caller
34
+ that does want every part of a document sets `all_parts` on the reference, or
35
+ keeps `#matches?(other, ignore:)`.
36
+
37
+ - **`suffix` and `parts` are strict** (`lib/pubid/tgpp/identifier.rb`).
38
+ `TS 29.198 === TS 29.198-04-1` and `TR 00.01 === TR 00.01U` are both
39
+ false. `parts` is the flavor's `collection: true, initialize_empty: true`
40
+ attribute, so a part-less identifier holds `[]` on both construction
41
+ paths and the strict comparison treats that `[]` and a nil alike — the
42
+ same asymmetry the `initialize_empty` note above is about, seen from the
43
+ matching side. relaton had two committed specs breaking on this.
@@ -39,3 +39,5 @@ module Pubid
39
39
  end
40
40
  end
41
41
  end
42
+
43
+ Pubid::Tgpp::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -27,6 +27,20 @@ module Pubid
27
27
  # Three-part version string, e.g. "2.0.0".
28
28
  attribute :version, :string
29
29
 
30
+ # 3GPP's edition/version identity is split across `release` and
31
+ # `version`. `version` is already in the default
32
+ # `%i[date year edition version]` list; `release` is not, so it
33
+ # survived without_parts untouched and leaked into an "all parts"
34
+ # identity built via #to_all_parts.
35
+ def self.all_parts_edition_keys
36
+ super + %i[release]
37
+ end
38
+
39
+ # A nil `suffix` and an empty `parts` mean the document has neither:
40
+ # `TS 29.198` is not `TS 29.198-04-1`, and `TR 00.01` is not
41
+ # `TR 00.01U`. A stated list is not a prefix either.
42
+ subset_strict :suffix, :parts
43
+
30
44
  # Polymorphic type map for lutaml::Model key_value (de)serialization.
31
45
  # Keys are the `pubid:3gpp:…` names produced by polymorphic_name (below).
32
46
  TGPP_TYPE_MAP = {
@@ -15,6 +15,9 @@ module Pubid
15
15
  class TypeResolver
16
16
  TYPE_PREFIX = "pubid:"
17
17
  SEGMENT_SEPARATOR = ":"
18
+ # Types of classes that belong to no flavor, so they carry no flavor
19
+ # segment. Class names are strings, so the lookup triggers the autoload.
20
+ SHARED_TYPES = { "pubid:all-parts" => "Pubid::AllPartsIdentifier" }.freeze
18
21
 
19
22
  class << self
20
23
  # @param type [String, nil] Polymorphic _type, e.g. "pubid:iso:technical-report".
@@ -23,6 +26,17 @@ module Pubid
23
26
  def resolve(type)
24
27
  return nil unless type.is_a?(String)
25
28
 
29
+ shared_class(type) || flavor_class(type)
30
+ end
31
+
32
+ private
33
+
34
+ def shared_class(type)
35
+ name = SHARED_TYPES[type]
36
+ name && Object.const_get(name)
37
+ end
38
+
39
+ def flavor_class(type)
26
40
  flavor_name = flavor_segment(type)
27
41
  return nil unless flavor_name
28
42
 
@@ -43,8 +57,6 @@ module Pubid
43
57
  nil
44
58
  end
45
59
 
46
- private
47
-
48
60
  # The registered flavor whose module constant gives +segment+.
49
61
  # Identifier.polymorphic_name takes the segment from the module name
50
62
  # ("Pubid::CenCenelec" -> "cencenelec"), and a flavor can be registered
@@ -40,3 +40,5 @@ module Pubid
40
40
  end
41
41
  end
42
42
  end
43
+
44
+ Pubid::Un::Builder.prepend(Pubid::Builder::AllPartsWrap)
data/lib/pubid/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pubid
4
- VERSION = "2.0.0.pre.alpha.12"
4
+ VERSION = "2.0.0.pre.alpha.13"
5
5
  end
@@ -0,0 +1,7 @@
1
+ # W3C flavor notes
2
+
3
+ W3C slug attribute and index key.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/w3c/` or `spec/pubid/w3c/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **W3C renamed its slug attribute `code` → `number` (index key)**: a W3C identifier is a flat slug plus an optional verbatim date, and the slug used to live in `attribute :code, :string` while the `number` inherited from `::Pubid::Identifier` (a `Components::Code`) was never set. `Relaton::Index::Type#candidates_by_number` sorts and bsearches every row on `id.root.number.to_s`, so all **16,336** published `relaton-data-w3c` rows shared the empty key `""` and the binary search degenerated to a linear scan — silently, with no error. The slug *is* the key, so the attribute was **renamed** rather than shadowed by a derived reader: `attribute :number, :string` on `Pubid::W3c::Identifier`, `map "number", to: :number` in the `key_value` block, and the three readers moved (`Builder#build`'s `new(number:)`, `Renderer#render`, `UrnGenerator#generate`). **`code` is dropped with no alias** — one name for one value. Serialized vocabulary is `{_type, number}` / `{_type, number, date}`; `to_s` and the URN shape (`urn:w3c:<type>:<slug>[:<date>]`) are byte-identical, verified over the whole published corpus (0 empty/mismatched keys, 0 round-trip failures, 1933 buckets — was 1). **Why the declaration sits on the shared base here, when IEEE/IETF/IANA put theirs on the leaves**: this still redefines the parent's `attribute :number, Components::Code` as `:string` on a class the 11 concrete `Identifiers::*` inherit from — the recorded landmine — but the mechanism shows the hazard is load-order, not inheritance: lutaml's `inherited` hook **deep-dups** the parent's attribute table into each subclass at class-definition time (`serialize/initialization.rb#initialize_attrs`), so a subclass holds a **snapshot**, not a live view. A base-level override is therefore safe iff every subclass body opens *after* the base body has run — and Ruby resolves the superclass constant (autoloading `w3c/identifier.rb` and running it to completion) before opening any leaf body. W3C's base is **one file, one class body, never reopened**, so this holds in every load order; the `date` override of the parent's `Components::Date` has relied on the same property since the flavor landed. IEEE is the counter-shape: its `identifier.rb` defines nothing and the class body lives in `identifiers/base.rb`, so the base is reachable through two paths and a leaf can snapshot it half-built. **Split this base across two files and the landmine comes back.** `spec/pubid/w3c/root_number_spec.rb` carries the structural tripwire (the base *and* all 11 leaves must resolve `number` to `Lutaml::Model::Type::String`) and is only meaningful under the full `bundle exec rake`. **relaton note**: there is no alias and no published W3C pubid index yet, so nothing needs migrating — but `relaton/relaton`'s in-flight migration off the hand-rolled `Relaton::W3c::PubId` must read `id.number`, and its `DataFetcher`-built index must be crawled *after* this lands or every row keys on `""` again. **Known gap, deliberately NOT fixed here and recorded as a separate hand-off (w3c-mr-string):** `to_mr_string` **raises `NoMethodError` for every dated W3C id** — the base `mr_year` calls `date.year`, but W3C's `date` is a plain `:string` (opaque digit runs of varying width; the flavor already works around this for its public `#year` reader, but not for `mr_year`), and `mr_type`/`mr_publisher` are always nil (`Builder` never sets `typed_stage`; the publisher is a constant). This is pre-existing on `main`, untouched by the rename, and is a *second* identity surface — `to_slug` is an output **filename**. Measured over the 16,336 published rows: a year-only `mr_year` gives **1,936 collisions**; the whole verbatim `date` plus BIPM-style charset sanitisation (the corpus needs `/` and `+`) gives **16,336 distinct, filename-safe slugs, 0 collisions**. (hand-off: w3c-index-number; consumer follow-up: relaton__relaton__w3c-pubid-number-rename.)
@@ -53,3 +53,5 @@ module Pubid
53
53
  end
54
54
  end
55
55
  end
56
+
57
+ Pubid::W3c::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -0,0 +1,11 @@
1
+ # XSF flavor notes
2
+
3
+ XSF named documents (`XEP README`, `XEP xxxx`).
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/xsf/` or `spec/pubid/xsf/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **The XEP number is not always a number**: the whole XSF grammar is four rules, and the number used to be `match["0-9"].repeat(1)`. The XMPP extensions repository publishes two documents whose name is a word — the editor README (`xep-README.html`) and the template (`xep-xxxx.html`) — and both reach pubid as **real primary docids**, `XEP README` and `XEP xxxx`. They are rows of the published `relaton-data-xsf/index-v1.yaml`, backed by `data/xep-readme.yaml` (title "XEP Editor README") and `data/xep-xxxx.yaml` (title "XEP Template"). The digits-only rule rejected both at char 5, which cost two repositories a permanent workaround: `Relaton::Xsf::DataFetcher` carried `NON_DOCUMENTS = ["XEP README", "XEP xxxx"]` to swallow the parse failure in `add_to_index`, and `relaton-data-xsf/build_index_v1.rb` documented that its `index-v1` is deliberately **two rows longer** than `index-v2` (520 against 518) — one unparseable row makes `Relaton::Index` declare the whole file corrupt, delete it, and hand back an empty index, so the two rows could never enter v2. The grammar now admits the two names beside the digits: `(digits | special_number).as(:number)`, with `SPECIAL_NUMBERS = %w[README xxxx]` on `Pubid::Xsf::Parser`.
8
+ - **Two literals, not a slug rule, and the exact case only.** `XEP foo`, `XEP readme`, `XEP XXXX`, `XEP xxx` and `XEP xxxxx` all still raise `Pubid::Errors::ParseError`, pinned one by one in `spec/pubid/xsf/identifier_spec.rb`. Widening the number to any alphanumeric run would have been one character shorter and would have made XSF an anything-goes flavor after its prefix — the shape `Pubid.parse` probes for with `accepts_anything?` and then refuses to use as a fallback. A case-insensitive match would have made the parse *normalizing* (`XEP readme` → `XEP README`), so `to_s` would no longer equal the input and the fixture round-trip spec would need the `!input!rendered` marker for a form the corpus does not contain.
9
+ - **Nothing outside the parser changed, and that is the reason to store the name in `number`.** `number` is already `attribute :number, :string` on `Pubid::Xsf::Identifier` (a string to keep the zero padding), so `"README"` stores as it arrives; `Renderer#render` interpolates it, so `to_s` gives back `XEP README`; `UrnGenerator` gives `urn:xsf:xep:README` and `UrnParser` rebuilds the printed string and re-parses it, so the URN round-trip closes; `key_value` already maps `number`, so `from_hash(to_hash) == parse`; and `root.number` is non-empty, so the index key is sound for both. A separate attribute for the name would have broken every one of those.
10
+ - **relaton note**: once this ships, `Relaton::Xsf::DataFetcher` can drop `NON_DOCUMENTS` and the rescue branch that reads it, `relaton-data-xsf` needs its `index-v2` regenerated to carry all 520 rows, and the `build_index_v1.rb` comment asserting the two-row difference is then wrong. (hand-off: `relaton__relaton__xsf-named-documents-are-parseable`.)
11
+ - **Pre-existing gaps, untouched here.** XSF's MR slug carries no publisher segment — `Pubid::Xsf.parse("XEP 0001").to_slug == "0001"` — so it collides with any other flavor's bare-number slug; `lib/pubid/iana/CLAUDE.md` records XSF among the slug-only flavors that would surface this if `mr_string_spec` were made registry-driven. The fixture corpus is one hand-written file, `spec/fixtures/xsf/identifiers/pass/xep.txt`: XSF has no `identifiers/full/` directory and no `validation:classify` entry, so that file is edited by hand — the root `CLAUDE.md` rule against hand-editing `pass/` covers the classifier-driven flavors, which XSF is not. There is no `fail/` corpus at all.
@@ -15,3 +15,5 @@ module Pubid
15
15
  end
16
16
  end
17
17
  end
18
+
19
+ Pubid::Xsf::Builder.prepend(Pubid::Builder::AllPartsWrap)
data/lib/pubid.rb CHANGED
@@ -143,7 +143,10 @@ module Pubid
143
143
  autoload :Parser, "pubid/parser"
144
144
  autoload :Components, "pubid/components"
145
145
  autoload :BundledIdentifier, "pubid/bundled_identifier"
146
+ autoload :AllParts, "pubid/all_parts"
147
+ autoload :AllPartsIdentifier, "pubid/all_parts_identifier"
146
148
  autoload :Identifier, "pubid/identifier"
149
+ autoload :SubsetMatch, "pubid/subset_match"
147
150
  autoload :IdentifierMetadata, "pubid/identifier_metadata"
148
151
  autoload :Rendering, "pubid/rendering"
149
152
  autoload :Renderers, "pubid/renderers"
@@ -486,15 +489,25 @@ module Pubid
486
489
  private_class_method :routing_table
487
490
 
488
491
  # True when +string+ starts with +prefix+ at a token boundary, so "ISO" does
489
- # not claim "ISOFIX" and "BS" does not claim "BSI".
490
- # @api private
492
+ # not claim "ISOFIX" and "BS" does not claim "BSI". A boundary is the end
493
+ # of the string or any non-alphanumeric character, so a registered prefix
494
+ # can be followed by a space ("ISO 9001") or by a slash attaching a type
495
+ # token ("ISO/TR 25901-1:2016").
496
+ #
497
+ # Public (not +@api private+) so a flavor's own prefix routing - GOST's
498
+ # foreign-adoption lookup, currently the only caller outside this file -
499
+ # can reuse the exact boundary rule {parse_by_prefix} uses, rather than
500
+ # keeping a second copy that can drift out of sync.
501
+ #
502
+ # @param string [String]
503
+ # @param prefix [String]
504
+ # @return [Boolean]
491
505
  def self.prefix_match?(string, prefix)
492
506
  return false unless string.start_with?(prefix)
493
507
 
494
508
  rest = string[prefix.length]
495
509
  rest.nil? || !/[A-Za-z0-9]/.match?(rest)
496
510
  end
497
- private_class_method :prefix_match?
498
511
 
499
512
  def self.detect_flavor_from_urn(urn)
500
513
  # urn:iso:std:... → "iso"