pubid 2.0.0.pre.alpha.11 → 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 (274) 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/components/date.rb +10 -4
  40. data/lib/pubid/bsi/identifiers/addendum_document.rb +2 -0
  41. data/lib/pubid/bsi/identifiers/adopted_european_norm.rb +6 -54
  42. data/lib/pubid/bsi/identifiers/adopted_international_standard.rb +5 -22
  43. data/lib/pubid/bsi/identifiers/amendment.rb +36 -12
  44. data/lib/pubid/bsi/identifiers/british_industrial_practice.rb +1 -1
  45. data/lib/pubid/bsi/identifiers/bundled_identifier.rb +2 -0
  46. data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +23 -26
  47. data/lib/pubid/bsi/identifiers/corrigendum.rb +29 -12
  48. data/lib/pubid/bsi/identifiers/expert_commentary.rb +6 -7
  49. data/lib/pubid/bsi/identifiers/handbook.rb +1 -1
  50. data/lib/pubid/bsi/identifiers/national_annex.rb +18 -20
  51. data/lib/pubid/bsi/identifiers/practice_guide.rb +1 -1
  52. data/lib/pubid/bsi/identifiers/root_identity.rb +31 -0
  53. data/lib/pubid/bsi/identifiers/set.rb +2 -0
  54. data/lib/pubid/bsi/identifiers/supplement_document.rb +2 -0
  55. data/lib/pubid/bsi/identifiers.rb +1 -0
  56. data/lib/pubid/bsi/parser.rb +8 -8
  57. data/lib/pubid/bsi/renderer.rb +20 -20
  58. data/lib/pubid/bsi/single_identifier.rb +11 -2
  59. data/lib/pubid/bsi/urn_generator.rb +28 -18
  60. data/lib/pubid/builder/base.rb +27 -0
  61. data/lib/pubid/calconnect/builder.rb +2 -0
  62. data/lib/pubid/calconnect/identifier.rb +4 -0
  63. data/lib/pubid/ccsds/builder.rb +2 -0
  64. data/lib/pubid/ccsds/identifier.rb +12 -0
  65. data/lib/pubid/ccsds/single_identifier.rb +14 -10
  66. data/lib/pubid/cen_cenelec/CLAUDE.md +59 -0
  67. data/lib/pubid/cen_cenelec/builder.rb +6 -1
  68. data/lib/pubid/cen_cenelec/identifier.rb +10 -27
  69. data/lib/pubid/cen_cenelec/identifiers/amendment.rb +3 -10
  70. data/lib/pubid/cen_cenelec/identifiers/corrigendum.rb +3 -10
  71. data/lib/pubid/cen_cenelec/parser.rb +11 -3
  72. data/lib/pubid/cie/CLAUDE.md +58 -0
  73. data/lib/pubid/cie/builder.rb +2 -0
  74. data/lib/pubid/cie/components/language.rb +2 -0
  75. data/lib/pubid/components/adoption.rb +2 -0
  76. data/lib/pubid/components/code.rb +2 -0
  77. data/lib/pubid/components/date.rb +8 -6
  78. data/lib/pubid/components/edition.rb +2 -0
  79. data/lib/pubid/components/iteration.rb +2 -0
  80. data/lib/pubid/components/language.rb +2 -0
  81. data/lib/pubid/components/locality.rb +2 -0
  82. data/lib/pubid/components/publisher.rb +2 -0
  83. data/lib/pubid/components/relationship.rb +2 -0
  84. data/lib/pubid/components/stage.rb +2 -0
  85. data/lib/pubid/components/supplement.rb +2 -0
  86. data/lib/pubid/components/type.rb +2 -0
  87. data/lib/pubid/components/typed_stage.rb +8 -0
  88. data/lib/pubid/csa/CLAUDE.md +41 -0
  89. data/lib/pubid/csa/builder.rb +5 -3
  90. data/lib/pubid/csa/identifier.rb +13 -1
  91. data/lib/pubid/csa/identifiers/bundled.rb +2 -2
  92. data/lib/pubid/csa/identifiers/cec.rb +1 -1
  93. data/lib/pubid/csa/renderer.rb +12 -12
  94. data/lib/pubid/csa/single_identifier.rb +18 -1
  95. data/lib/pubid/csa/urn_generator.rb +1 -1
  96. data/lib/pubid/doi/builder.rb +2 -0
  97. data/lib/pubid/easc/builder.rb +2 -0
  98. data/lib/pubid/ecma/CLAUDE.md +28 -0
  99. data/lib/pubid/ecma/builder.rb +2 -0
  100. data/lib/pubid/ecma/identifier.rb +7 -0
  101. data/lib/pubid/etsi/CLAUDE.md +34 -0
  102. data/lib/pubid/etsi/builder.rb +2 -0
  103. data/lib/pubid/etsi/components/code.rb +6 -0
  104. data/lib/pubid/etsi/components/version.rb +2 -0
  105. data/lib/pubid/etsi/identifiers/etsi_standard.rb +7 -0
  106. data/lib/pubid/evs/CLAUDE.md +58 -0
  107. data/lib/pubid/evs/builder.rb +3 -1
  108. data/lib/pubid/evs/identifier.rb +6 -6
  109. data/lib/pubid/evs/identifiers/national_adoption.rb +7 -2
  110. data/lib/pubid/evs/renderer.rb +1 -1
  111. data/lib/pubid/evs/urn_generator.rb +1 -1
  112. data/lib/pubid/evs/urn_parser.rb +1 -1
  113. data/lib/pubid/gb/CLAUDE.md +140 -0
  114. data/lib/pubid/gb/builder.rb +21 -11
  115. data/lib/pubid/gb/identifier.rb +28 -7
  116. data/lib/pubid/gb/identifiers/all_parts.rb +17 -0
  117. data/lib/pubid/gb/identifiers.rb +1 -0
  118. data/lib/pubid/gb/parser.rb +4 -0
  119. data/lib/pubid/gb/renderer.rb +4 -4
  120. data/lib/pubid/gb.rb +1 -0
  121. data/lib/pubid/gost/CLAUDE.md +64 -0
  122. data/lib/pubid/gost/builder.rb +3 -1
  123. data/lib/pubid/gost/identifier.rb +5 -0
  124. data/lib/pubid/iala/CLAUDE.md +82 -0
  125. data/lib/pubid/iala/builder.rb +2 -0
  126. data/lib/pubid/iana/CLAUDE.md +7 -0
  127. data/lib/pubid/iana/builder.rb +2 -0
  128. data/lib/pubid/identifier.rb +172 -20
  129. data/lib/pubid/idf/builder.rb +6 -1
  130. data/lib/pubid/idf/identifier.rb +12 -0
  131. data/lib/pubid/idf/identifiers/all_parts.rb +17 -0
  132. data/lib/pubid/idf/identifiers/amendment.rb +1 -1
  133. data/lib/pubid/idf/identifiers/corrigendum.rb +1 -1
  134. data/lib/pubid/idf/identifiers/international_standard.rb +1 -1
  135. data/lib/pubid/idf/identifiers/reviewed_method.rb +1 -1
  136. data/lib/pubid/idf/identifiers.rb +1 -0
  137. data/lib/pubid/idf/single_identifier.rb +1 -1
  138. data/lib/pubid/iec/CLAUDE.md +31 -0
  139. data/lib/pubid/iec/builder.rb +7 -1
  140. data/lib/pubid/iec/components/consolidated_amendment.rb +4 -0
  141. data/lib/pubid/iec/components/sheet.rb +2 -0
  142. data/lib/pubid/iec/components/trf_info.rb +2 -0
  143. data/lib/pubid/iec/components/vap_suffix.rb +2 -0
  144. data/lib/pubid/iec/identifier.rb +7 -2
  145. data/lib/pubid/iec/identifiers/all_parts.rb +19 -0
  146. data/lib/pubid/iec/identifiers.rb +1 -0
  147. data/lib/pubid/iec/renderer.rb +0 -1
  148. data/lib/pubid/iec/urn_generator.rb +9 -1
  149. data/lib/pubid/iec/urn_parser.rb +3 -2
  150. data/lib/pubid/ieee/CLAUDE.md +97 -0
  151. data/lib/pubid/ieee/builder.rb +176 -7
  152. data/lib/pubid/ieee/components/code.rb +2 -0
  153. data/lib/pubid/ieee/components/draft.rb +35 -2
  154. data/lib/pubid/ieee/components/typed_stage.rb +2 -0
  155. data/lib/pubid/ieee/identifiers/base.rb +20 -0
  156. data/lib/pubid/ieee/identifiers/iec_ieee_copublished.rb +9 -0
  157. data/lib/pubid/ieee/identifiers/joint_development.rb +17 -10
  158. data/lib/pubid/ieee/identifiers/project_draft_identifier.rb +8 -1
  159. data/lib/pubid/ieee/identifiers/si_standard.rb +5 -2
  160. data/lib/pubid/ieee/ire/parser.rb +19 -1
  161. data/lib/pubid/ieee/parser.rb +337 -49
  162. data/lib/pubid/ieee/project_renderer.rb +46 -0
  163. data/lib/pubid/ieee/renderer.rb +46 -14
  164. data/lib/pubid/ieee/urn_generator.rb +31 -0
  165. data/lib/pubid/ieee.rb +2 -0
  166. data/lib/pubid/ietf/CLAUDE.md +7 -0
  167. data/lib/pubid/ietf/builder.rb +2 -0
  168. data/lib/pubid/iho/builder.rb +2 -0
  169. data/lib/pubid/isbn/builder.rb +2 -0
  170. data/lib/pubid/iso/CLAUDE.md +47 -0
  171. data/lib/pubid/iso/builder.rb +29 -16
  172. data/lib/pubid/iso/components/publisher.rb +2 -0
  173. data/lib/pubid/iso/components.rb +0 -1
  174. data/lib/pubid/iso/identifier.rb +17 -18
  175. data/lib/pubid/iso/identifiers/all_parts.rb +19 -0
  176. data/lib/pubid/iso/identifiers/directives.rb +7 -4
  177. data/lib/pubid/iso/identifiers/directives_supplement.rb +4 -2
  178. data/lib/pubid/iso/identifiers/tc_document.rb +31 -46
  179. data/lib/pubid/iso/identifiers.rb +1 -0
  180. data/lib/pubid/iso/normalizer.rb +1 -1
  181. data/lib/pubid/iso/rendering_style.rb +0 -1
  182. data/lib/pubid/iso/urn_generator.rb +10 -10
  183. data/lib/pubid/iso.rb +5 -4
  184. data/lib/pubid/itu/CLAUDE.md +69 -0
  185. data/lib/pubid/itu/builder.rb +12 -0
  186. data/lib/pubid/itu/components/code.rb +2 -0
  187. data/lib/pubid/itu/components/designation.rb +2 -0
  188. data/lib/pubid/itu/components/sector.rb +2 -0
  189. data/lib/pubid/itu/components/series.rb +2 -0
  190. data/lib/pubid/itu/identifiers/base.rb +10 -0
  191. data/lib/pubid/itu/identifiers/contribution.rb +31 -0
  192. data/lib/pubid/itu/identifiers/supplement.rb +15 -0
  193. data/lib/pubid/itu/identifiers.rb +1 -0
  194. data/lib/pubid/itu/parser.rb +19 -2
  195. data/lib/pubid/jcgm/CLAUDE.md +7 -0
  196. data/lib/pubid/jcgm/builder.rb +2 -0
  197. data/lib/pubid/jcgm/components/publisher.rb +2 -0
  198. data/lib/pubid/jis/builder.rb +5 -1
  199. data/lib/pubid/jis/identifier.rb +5 -17
  200. data/lib/pubid/jis/identifiers/all_parts.rb +19 -0
  201. data/lib/pubid/jis/identifiers.rb +1 -0
  202. data/lib/pubid/jis/renderer.rb +0 -2
  203. data/lib/pubid/jis/urn_generator.rb +0 -1
  204. data/lib/pubid/nist/CLAUDE.md +56 -0
  205. data/lib/pubid/nist/builder.rb +14 -12
  206. data/lib/pubid/nist/components/edition.rb +2 -0
  207. data/lib/pubid/nist/components/issue_number.rb +2 -0
  208. data/lib/pubid/nist/components/part.rb +2 -0
  209. data/lib/pubid/nist/components/stage.rb +2 -0
  210. data/lib/pubid/nist/components/supplement.rb +2 -0
  211. data/lib/pubid/nist/components/translation.rb +2 -0
  212. data/lib/pubid/nist/components/update.rb +2 -0
  213. data/lib/pubid/nist/components/version.rb +2 -0
  214. data/lib/pubid/nist/components/volume.rb +2 -0
  215. data/lib/pubid/nist/identifiers/base.rb +34 -6
  216. data/lib/pubid/nist/identifiers/commercial_standard_emergency.rb +1 -1
  217. data/lib/pubid/nist/identifiers/crpl_report.rb +5 -6
  218. data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +2 -2
  219. data/lib/pubid/nist/identifiers/miscellaneous_publication.rb +2 -2
  220. data/lib/pubid/nist/parser.rb +15 -2
  221. data/lib/pubid/nist/preprocessor.rb +53 -2
  222. data/lib/pubid/nist/series/ir.rb +3 -7
  223. data/lib/pubid/oasis/CLAUDE.md +19 -0
  224. data/lib/pubid/oasis/builder.rb +2 -0
  225. data/lib/pubid/oasis/identifier.rb +19 -0
  226. data/lib/pubid/ogc/CLAUDE.md +34 -0
  227. data/lib/pubid/ogc/builder.rb +2 -0
  228. data/lib/pubid/ogc/identifier.rb +11 -0
  229. data/lib/pubid/oiml/CLAUDE.md +189 -0
  230. data/lib/pubid/oiml/builder.rb +22 -2
  231. data/lib/pubid/oiml/components/code.rb +6 -0
  232. data/lib/pubid/oiml/identifier.rb +13 -0
  233. data/lib/pubid/oiml/identifiers/annex.rb +4 -0
  234. data/lib/pubid/oiml/identifiers/bulletin.rb +30 -36
  235. data/lib/pubid/oiml/identifiers/certification_system.rb +34 -0
  236. data/lib/pubid/oiml/identifiers/code_number.rb +10 -2
  237. data/lib/pubid/oiml/identifiers/dual_published.rb +174 -0
  238. data/lib/pubid/oiml/identifiers.rb +2 -0
  239. data/lib/pubid/oiml/parser.rb +41 -5
  240. data/lib/pubid/oiml/renderer.rb +26 -4
  241. data/lib/pubid/oiml/single_identifier.rb +5 -1
  242. data/lib/pubid/oiml/supplement_identifier.rb +7 -0
  243. data/lib/pubid/oiml/urn_generator.rb +32 -4
  244. data/lib/pubid/oiml.rb +5 -0
  245. data/lib/pubid/omg/CLAUDE.md +15 -0
  246. data/lib/pubid/omg/builder.rb +2 -0
  247. data/lib/pubid/parser/grammar.rb +23 -0
  248. data/lib/pubid/plateau/builder.rb +2 -0
  249. data/lib/pubid/plateau/identifiers/base.rb +4 -0
  250. data/lib/pubid/plateau/supplement_identifier.rb +14 -2
  251. data/lib/pubid/plateau/urn_generator.rb +7 -1
  252. data/lib/pubid/renderers/directives_renderer.rb +3 -1
  253. data/lib/pubid/renderers/human_readable.rb +0 -1
  254. data/lib/pubid/sae/builder.rb +3 -1
  255. data/lib/pubid/sae/components/date.rb +2 -0
  256. data/lib/pubid/sae/components/type.rb +2 -0
  257. data/lib/pubid/sae/identifiers/base.rb +4 -1
  258. data/lib/pubid/sae/urn_generator.rb +1 -1
  259. data/lib/pubid/subset_match.rb +197 -0
  260. data/lib/pubid/tgpp/CLAUDE.md +43 -0
  261. data/lib/pubid/tgpp/builder.rb +2 -0
  262. data/lib/pubid/tgpp/identifier.rb +14 -0
  263. data/lib/pubid/type_resolver.rb +14 -2
  264. data/lib/pubid/un/builder.rb +2 -0
  265. data/lib/pubid/version.rb +1 -1
  266. data/lib/pubid/w3c/CLAUDE.md +7 -0
  267. data/lib/pubid/w3c/builder.rb +2 -0
  268. data/lib/pubid/xsf/CLAUDE.md +11 -0
  269. data/lib/pubid/xsf/builder.rb +2 -0
  270. data/lib/pubid.rb +16 -3
  271. data/lib/tasks/docs.rake +13 -11
  272. metadata +46 -4
  273. data/lib/pubid/ccsds/identifiers/base_BASE_88929.rb +0 -70
  274. data/lib/pubid/iso/components/code.rb +0 -24
@@ -0,0 +1,15 @@
1
+ # OMG flavor notes
2
+
3
+ OMG document parts, the separator that normalizes, the acronym charset and its slash, and the bare `beta`.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/omg/` or `spec/pubid/omg/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **The document part reuses the inherited `part`, retyped to `:string`**: an OMG identifier is `OMG <ACRONYM>[ <VERSION>][ <PART>]`, and the third component is a real OMG form, not an invention. OMG published UML 2.1.1 as **two** documents and its URLs carry the segment (`/spec/UML/2.1.1/Superstructure`, `/spec/UML/2.1.1/Infrastructure`); the same position also holds a format name (`/spec/DDS/1.4/PDF`). Both go into one optional component. The attribute is the **`part` that `::Pubid::Identifier` already declares** as a `Components::Code`, retyped to `:string` on `Pubid::Omg::Identifier` — the tranche-1 shape (ansi/api/idf/jcgm/bsi/cen_cenelec). Three things follow from reusing the inherited name rather than inventing a `document_part`: relaton gets `remove_part!` as a plain `exclude(:part)` with no OMG-specific knowledge, `Renderers::Annotator::TOKENS` already carries `[:part, "part"]` so annotation costs nothing, and there is no second, permanently-nil `part` on every identifier. The **placement** is what makes the retype safe: it sits once on `Pubid::Omg::Identifier`, which every OMG identifier inherits from and whose class body lives in one file (`lib/pubid/omg/identifier.rb`) and is never reopened — the condition the root `CLAUDE.md` states for a base-level declaration. Never move it onto `Identifiers::Specification`, and never add a second declaration there: a redeclaration moves the generated accessor's `owner`, which the retype tripwire specs of other flavors treat as a delegation.
8
+ - **The separator normalizes, and that is a matching decision, not a cosmetic one**: OMG writes the part behind either a space or a slash, so `Parser#part_separator` is `space | str("/")` and the parser takes both. The renderer prints **only a space**, so `OMG DDS 1.4/PDF` is a *normalizing* parse rendering `OMG DDS 1.4 PDF`. The alternative — a `part_separator` sibling attribute in the CSA `year_format` shape — round-trips both spellings byte for byte but makes them **not `==`**, and `#matches?` is `exclude(*ignore) == other.exclude(*ignore)`, so a relaton index lookup between the two spellings would return nothing with no error. That is the silent failure mode the root file records as the costliest here, and it is not worth a separator. `spec/pubid/omg/identifier_spec.rb` asserts the normalization **and** the equality, so a future attempt to preserve the separator turns both red. The slash forms therefore cannot live in `spec/fixtures/omg/pass/`, whose spec demands a byte-exact round-trip; they are pinned in the identifier spec instead, with a comment in the fixture file saying why.
9
+ - **The acronym is the URL segment, so it takes every character OMG puts there — and a slash before the version belongs to it.** A consumer (relaton) builds `https://www.omg.org/spec/<acronym>/` from the parsed acronym, so the acronym must be verbatim. The old rule `[A-Z][A-Za-z0-9]*` rejected **30 of the 270** acronyms the catalog names: 25 with a hyphen (`DDS-XTypes`, `IDL4-CPP`), 4 with a slash (`EDMC-FIBO/BE`), `VSIPL++` and the lower-case `smartant`. `Parser#acronym` now starts with any letter, takes letters, digits and `+`, and joins further non-empty segments with `-` or `/`, so a trailing hyphen or slash is never consumed. **The slash is the design decision.** The document-part bullet above made a slash separate the part, and `OMG EDMC-FIBO/BE` could then read as acronym `EDMC-FIBO` with part `BE` — which builds the URL of the wrong page, silently. The rule now is: **before the version a slash is part of the acronym, after the version it separates the part** (`OMG DDS 1.4/PDF` is unchanged; `OMG EDMC-FIBO/BE 1.1/PDF` reads both). `Parser#identifier` spells that out — a part that follows the acronym directly takes only a space. The cost is that `OMG UML/Superstructure` now reads as acronym `UML/Superstructure`; no spec, fixture or relaton corpus row used that spelling, and OMG writes the space. A fixed list of the four FIBO domains was the alternative and was rejected: it drifts when OMG adds a domain. **Known limit — a two-word title still parses.** The grammar cannot tell an acronym from a word, so `OMG Real-Time Extension` reads as acronym `Real-Time`, part `Extension`. This is not new: on `main` before this change, `OMG Model Driven` already read as acronym `Model`, part `Driven`; the wider charset only adds hyphenated and lower-case first words. A longer title (`OMG Model Driven Architecture Guide rev. 2.0`) still raises, because the grammar has no place for a third word. `spec/pubid/omg/identifier_spec.rb` carries all 30 catalog acronyms as a frozen, network-free list. To re-check against the live catalog (270 acronyms, 0 rejections on 2026-09-14): `curl -s https://www.omg.org/spec/ | ruby -e 'puts STDIN.read.scan(%r{spec/(.+?)/About-[^"]*"}).flatten.uniq'`, then `Pubid::Omg.parse("OMG #{acronym}")` for each. (hand-off: `metanorma__pubid__omg-acronym-charset`.)
10
+ - **The bare `beta` is not optional polish — the document part makes it load-bearing.** `parser.rb` used to demand `" beta "` followed by at least one digit. Once an optional third token is legal, a PEG grammar parses `OMG UML 2.5 beta` as version `2.5` plus a **document part named `beta`** — a silent wrong answer, not a parse failure, and exactly the shape that costs nothing until a consumer compares two identifiers. So the beta number is `.maybe`, and the version rule consumes the label whole. **This is what OMG actually publishes, checked against the source**: `https://www.omg.org/spec/UML/2.5/Beta1/` gives its own version as **`2.5 beta`**, the unnumbered form, while DDS 1.4 supersedes `https://www.omg.org/spec/DDS/1.4/Beta2`. Both spellings are real. The regression guard is the example asserting that `OMG UML 2.5 beta` leaves `part` **nil**; the round-trip example alone would pass either way.
11
+ - **Both halves of the beta label need a word boundary, and the failure mode is a rejection rather than a misparse.** Parslet never backtracks into a `.maybe` that already succeeded. With an unanchored `str(" beta")`, `OMG DDS 1.4 beta2` and `OMG DDS 1.4 betawave` made the version rule commit to `" beta"`, fail to find the beta number, and leave `2`/`wave` with no separator in front of it — so the **whole identifier raised**, not merely parsed oddly. The same trap sits behind the beta number: without a guard, `OMG UML 2.5 beta 1x` consumed `" 1"` and then choked on `x`. `Parser#beta` therefore ends each half with `word_boundary` (`match("[A-Za-z0-9]").absent?`), which costs nothing — a genuine bare `beta` is followed by the end of input or a space, and a genuine beta number by the same. All three inputs now read the tail as a document part. A code review found the first case; the second and third came out of probing the fix. The lesson generalizes to any flavor adding an optional trailing token after an optional literal-suffixed one: **anchor the literal, or the earlier rule eats the later one's first word and the identifier is rejected**.
12
+ - **Known limit — `Beta2` reads as a document part.** OMG's URL spelling glues the label and the number and capitalizes (`.../1.4/Beta2`), so `OMG DDS 1.4 Beta2` parses as version `1.4` with the part `"Beta2"`. It round-trips and it is not wrong enough to chase: no reference in the relaton corpus uses that spelling, the version rule follows the lowercase, space-separated form the relaton regex accepted, and widening it would need a rule that tells `Beta2` from a genuine volume name. Recorded rather than fixed.
13
+ - **`root.number` is nil for every OMG identifier, and so is the MR slug. Neither moved here, and nothing depends on either yet.** OMG models `acronym`/`version`/`part` and never sets the `number` it inherits, so the index key the root file requires of every leaf is empty and `to_mr_string` is `""` for every identifier — both were already true before this branch and both are unchanged by it (the base `mr_number_with_part` reads `number`, not `part`, so adding the part does not populate the slug). OMG has **no index and no `relaton-data-omg`** — it is scraped from `www.omg.org/spec` with Mechanize — and the relaton migration hand-off explicitly forbids adding one, so the binary-search degradation the root file describes cannot bite today. Do not "fix" it by mirroring `acronym` into `number` without checking the renderer first: that is the precondition the BIPM entry names, and the NIST attempt recorded in the root file is what happens when it does not hold.
14
+ - **The fixture corpus is hand-written.** OMG has no `spec/fixtures/omg/identifiers/full/` tree and no entry in `spec/fixtures/classify_fixtures.rb`, so `validation:classify` never rewrites `pass/` or `fail/` and the root file's rule against hand-editing them does not apply. `spec/pubid/omg/fixtures_spec.rb` skips `#` lines, so the files carry comments.
15
+ - **relaton note**: `relaton/relaton` moves its OMG flavor off the hand-written regex at `lib/relaton/omg/scraper.rb:19` onto this flavor. Both blockers it named are fixed. Its probe expected `OMG DDS 1.4/PDF` to round-trip byte for byte; it now sees a normalizing parse, which is harmless there because the flavor reads `acronym` and `version` off the parsed object and keeps the document part out of the request URL. (hand-offs: `metanorma__pubid__omg-document-part-and-bare-beta`, `relaton__relaton__omg-pubid-migration`.)
@@ -18,3 +18,5 @@ module Pubid
18
18
  end
19
19
  end
20
20
  end
21
+
22
+ Pubid::Omg::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -21,10 +21,22 @@ module Pubid
21
21
  # bypasses this and raises a bare `Parslet::ParseFailed`. Nothing in the
22
22
  # gem does that.
23
23
  class Grammar < ::Parslet::Parser
24
+ # A trailing "(all parts)" marks the reference as the whole document.
25
+ # The flavor grammars that carry their own rule consume it inside
26
+ # parslet; this shared strip gives every other flavor the same read:
27
+ # the suffix never reaches the flavor grammar, and the parsed tree
28
+ # carries :all_parts for the builder to wrap (see Builder::Base).
29
+ ALL_PARTS_SUFFIX = "(all parts)".freeze
30
+
24
31
  # @param io [String, IO]
25
32
  # @param options [Hash] passed through to parslet
26
33
  # @raise [Pubid::Errors::ParseError]
27
34
  def parse(io, options = {})
35
+ if io.is_a?(String) && io.end_with?(ALL_PARTS_SUFFIX)
36
+ base = io.sub(/\s*\(all parts\)\s*\z/, "")
37
+ return mark_all_parts(super(base, options))
38
+ end
39
+
28
40
  super
29
41
  rescue ::Pubid::Errors::ParseError
30
42
  # A nested grammar already wrapped it. Keep the inner flavor and input.
@@ -35,6 +47,17 @@ module Pubid
35
47
 
36
48
  private
37
49
 
50
+ # Carry the stripped suffix into the tree. Parslet tops are a Hash or
51
+ # an Array of Hashes; the marker joins either shape, and every builder
52
+ # (Builder::Base and the standalone ones) routes it to #to_all_parts.
53
+ def mark_all_parts(tree)
54
+ case tree
55
+ when Hash then tree.merge(all_parts: true)
56
+ when Array then tree.map { |t| t.merge(all_parts: true) }
57
+ else tree
58
+ end
59
+ end
60
+
38
61
  # @param error [Parslet::ParseFailed]
39
62
  # @param io [String, IO] what was handed to {#parse}
40
63
  # @return [Pubid::Errors::ParseError]
@@ -52,3 +52,5 @@ module Pubid
52
52
  end
53
53
  end
54
54
  end
55
+
56
+ Pubid::Plateau::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -14,6 +14,10 @@ module Pubid
14
14
  attribute :number, :integer
15
15
  attribute :annex, :integer, default: -> {}
16
16
 
17
+ # A nil `annex` means the document has none: PLATEAU Handbook #10 is
18
+ # not its annex, PLATEAU Handbook #10-1.
19
+ subset_strict :annex
20
+
17
21
  # Stored as a plain string (always "PLATEAU") so it round-trips through
18
22
  # to_hash/from_hash. Was a `def publisher` method, which made lutaml
19
23
  # serialize a String against the Components::Publisher attribute.
@@ -9,9 +9,16 @@ module Pubid
9
9
  class SupplementIdentifier < Pubid::Identifier
10
10
  attribute :base, Identifier
11
11
  attribute :letter, :string, default: -> {}
12
+ # Stored as a plain string (always "PLATEAU") so it round-trips through
13
+ # to_hash/from_hash. Was a `def publisher` method, which made lutaml
14
+ # serialize a String against the Components::Publisher attribute
15
+ # (pubid/pubid#407) — the same fix as Identifiers::Base.
16
+ attribute :publisher, :string, default: -> { "PLATEAU" }
12
17
 
13
- def publisher
14
- "PLATEAU"
18
+ # The UrnGenerator reads type_string on every identifier; the annex
19
+ # supplement's own type makes its "an" URN branch reachable.
20
+ def type_string
21
+ "Annex"
15
22
  end
16
23
 
17
24
  # Subclasses must implement supplement_string
@@ -22,6 +29,11 @@ module Pubid
22
29
  # Override base_hash to extract edition, type, and annex from base
23
30
  def base_hash
24
31
  hash = super
32
+ # The base document's number: without it from_hash cannot
33
+ # reconstruct the wrapped identifier (pubid/pubid#407).
34
+ if base.class.attributes.key?(:number) && base.number
35
+ hash[:number] = base.number
36
+ end
25
37
  # For Plateau supplements, edition comes from the base identifier
26
38
  if base.class.attributes.key?(:edition) && base.edition
27
39
  hash[:edition] = base.edition
@@ -20,7 +20,13 @@ module Pubid
20
20
 
21
21
  parts << format("%02d", identifier.number) if identifier.number
22
22
 
23
- parts << format("%02d", identifier.annex) if identifier.annex
23
+ if identifier.class.attributes.key?(:annex)
24
+ parts << format("%02d", identifier.annex) if identifier.annex
25
+ # Annex supplements carry a letter, not an annex number
26
+ # (pubid/pubid#407).
27
+ elsif identifier.class.attributes.key?(:letter) && identifier.letter
28
+ parts << identifier.letter.to_s.downcase
29
+ end
24
30
 
25
31
  parts.join(":")
26
32
  end
@@ -37,7 +37,9 @@ module Pubid
37
37
  abbr = annotate(abbr, typed_stage_css(@id.typed_stage),
38
38
  annotated: ann)
39
39
  end
40
- subgroup_str = @id.subgroup.render(context:) if @id.subgroup
40
+ if @id.subgroup
41
+ subgroup_str = render_component(@id.subgroup, context)
42
+ end
41
43
 
42
44
  [
43
45
  pub_str,
@@ -12,7 +12,6 @@ module Pubid
12
12
  parts << render_edition_portion(context) if with_edition
13
13
  result = parts.compact.join(" ")
14
14
  result << render_language_portion(context, with_edition: with_edition)
15
- result << " (all parts)" if @id.all_parts
16
15
  result
17
16
  end
18
17
 
@@ -20,7 +20,7 @@ module Pubid
20
20
  when :type
21
21
  Components::Type.new(abbr: value.to_s)
22
22
  when :number
23
- Components::Code.new(value: value.to_s)
23
+ value.to_s
24
24
  when :year
25
25
  { date: Components::Date.new(year: value.to_i) }
26
26
  else
@@ -30,3 +30,5 @@ module Pubid
30
30
  end
31
31
  end
32
32
  end
33
+
34
+ Pubid::Sae::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -8,6 +8,8 @@ module Pubid
8
8
  # Date component for SAE standards
9
9
  # SAE uses year only (e.g., 2024, 2022)
10
10
  class Date < Lutaml::Model::Serializable
11
+ include ::Pubid::SubsetMatch
12
+
11
13
  attribute :year, :integer
12
14
 
13
15
  def present?
@@ -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
@@ -22,7 +22,10 @@ module Pubid
22
22
 
23
23
  attribute :publisher, :string, default: -> { "SAE" }
24
24
  attribute :type, Sae::Components::Type
25
- attribute :number, Sae::Components::Code
25
+ # A plain :string, like every converted flavor. Sae::Components::Code was
26
+ # a bare alias of the shared component and never carried more than
27
+ # `value`, so this is a representation change only.
28
+ attribute :number, :string
26
29
  attribute :date, Sae::Components::Date
27
30
 
28
31
  def self.type
@@ -12,7 +12,7 @@ module Pubid
12
12
  def urn_number
13
13
  return nil unless identifier.number
14
14
 
15
- identifier.number.render(context: URN_CONTEXT)
15
+ identifier.number.to_s
16
16
  end
17
17
 
18
18
  def generate
@@ -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.11"
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"