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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +43 -1
  3. data/data/ieee/update_codes.yaml +17 -4
  4. data/data/nist/update_codes.yaml +7 -3
  5. data/data/parg/tables/bipm_groups.yaml +14 -0
  6. data/data/parg/tables/bipm_type_codes.yaml +5 -0
  7. data/data/parg/tables/bipm_type_names_en.yaml +6 -0
  8. data/data/parg/tables/bipm_type_names_fr.yaml +6 -0
  9. data/data/parg/tables/directives_supplements_typed_stages.yaml +3 -0
  10. data/data/parg/tables/directives_typed_stages.yaml +5 -0
  11. data/data/parg/tables/idf_typed_stages.yaml +27 -0
  12. data/data/parg/tables/idf_typed_stages_supplements.yaml +2 -0
  13. data/data/parg/tables/iec_typed_stages.yaml +130 -0
  14. data/data/parg/tables/iso_publishers.yaml +4 -0
  15. data/data/parg/tables/organizations.yaml +12 -0
  16. data/data/parg/tables/tc_types.yaml +42 -0
  17. data/data/parg/tables/typed_stages.yaml +114 -0
  18. data/data/parg/tables/typed_stages_supplements.yaml +64 -0
  19. data/data/parg/tables/wg_types.yaml +21 -0
  20. data/lib/pubid/adobe/builder.rb +2 -0
  21. data/lib/pubid/adobe/identifier.rb +11 -1
  22. data/lib/pubid/all_parts.rb +201 -0
  23. data/lib/pubid/all_parts_identifier.rb +19 -0
  24. data/lib/pubid/amca/CLAUDE.md +47 -0
  25. data/lib/pubid/amca/builder.rb +3 -5
  26. data/lib/pubid/amca/identifiers/base.rb +11 -1
  27. data/lib/pubid/amca/identifiers/publication.rb +13 -0
  28. data/lib/pubid/amca/parser.rb +2 -1
  29. data/lib/pubid/amca/renderer.rb +22 -33
  30. data/lib/pubid/amca/urn_generator.rb +21 -2
  31. data/lib/pubid/amca/urn_parser.rb +36 -10
  32. data/lib/pubid/ansi/builder.rb +6 -0
  33. data/lib/pubid/ansi/identifier.rb +1 -1
  34. data/lib/pubid/api/CLAUDE.md +23 -0
  35. data/lib/pubid/api/builder.rb +2 -0
  36. data/lib/pubid/api/identifier.rb +1 -1
  37. data/lib/pubid/api/parser.rb +8 -4
  38. data/lib/pubid/ashrae/CLAUDE.md +13 -0
  39. data/lib/pubid/ashrae/builder.rb +58 -14
  40. data/lib/pubid/ashrae/identifiers/base.rb +10 -1
  41. data/lib/pubid/ashrae/identifiers/errata.rb +14 -2
  42. data/lib/pubid/ashrae/identifiers/interpretation.rb +2 -10
  43. data/lib/pubid/ashrae/parser.rb +80 -39
  44. data/lib/pubid/ashrae/renderer.rb +32 -1
  45. data/lib/pubid/ashrae/urn_generator.rb +32 -9
  46. data/lib/pubid/asme/CLAUDE.md +25 -0
  47. data/lib/pubid/asme/builder.rb +16 -9
  48. data/lib/pubid/asme/components/code.rb +2 -0
  49. data/lib/pubid/asme/identifier.rb +1 -1
  50. data/lib/pubid/asme/identifiers/standard.rb +6 -1
  51. data/lib/pubid/asme/parser.rb +41 -14
  52. data/lib/pubid/astm/CLAUDE.md +9 -0
  53. data/lib/pubid/astm/builder.rb +2 -0
  54. data/lib/pubid/astm/components/code.rb +2 -0
  55. data/lib/pubid/astm/identifier.rb +1 -1
  56. data/lib/pubid/astm/parser.rb +4 -1
  57. data/lib/pubid/bipm/CLAUDE.md +11 -0
  58. data/lib/pubid/bipm/builder.rb +2 -0
  59. data/lib/pubid/bipm/identifier.rb +1 -1
  60. data/lib/pubid/bsi/CLAUDE.md +93 -0
  61. data/lib/pubid/bsi/builder.rb +13 -11
  62. data/lib/pubid/bsi/identifiers/addendum_document.rb +2 -0
  63. data/lib/pubid/bsi/identifiers/adopted_european_norm.rb +6 -54
  64. data/lib/pubid/bsi/identifiers/adopted_international_standard.rb +5 -22
  65. data/lib/pubid/bsi/identifiers/amendment.rb +36 -12
  66. data/lib/pubid/bsi/identifiers/bundled_identifier.rb +2 -0
  67. data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +23 -26
  68. data/lib/pubid/bsi/identifiers/corrigendum.rb +29 -12
  69. data/lib/pubid/bsi/identifiers/expert_commentary.rb +6 -7
  70. data/lib/pubid/bsi/identifiers/national_annex.rb +18 -20
  71. data/lib/pubid/bsi/identifiers/root_identity.rb +31 -0
  72. data/lib/pubid/bsi/identifiers/set.rb +2 -0
  73. data/lib/pubid/bsi/identifiers/supplement_document.rb +2 -0
  74. data/lib/pubid/bsi/identifiers.rb +1 -0
  75. data/lib/pubid/bsi/parser.rb +8 -8
  76. data/lib/pubid/bsi/renderer.rb +20 -20
  77. data/lib/pubid/bsi/single_identifier.rb +1 -3
  78. data/lib/pubid/bsi/urn_generator.rb +28 -18
  79. data/lib/pubid/builder/base.rb +27 -0
  80. data/lib/pubid/calconnect/builder.rb +2 -0
  81. data/lib/pubid/calconnect/identifier.rb +5 -1
  82. data/lib/pubid/ccsds/builder.rb +2 -0
  83. data/lib/pubid/ccsds/identifier.rb +9 -1
  84. data/lib/pubid/cen_cenelec/CLAUDE.md +59 -0
  85. data/lib/pubid/cen_cenelec/builder.rb +6 -1
  86. data/lib/pubid/cen_cenelec/identifier.rb +12 -28
  87. data/lib/pubid/cen_cenelec/identifiers/amendment.rb +3 -10
  88. data/lib/pubid/cen_cenelec/identifiers/corrigendum.rb +3 -10
  89. data/lib/pubid/cen_cenelec/parser.rb +20 -5
  90. data/lib/pubid/cie/CLAUDE.md +58 -0
  91. data/lib/pubid/cie/builder.rb +2 -0
  92. data/lib/pubid/cie/components/language.rb +2 -0
  93. data/lib/pubid/cie/identifier.rb +1 -1
  94. data/lib/pubid/cie/parser.rb +9 -2
  95. data/lib/pubid/components/adoption.rb +2 -0
  96. data/lib/pubid/components/code.rb +2 -0
  97. data/lib/pubid/components/date.rb +8 -6
  98. data/lib/pubid/components/edition.rb +2 -0
  99. data/lib/pubid/components/iteration.rb +2 -0
  100. data/lib/pubid/components/language.rb +2 -0
  101. data/lib/pubid/components/locality.rb +2 -0
  102. data/lib/pubid/components/publisher.rb +2 -0
  103. data/lib/pubid/components/relationship.rb +2 -0
  104. data/lib/pubid/components/stage.rb +2 -0
  105. data/lib/pubid/components/supplement.rb +2 -0
  106. data/lib/pubid/components/type.rb +2 -0
  107. data/lib/pubid/components/typed_stage.rb +8 -0
  108. data/lib/pubid/conformance/checks.rb +1 -1
  109. data/lib/pubid/csa/CLAUDE.md +41 -0
  110. data/lib/pubid/csa/builder.rb +2 -0
  111. data/lib/pubid/csa/identifier.rb +19 -3
  112. data/lib/pubid/csa/parser.rb +25 -8
  113. data/lib/pubid/csa/renderer.rb +12 -12
  114. data/lib/pubid/csa/single_identifier.rb +17 -0
  115. data/lib/pubid/doi/builder.rb +2 -0
  116. data/lib/pubid/doi/identifier.rb +1 -1
  117. data/lib/pubid/easc/builder.rb +2 -0
  118. data/lib/pubid/easc/identifier.rb +10 -1
  119. data/lib/pubid/ecma/CLAUDE.md +28 -0
  120. data/lib/pubid/ecma/builder.rb +2 -0
  121. data/lib/pubid/ecma/identifier.rb +8 -1
  122. data/lib/pubid/etsi/CLAUDE.md +34 -0
  123. data/lib/pubid/etsi/builder.rb +2 -0
  124. data/lib/pubid/etsi/components/code.rb +6 -0
  125. data/lib/pubid/etsi/components/version.rb +2 -0
  126. data/lib/pubid/etsi/identifiers/base.rb +1 -1
  127. data/lib/pubid/etsi/identifiers/etsi_standard.rb +7 -0
  128. data/lib/pubid/evs/CLAUDE.md +58 -0
  129. data/lib/pubid/evs/builder.rb +2 -0
  130. data/lib/pubid/evs.rb +1 -1
  131. data/lib/pubid/gb/CLAUDE.md +140 -0
  132. data/lib/pubid/gb/builder.rb +7 -2
  133. data/lib/pubid/gb/identifier.rb +6 -4
  134. data/lib/pubid/gb/identifiers/all_parts.rb +17 -0
  135. data/lib/pubid/gb/identifiers.rb +1 -0
  136. data/lib/pubid/gb/renderer.rb +0 -1
  137. data/lib/pubid/gost/CLAUDE.md +64 -0
  138. data/lib/pubid/gost/builder.rb +3 -1
  139. data/lib/pubid/gost/identifier.rb +16 -1
  140. data/lib/pubid/gost/parser.rb +8 -1
  141. data/lib/pubid/iala/CLAUDE.md +82 -0
  142. data/lib/pubid/iala/builder.rb +2 -0
  143. data/lib/pubid/iala/identifier.rb +10 -1
  144. data/lib/pubid/iana/CLAUDE.md +7 -0
  145. data/lib/pubid/iana/builder.rb +2 -0
  146. data/lib/pubid/iana/identifier.rb +1 -1
  147. data/lib/pubid/identifier.rb +161 -17
  148. data/lib/pubid/idf/builder.rb +11 -1
  149. data/lib/pubid/idf/identifier.rb +5 -0
  150. data/lib/pubid/idf/identifiers/all_parts.rb +17 -0
  151. data/lib/pubid/idf/identifiers.rb +1 -0
  152. data/lib/pubid/iec/CLAUDE.md +31 -0
  153. data/lib/pubid/iec/builder.rb +7 -1
  154. data/lib/pubid/iec/components/consolidated_amendment.rb +4 -0
  155. data/lib/pubid/iec/components/sheet.rb +2 -0
  156. data/lib/pubid/iec/components/trf_info.rb +2 -0
  157. data/lib/pubid/iec/components/vap_suffix.rb +2 -0
  158. data/lib/pubid/iec/identifier.rb +8 -3
  159. data/lib/pubid/iec/identifiers/all_parts.rb +19 -0
  160. data/lib/pubid/iec/identifiers.rb +1 -0
  161. data/lib/pubid/iec/parser.rb +9 -4
  162. data/lib/pubid/iec/renderer.rb +0 -1
  163. data/lib/pubid/iec/urn_generator.rb +9 -1
  164. data/lib/pubid/iec/urn_parser.rb +3 -2
  165. data/lib/pubid/ieee/CLAUDE.md +97 -0
  166. data/lib/pubid/ieee/builder.rb +194 -27
  167. data/lib/pubid/ieee/components/code.rb +2 -0
  168. data/lib/pubid/ieee/components/draft.rb +35 -2
  169. data/lib/pubid/ieee/components/typed_stage.rb +2 -0
  170. data/lib/pubid/ieee/identifiers/base.rb +21 -1
  171. data/lib/pubid/ieee/identifiers/iec_ieee_copublished.rb +9 -0
  172. data/lib/pubid/ieee/identifiers/joint_development.rb +66 -23
  173. data/lib/pubid/ieee/identifiers/project_draft_identifier.rb +8 -1
  174. data/lib/pubid/ieee/parser.rb +156 -29
  175. data/lib/pubid/ieee/renderer.rb +42 -7
  176. data/lib/pubid/ieee/urn_generator.rb +31 -0
  177. data/lib/pubid/ietf/CLAUDE.md +7 -0
  178. data/lib/pubid/ietf/builder.rb +2 -0
  179. data/lib/pubid/ietf/identifiers/base.rb +1 -1
  180. data/lib/pubid/iho/builder.rb +2 -0
  181. data/lib/pubid/isbn/builder.rb +2 -0
  182. data/lib/pubid/isbn/identifier.rb +1 -1
  183. data/lib/pubid/iso/CLAUDE.md +47 -0
  184. data/lib/pubid/iso/builder.rb +19 -5
  185. data/lib/pubid/iso/components/publisher.rb +2 -0
  186. data/lib/pubid/iso/identifier.rb +10 -15
  187. data/lib/pubid/iso/identifiers/all_parts.rb +19 -0
  188. data/lib/pubid/iso/identifiers/directives_supplement.rb +4 -2
  189. data/lib/pubid/iso/identifiers.rb +1 -0
  190. data/lib/pubid/iso/normalizer.rb +4 -1
  191. data/lib/pubid/iso/rendering_style.rb +0 -1
  192. data/lib/pubid/itu/CLAUDE.md +115 -0
  193. data/lib/pubid/itu/builder.rb +26 -4
  194. data/lib/pubid/itu/components/code.rb +2 -0
  195. data/lib/pubid/itu/components/designation.rb +2 -0
  196. data/lib/pubid/itu/components/sector.rb +2 -0
  197. data/lib/pubid/itu/components/series.rb +2 -0
  198. data/lib/pubid/itu/identifiers/base.rb +11 -18
  199. data/lib/pubid/itu/identifiers/radio_regulations.rb +27 -0
  200. data/lib/pubid/itu/identifiers/special_publication.rb +48 -14
  201. data/lib/pubid/itu/identifiers/standard_serialization.rb +2 -0
  202. data/lib/pubid/itu/identifiers/supplement.rb +15 -0
  203. data/lib/pubid/itu/identifiers.rb +1 -0
  204. data/lib/pubid/itu/parser.rb +108 -22
  205. data/lib/pubid/itu/urn_generator.rb +9 -2
  206. data/lib/pubid/jcgm/CLAUDE.md +7 -0
  207. data/lib/pubid/jcgm/builder.rb +2 -0
  208. data/lib/pubid/jcgm/components/publisher.rb +2 -0
  209. data/lib/pubid/jcgm.rb +1 -1
  210. data/lib/pubid/jis/builder.rb +5 -1
  211. data/lib/pubid/jis/identifier.rb +6 -18
  212. data/lib/pubid/jis/identifiers/all_parts.rb +19 -0
  213. data/lib/pubid/jis/identifiers.rb +1 -0
  214. data/lib/pubid/jis/renderer.rb +0 -2
  215. data/lib/pubid/jis/urn_generator.rb +0 -1
  216. data/lib/pubid/nist/CLAUDE.md +56 -0
  217. data/lib/pubid/nist/builder.rb +3 -0
  218. data/lib/pubid/nist/components/edition.rb +2 -0
  219. data/lib/pubid/nist/components/issue_number.rb +2 -0
  220. data/lib/pubid/nist/components/part.rb +2 -0
  221. data/lib/pubid/nist/components/stage.rb +2 -0
  222. data/lib/pubid/nist/components/supplement.rb +2 -0
  223. data/lib/pubid/nist/components/translation.rb +2 -0
  224. data/lib/pubid/nist/components/update.rb +2 -0
  225. data/lib/pubid/nist/components/version.rb +2 -0
  226. data/lib/pubid/nist/components/volume.rb +2 -0
  227. data/lib/pubid/nist/identifiers/base.rb +39 -7
  228. data/lib/pubid/nist/parser.rb +24 -2
  229. data/lib/pubid/nist/preprocessor.rb +53 -2
  230. data/lib/pubid/nist/urn_parser.rb +10 -1
  231. data/lib/pubid/oasis/CLAUDE.md +19 -0
  232. data/lib/pubid/oasis/builder.rb +2 -0
  233. data/lib/pubid/oasis/identifier.rb +20 -1
  234. data/lib/pubid/ogc/CLAUDE.md +34 -0
  235. data/lib/pubid/ogc/builder.rb +2 -0
  236. data/lib/pubid/ogc/identifier.rb +12 -1
  237. data/lib/pubid/oiml/CLAUDE.md +189 -0
  238. data/lib/pubid/oiml/builder.rb +20 -0
  239. data/lib/pubid/oiml/components/code.rb +6 -0
  240. data/lib/pubid/oiml/identifier.rb +13 -0
  241. data/lib/pubid/oiml/identifiers/annex.rb +4 -0
  242. data/lib/pubid/oiml/identifiers/certification_system.rb +34 -0
  243. data/lib/pubid/oiml/identifiers/code_number.rb +8 -0
  244. data/lib/pubid/oiml/identifiers/dual_published.rb +174 -0
  245. data/lib/pubid/oiml/identifiers.rb +2 -0
  246. data/lib/pubid/oiml/parser.rb +35 -4
  247. data/lib/pubid/oiml/renderer.rb +23 -1
  248. data/lib/pubid/oiml/single_identifier.rb +4 -0
  249. data/lib/pubid/oiml/supplement_identifier.rb +7 -0
  250. data/lib/pubid/oiml/urn_generator.rb +28 -0
  251. data/lib/pubid/oiml.rb +6 -1
  252. data/lib/pubid/omg/CLAUDE.md +15 -0
  253. data/lib/pubid/omg/builder.rb +2 -0
  254. data/lib/pubid/omg/identifier.rb +1 -1
  255. data/lib/pubid/parg/artifact.rb +46 -0
  256. data/lib/pubid/parg/backend.rb +92 -0
  257. data/lib/pubid/parg.rb +8 -0
  258. data/lib/pubid/parser/grammar.rb +23 -0
  259. data/lib/pubid/pg.rb +8 -0
  260. data/lib/pubid/plateau/builder.rb +2 -0
  261. data/lib/pubid/plateau/identifiers/base.rb +4 -0
  262. data/lib/pubid/plateau/supplement_identifier.rb +14 -2
  263. data/lib/pubid/plateau/urn_generator.rb +7 -1
  264. data/lib/pubid/plateau.rb +1 -2
  265. data/lib/pubid/renderers/human_readable.rb +0 -1
  266. data/lib/pubid/sae/builder.rb +2 -0
  267. data/lib/pubid/sae/components/date.rb +2 -0
  268. data/lib/pubid/sae/components/type.rb +2 -0
  269. data/lib/pubid/sae/identifiers/base.rb +1 -1
  270. data/lib/pubid/subset_match.rb +197 -0
  271. data/lib/pubid/tgpp/CLAUDE.md +43 -0
  272. data/lib/pubid/tgpp/builder.rb +2 -0
  273. data/lib/pubid/tgpp/identifier.rb +15 -1
  274. data/lib/pubid/type_resolver.rb +14 -2
  275. data/lib/pubid/un/builder.rb +2 -0
  276. data/lib/pubid/un/identifier.rb +1 -1
  277. data/lib/pubid/version.rb +1 -1
  278. data/lib/pubid/w3c/CLAUDE.md +7 -0
  279. data/lib/pubid/w3c/builder.rb +2 -0
  280. data/lib/pubid/w3c/identifier.rb +1 -1
  281. data/lib/pubid/xsf/CLAUDE.md +11 -0
  282. data/lib/pubid/xsf/builder.rb +2 -0
  283. data/lib/pubid/xsf/identifier.rb +1 -1
  284. data/lib/pubid.rb +17 -3
  285. metadata +78 -2
@@ -0,0 +1,13 @@
1
+ # ASHRAE flavor notes
2
+
3
+ ASHRAE index key, the identifiers that lost their number, the errata date, and interpretations.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/ashrae/` or `spec/pubid/ashrae/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **ASHRAE index key (`root.number`), and the 246 identifiers that were losing their number**: ASHRAE kept its document code in a `code` :string on the shared base and never set the inherited `number`, so all 2,103 fixture identifiers keyed `""`. Worse, **246 of them had no number to key on**: they parsed *successfully* and rendered as nonsense — 160 Errata as the identical `"ASHRAE Standard Errata"` (note the double space, the BIPM `Declaration` corruption signature), the addendum forms as `"ASHRAE Standard "` or `"… to ASHRAE Standard -2024"`. Triaged over the whole corpus, that was **not one bug but three**, and the hand-off's guess ("the parser discards the number") was wrong about the largest: **(1) The BUILDER, 223 ids — the real cause.** The grammar captured everything; the parse tree for an erratum is `{base: {publisher, type, code, year}, errata_keyword: …}`. But `build_errata` and the addendum builders passed the **whole** parse hash to `#extract_base_attributes`, which reads `parsed[:publisher]`/`[:type]`/`[:code]` — all nil, because they sit under `parsed[:base]`. `Builder#build` unwraps at its own entry (`if parsed_hash[:base]`); the five other call sites never did. Fixed **inside `extract_base_attributes`**, which covers all five at once rather than repeating the unwrap. Two grammar rules — `:addendum` and `:publisher_base_addendum` — additionally had **no dispatch branch at all**, so they fell through to the plain-identifier path, which looks for publisher/type/code at the top of the hash, found only the wrapper key, and built an empty Standard (63 of the 223, which were additionally the WRONG CLASS — a bare `Standard` where an `Addendum` was meant). Both subtrees are flat and carry exactly what `build_addendum_from_identifier` already reads. **(2) The GRAMMAR, 23 ids — one missing capture.** `rule(:code_with_year)` matched the code digits and then **discarded** them: only `year_digits.as(:year)` was named. Wrapping the code portion in `.as(:code)` is the whole fix, and the builder was already reading `code_with_year[:code]` — it had been waiting for a value the grammar never produced. **(3) The index key.** `attribute :code, :string` moved off `Pubid::Ashrae::Identifier` and became `attribute :number, :string` on the **two single-document leaves** (`Standard`, `Guideline`). Leaf placement is load-bearing in the CSA counter-shape here: that base is inherited by **both** `SingleIdentifier` and `SupplementIdentifier`. The five supplement types carry **no number of their own** and reach the key through `#root`, which walks `base` — so `UrnGenerator#urn_number` needed an `identifier.number || identifier.root.number` fallback, without which every supplement URN silently lost its number when the attribute moved. **Landmine when renaming `code` → `number` in a flavor whose builder reads Parslet output: the builder holds BOTH attribute keys and parse-TREE keys under the same name.** A blanket `s/\bcode\b/number/` through `builder.rb` renames `parsed[:code]` too and breaks every lookup; the rename must be surgical (`attributes[:code]`, `base_attrs[:code]`, `code: extract_value`) while `parsed[:code]` / `code_year_data[:code]` stay. (`renderer.rb` and `urn_generator.rb` read only attributes, so a blanket rename is safe there.) Also note **BSD `sed` does not support `\b`** — an `sed -i '' 's/\bcode\b/…/'` on macOS silently changes nothing; use `perl -pi -e`. **(4) The MR slug, the worst collapse measured in the gem: ALL 2,103 ids shared the single slug `"ashrae"`.** Every base hook looked for something ASHRAE does not use (`mr_type` a typed_stage, `mr_year` a `Components::Date`), and with `number` nil too there was nothing left. The base now supplies `mr_type`/`mr_year`/`mr_publisher`/`mr_number_with_part`/`mr_sanitize`, and the four **reachable** supplement types define `mr_supplement_suffix` so `Renderers::MrString` recurses into `base` instead of slugging flat. Every identity-bearing marker was chased in until distinct identifiers got distinct slugs: `type` (Guideline 0 ≠ Standard 0), `copublisher` (`ANSI/ASHRAE` ≠ `ASHRAE` — their hashes differ, so their filenames must), `reaffirmed` (`RA 2017`), `suffix` (`R`/`P`), and the addendum letters. `mr_sanitize` filters **by charset** (the BIPM precedent): an ASHRAE code carries commas (`90A,B,C`) and the copublisher a slash, both outside the `[a-z0-9.-]` charset `MrString` documents. Result: **2,062 distinct slugs for 2,103 ids, 0 unsafe characters, 0 clashes between distinct identifiers** (the 41 collapses are pairs whose hashes are genuinely equal). **Verified** by replaying a baseline captured on `main` over every real fixture input: `to_s` **shrank for 0 of 2,103** (every rendering only gained content — the right invariant for a repair, stronger than byte-identity), 0 empty `root.number` (was 2,103), 0 renderings with a double space (was 160) or without a digit (was 223), 0 round-trip failures, and `to_urn` changed for exactly the 246 repaired ids (243 gained their number; 3 replaced the garbage `urn:ashrae:standard`, which carried only a wrongly-defaulted type, with `urn:ashrae:28`). **COUNT IN IDENTIFIERS, NOT FIXTURE LINES**: the first revision of this entry said 3,619 and 489, because a `pass/` line for a normalizing parse is written `!input!rendered` and the ASHRAE grammar is loose enough to *parse* that doubled string — so 1,518 of them were counted, and swept, as identifiers. `spec/pubid/ashrae/root_number_spec.rb` now splits the marker. **Two review findings worth keeping.** The `code` -> `number` rename left `SupplementIdentifier#code` (`def code; base&.code; end`) pointing at a method that no longer exists, so it **raised NoMethodError on every supplement instance** — and nothing inside `lib/pubid/ashrae` calls it, which is exactly why the whole suite stayed green while a public method on five types was broken. Its body is now `base&.number`, and **the method is deliberately still named `code`**: renaming it to `number` looks tidier and is wrong twice over — `number` is an inherited lutaml attribute typed `Components::Code`, so a String-returning method of that name makes `to_hash` raise `IncorrectModelError` on every supplement, and even typed correctly it would serialize a duplicate of the base's number onto the wrapper. (The same trap as the ASTM `Adjunct#number` above; `code` is free to be a plain reader precisely because it is not an attribute anywhere.) Second, `mr_number_with_part` now goes through the shared `#mr_join` rather than a bare `compact.join("-")`, so it degrades to nil rather than `""`. **Three known gaps, all pre-existing and all pinned by specs asserting the CURRENT behaviour, so a fix trips them rather than passing silently.** **(a) Interpretations — FIXED since (see the interpretation bullet below).** An `"Interpretations for Standard 15.2-2022"` reference builds a plain `Standard` **identical** to the standard it interprets (same class, `to_s`, hash, URN and slug), and all 51 corpus rows already behaved this way on `main`. **The cause is NOT alternation ordering** — the first revision of this entry said it was. `rule(:interpretation_identifier)` *is* reached and *does* match (dump the tree: `{base: {type: "Standard", code: "15.2", year: "2022"}}`, prefix consumed); what it never does is **tag** itself, having no `.as(:interpretation_identifier)` wrapper — so `Builder#build`'s `elsif parsed_hash[:interpretation_identifier]` is unreachable dead code and the tree falls through to the plain path. `Identifiers::Interpretation` is therefore unreachable, which is why it alone got no `mr_supplement_suffix`. **(b) The errata date — FIXED since (see the errata-date bullet below).** `Addendum#addendum_date` still has the always-nil extractor (making `render_addendum`'s date branch dead), and `AddendaPackage`'s `package_year`/`target_year` and `CombinedAddenda`'s `connector` are still captured by the grammar and never read by the builder. **(c) The supplement URN — FIXED since (see the supplement-URN bullet below).** **Also stale, deliberately**: `spec/fixtures/ashrae/identifiers/pass/*.txt` was generated on `main`, so it still records the corrupt renderings as expected output (`!… Errata (September 28, 2011) (PDF)!ASHRAE Standard Errata`). Inert while `fixtures_spec.rb`'s glob is dead, but it will fail the moment that glob is fixed; regenerate with `bundle exec rake "validation:classify[ashrae]"`, which rewrites `pass/` and `fail/` wholesale (the CSA precedent above). Locked by `spec/pubid/ashrae/root_number_spec.rb` — structural tripwire (the two numbered leaves resolve `number` to `String`, the three inherited-from classes still resolve to `Components::Code`, and the supplements deliberately have no number of their own), a recovered-identifier table naming one example per defect shape, and a corpus sweep asserting non-empty keys, no double space, at least one digit, round-trip, and slug distinctness. **That sweep is ASHRAE's only fixture net**: `spec/pubid/ashrae/fixtures_spec.rb` globs `../../../fixtures/ASHRAE/…` and reports 0 examples (hand-off `ten-dead-fixture-specs`). (hand-off: ashrae-parser-drops-the-number; follow-ups: ashrae-interpretation-collapses-onto-base, ashrae-errata-date-dropped, ashrae-supplement-urn-collapse.)
8
+
9
+ - **The errata date is captured, because ASHRAE identifies an erratum by its date.** `ASHRAE Guideline 14-2002 Errata (October 10, 2008)` and `… (October 20, 2008)` are two documents with different content (the October 10 sheet applies to all copies; the October 20 sheet applies only to the downloaded PDF). Before this change they had one `to_s`, `to_hash`, `==` and slug. **There were three causes, and the hand-off named only two.** The builder stub returned nil, and the rule named only `:errata_year`. The third cause is the one to remember: `rule(:errata_date)` started with `lparen` and had **no leading space**, so after `Errata` it never matched, and `optional_suffix` (a generic `" (…)"` eater) took the date instead. The parse tree showed it: `optional_suffixes: " (October 10, 2008)"`. **A generic suffix rule after an optional capture hides a capture that does not match**: the parse still succeeds, so no test fails. The rule now starts with `space.maybe` and names `:month`, `:day`, `:errata_year` and `:numeric_date`. The three `errata_identifier` branches now share one `rule(:errata_date_and_suffix)`: the plain-publisher branch had only the date-first order, so `ASHRAE Standard 55-2004 Errata – Spanish Edition (June 2, 2010)` lost its date the same way (a code review found it; no corpus row has that shape). **The date lives in the `date` component `Errata` inherits, not in a flavor-named attribute.** The first version of this fix (PR #405) declared `attribute :errata_date, :string`. That is the GB shape: two attributes describing one value, and the flavor-named one is invisible to every shared surface, which reads `date`. `Builder#extract_errata_date` now returns a `Components::Date`, so one date has one value whatever the input spelled — `October 10, 2008`, `October 10,2008` and `10-10-2008` all give `year: "2008", month: "10", day: "10"`. **The month and the day are padded to two digits**, so two identifiers cannot differ by a leading zero; `Renderer#long_date` inverts the mapping and prints the day unpadded, in the long form the ASHRAE PDF headers use. `to_s` is byte-identical to the string form for every corpus row, and a numeric date now prints as `(July 17, 2003)`. The slug takes the sortable form: `ashrae.guideline.14.2002_errata.2008-10-10`. **One override was needed, and it is the price of using the shared name**: `UrnGenerator::Base#urn_year` reads `date` first, so an erratum would have emitted `urn:ashrae:14:2008` — the erratum year in the slot that holds the edition year of the standard. `Ashrae::UrnGenerator#urn_year` reads the `year` attribute only, and every ASHRAE URN stays as it was. A second consequence is intended and recorded in the spec: the base `#exclude` adds `:date` to `:year`, so `matches?(other, ignore: [:year])` now ignores the erratum date as well. **Measured** by a replay of all 2,103 inputs in `spec/fixtures/ashrae/identifiers/full/` against a `main` baseline: exactly the 160 `Errata` rows changed `to_s`, `to_hash` and slug; every changed `to_s` only gained the date; the class and `to_urn` did not change for any row; `from_hash(to_hash) == id` holds for all. **Not changed, on purpose:** the URN, which had no supplement marker at the time (fixed since — see the supplement-URN bullet below); the 34 `Addendum … Errata (date)` inputs, which parse through `errata_suffix_on_addendum` and build a plain `Addendum` without the errata; and the `– Spanish Edition` errata suffix, which is still dropped. **A bare `ASHRAE Guideline 14-2002 Errata` still parses, with `date` nil** (the partial-reference contract), and `matches?(other, ignore: [:date])` matches every erratum of the standard. It names no single document, and neither erratum supersedes the other, so **a consumer must treat a bare reference as ambiguous and must not resolve it to the latest erratum**. No relaton consumer of ASHRAE exists today. Locked by `spec/pubid/ashrae/identifiers/errata_spec.rb` and the flipped block "two errata of one standard are two identifiers" in `spec/pubid/ashrae/root_number_spec.rb`. (hand-off: ashrae-errata-date-dropped.)
10
+
11
+ - **An interpretation is an `Interpretation`, not the standard it interprets.** `Interpretations for Standard 15.2-2022` built a plain `Standard` with the `to_s`, `to_hash`, `==` and slug of `ASHRAE Standard 15.2-2022`, for all 51 corpus rows. The builder, the class, the renderer and `mr_supplement_suffix` were all correct; only the grammar tag was missing. `rule(:interpretation_identifier)` now wraps its output in `.as(:interpretation_identifier)`, so the `Builder#build` branch for that key is reached. **Lesson: a `Builder#build` branch that dispatches on a parse-tree key is dead code unless some rule emits that key** — grep the parser for the `.as(:key)` before you trust the branch. The `:addendum_identifier` branch has the same shape; it is harmless, because each sub-alternative emits its own key and has its own branch. **Measured** by a replay of all 2,103 inputs against a `main` baseline: exactly the 51 interpretation rows changed; each renders byte-exact (`Interpretations for Standard 15.2-2022`), keeps its `root.number`, round-trips through `from_hash`, and has a unique slug (`ashrae.standard.15.2.2022_interp`). The URN changed from the standard's own URN to the bare supplement URN `urn:ashrae:15.2`, which every other supplement of that base shared; it is now `urn:ashrae:15.2:2022:standard:interp` (see the supplement-URN bullet below). The interpretations still sit in `spec/fixtures/ashrae/identifiers/pass/standard.txt` until the next `validation:classify[ashrae]` run. Locked by the flipped block "interpretations are distinct from their base standard" in `spec/pubid/ashrae/root_number_spec.rb`. (hand-off: ashrae-interpretation-collapses-onto-base.)
12
+
13
+ - **A supplement URN is the URN of its base plus one supplement marker.** `UrnGenerator#generate` read `year`, `type` and `suffix` off the identifier, and a supplement keeps them on `base`. So every Addendum, Errata, Interpretation and package of one document shared the bare `urn:ashrae:<number>`, which was not the base's own URN either. `urn_addendum` read `maybe(:addendum)`, an attribute no class declares, so the addendum letter never reached a URN. `#generate` now sends a supplement with a `base` to `#supplement_urn`: the base URN (recursive, through `self.class.new(base)`), then `mr_supplement_suffix` — `urn:ashrae:28:2016:guideline:add.e`, `urn:ashrae:14:2002:guideline:errata.2008-10-10`, `urn:ashrae:15.2:2022:standard:interp`. **The marker is the MR slug marker on purpose**, so the two identity surfaces cannot drift, and it is already filtered to `[a-z0-9.-]`. The dead `urn_addendum` is gone. A supplement `copublisher` equals its base `copublisher` for every corpus row, so the base URN loses nothing; the `copub.` segment now sits after the year and the type, where the base puts it. **Measured** by a replay of all 2,103 inputs against a `main` baseline: `to_s`, `to_hash`, the class and the slug did not change for any row; the URN changed for exactly the 1,649 supplements; distinct URNs went from 562 to 2,088, and URNs shared by different hashes went from 71 to 0. No `relaton-data-ashrae` exists, so no published index holds an ASHRAE URN. **Not changed:** `UrnParser` still reads only `number:year` into a plain `Standard`, so a supplement URN does not parse back (it did not before either); and a base number or copublisher can put a comma, a slash or a capital letter into a URN (`urn:ashrae:90A,B,C:standard`, `copub.ansi/ashrae`). Locked by `spec/pubid/ashrae/supplement_urn_spec.rb` and the corpus example "gives distinct identifiers distinct URNs" in `spec/pubid/ashrae/root_number_spec.rb`. (hand-off: ashrae-supplement-urn-collapse.)
@@ -5,6 +5,13 @@ module Pubid
5
5
  # Builder class for constructing ASHRAE identifier scheme from parsed data
6
6
  # Single Responsibility: Transform parsed data into identifier objects
7
7
  class Builder
8
+ # Month names, in the order the parser matches them. The index gives the
9
+ # month number the date component stores, and the renderer inverts it.
10
+ MONTH_NAMES = %w[
11
+ January February March April May June July August September October
12
+ November December
13
+ ].freeze
14
+
8
15
  attr_reader :identifier_class
9
16
 
10
17
  def initialize(identifier_class = Identifier)
@@ -172,11 +179,9 @@ module Pubid
172
179
  base_class = determine_identifier_class(base_attrs)
173
180
  base = base_class.new(**base_attrs)
174
181
 
175
- errata_date = extract_errata_date(parsed[:errata_date])
176
-
177
182
  Identifiers::Errata.new(
178
183
  base: base,
179
- errata_date: errata_date,
184
+ date: extract_errata_date(parsed[:errata_date]),
180
185
  )
181
186
  end
182
187
 
@@ -388,7 +393,10 @@ module Pubid
388
393
 
389
394
  # Build the addendum_codes string from first code and additional codes
390
395
  first_code = extract_value(parsed[:addendum_code])
396
+ # The "a through z" range form yields a single Hash, not an
397
+ # Array; iterating it would emit the key name into the codes.
391
398
  additional_codes = parsed[:additional_codes]
399
+ additional_codes = [additional_codes] if additional_codes.is_a?(Hash)
392
400
 
393
401
  # Handle Format 4: "ASHRAE Addenda to Standard" (no specific codes)
394
402
  if first_code.nil? && additional_codes.nil?
@@ -490,19 +498,53 @@ module Pubid
490
498
  attributes
491
499
  end
492
500
 
493
- # Extract errata date from parsed errata_date data
501
+ # Extract the errata date into the inherited `date` component.
502
+ # The component holds numbers, so every spelling of one date — "October
503
+ # 10, 2008", "October 10,2008", "10-10-2008" — gives one value, and the
504
+ # renderer writes the long form ASHRAE prints. The month and the day are
505
+ # padded to two digits, so two identifiers cannot differ by a leading
506
+ # zero. A date with no year keeps the month and the day.
494
507
  # @param errata_date [Hash] the parsed errata_date hash
495
- # @return [String, nil] the formatted errata date string
508
+ # @return [Pubid::Components::Date, nil] the date, or nil when absent
496
509
  def extract_errata_date(errata_date)
497
- return nil unless errata_date
498
-
499
- # errata_date contains { month_name: "...", errata_year: "..." }
500
- # We need to format this as "Month Day, Year"
501
- extract_value(errata_date[:month_name])
502
- extract_value(errata_date[:errata_year])
503
- # Note: day is captured as digit.repeat(1,2) but not named in the parser
504
- # We'll need to reconstruct from the raw string or enhance parser
505
- nil # For now, return nil - parser enhancement needed
510
+ return nil unless errata_date.is_a?(Hash)
511
+
512
+ numeric = extract_value(errata_date[:numeric_date])
513
+ return numeric_errata_date(numeric) if numeric
514
+
515
+ month = month_number(extract_value(errata_date[:month]))
516
+ day = extract_value(errata_date[:day])
517
+ return nil unless month && day
518
+
519
+ date(extract_value(errata_date[:errata_year]), month, day)
520
+ end
521
+
522
+ # "7-17-2003" (the parser also accepts "7-17- 2003") is month, day, year.
523
+ # @param value [String] the numeric date as written
524
+ # @return [Pubid::Components::Date]
525
+ def numeric_errata_date(value)
526
+ month, day, year = value.delete(" ").split("-")
527
+ date(year, month, day)
528
+ end
529
+
530
+ # @param name [String, nil] a month name the parser matched
531
+ # @return [Integer, nil] the month number
532
+ def month_number(name)
533
+ index = MONTH_NAMES.index(name)
534
+ index && index + 1
535
+ end
536
+
537
+ # @return [Pubid::Components::Date] the date, with padded month and day
538
+ def date(year, month, day)
539
+ Pubid::Components::Date.new(
540
+ year: year, month: pad2(month), day: pad2(day),
541
+ )
542
+ end
543
+
544
+ # @param value [String, Integer] a month or day number
545
+ # @return [String] the number with two digits
546
+ def pad2(value)
547
+ value.to_s.to_i.to_s.rjust(2, "0")
506
548
  end
507
549
 
508
550
  # Extract addendum date from parsed addendum_year data
@@ -517,3 +559,5 @@ module Pubid
517
559
  end
518
560
  end
519
561
  end
562
+
563
+ Pubid::Ashrae::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -10,7 +10,16 @@ module Pubid
10
10
  # @return [Pubid::Ashrae::Identifier] The appropriate identifier object
11
11
  # @raise [Pubid::Errors::ParseError] If parsing fails
12
12
  def self.parse(identifier)
13
- parsed = Parser.parse(identifier)
13
+ unless identifier.is_a?(String)
14
+ raise Pubid::Errors::InvalidInputError,
15
+ Pubid::INPUT_NOT_A_STRING_MESSAGE
16
+ end
17
+
18
+ if identifier.length > Pubid::MAX_INPUT_LENGTH
19
+ raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
20
+ end
21
+
22
+ parsed = Pubid::Parg::Backend.parse(:ashrae, Parser.normalize_input(identifier))
14
23
  Builder.build(parsed)
15
24
  end
16
25
 
@@ -11,17 +11,29 @@ module Pubid
11
11
  # - ASHRAE Guideline 0-2005 Errata (September 28, 2011)
12
12
  # - ANSI/ASHRAE Standard 62.1-2004 Errata (May 4, 2007)
13
13
  class Errata < SupplementIdentifier
14
- attribute :errata_date, :string
14
+ # The date of the erratum lives in the `date` component this class
15
+ # inherits from ::Pubid::Identifier. A flavor-named second attribute
16
+ # (the old `errata_date` :string) described the same value under a
17
+ # name no shared code reads — the GB lesson in CLAUDE.md. The base
18
+ # standard keeps its own edition year in `base.year`.
15
19
 
16
20
  # Make Renderers::MrString recurse into `base` instead of slugging the
17
21
  # supplement flat off attributes it does not have. Without it every
18
22
  # erratum of every standard shared one filename, and `to_slug` is what
19
23
  # consumers use as an output filename.
20
24
  def mr_supplement_suffix
21
- ["errata", mr_sanitize(errata_date)]
25
+ ["errata", mr_sanitize(date_slug)]
22
26
  .compact.reject(&:empty?).join(".")
23
27
  end
24
28
 
29
+ # The sortable form of the date, for the slug only: "2008-10-10", or
30
+ # "08-27" for the one shape that carries no year.
31
+ def date_slug
32
+ return nil unless date
33
+
34
+ [date.year, date.month, date.day].compact.join("-")
35
+ end
36
+
25
37
  TYPED_STAGES = [
26
38
  Components::TypedStage.new(
27
39
  abbr: ["Errata"],
@@ -13,16 +13,8 @@ module Pubid
13
13
  class Interpretation < SupplementIdentifier
14
14
  # Mirrors its four sibling supplement types: without it
15
15
  # Renderers::MrString slugs the interpretation FLAT off attributes it
16
- # does not have, instead of recursing into `base`.
17
- #
18
- # Currently unreachable, and NOT because of alternation ordering:
19
- # rule(:interpretation_identifier) is reached and does match, but it
20
- # never wraps its output in `.as(:interpretation_identifier)`, so
21
- # Builder#build's branch for that key is dead code and the tree falls
22
- # through to the plain-Standard path (pre-existing; pinned in
23
- # spec/pubid/ashrae/root_number_spec.rb, hand-off
24
- # ashrae-interpretation-collapses-onto-base). Added anyway so that
25
- # fixing the dispatch does not silently ship malformed filenames.
16
+ # does not have, instead of recursing into `base`. An interpretation
17
+ # carries no field of its own, so the marker alone is the suffix.
26
18
  def mr_supplement_suffix
27
19
  "interp"
28
20
  end
@@ -120,17 +120,22 @@ module Pubid
120
120
  str("November") | str("December")
121
121
  end
122
122
 
123
+ # The date is what tells two errata of one standard apart, so every part
124
+ # of it is named. The leading space is part of the rule: without it the
125
+ # date never matched after "Errata", and optional_suffix took it instead.
123
126
  rule(:errata_date) do
124
127
  # Full date: (Month Day, Year)
125
- (lparen >> month_name >> space >> digit.repeat(1,
126
- 2) >> comma.maybe >> (space | comma.maybe) >>
128
+ (space.maybe >> lparen >> month_name.as(:month) >> space >>
129
+ digit.repeat(1, 2).as(:day) >> comma.maybe >> (space | comma.maybe) >>
127
130
  year_digits.as(:errata_year) >> rparen).as(:errata_date) |
128
131
  # Month+day without year: (August 27)
129
- (lparen >> month_name >> space >> digit.repeat(1,
130
- 2) >> rparen).as(:errata_date) |
132
+ (space.maybe >> lparen >> month_name.as(:month) >> space >>
133
+ digit.repeat(1, 2).as(:day) >> rparen).as(:errata_date) |
131
134
  # Numeric date with dash: (7-17- 2003) or (7-17-2003)
132
- (lparen >> digit.repeat(1, 2) >> dash >> digit.repeat(1,
133
- 2) >> dash >> space.maybe >> year_digits.as(:errata_year) >> rparen).as(:errata_date)
135
+ (space.maybe >> lparen >>
136
+ (digit.repeat(1, 2) >> dash >> digit.repeat(1, 2) >> dash >>
137
+ space.maybe >> year_digits).as(:numeric_date) >>
138
+ rparen).as(:errata_date)
134
139
  end
135
140
 
136
141
  # Errata suffix pattern - handles descriptive text like "– Spanish Edition" after "Errata"
@@ -141,6 +146,17 @@ module Pubid
141
146
  )
142
147
  end
143
148
 
149
+ # The date and the descriptive suffix after "Errata", in either order. All
150
+ # three errata_identifier branches share this rule: the publisher branch had
151
+ # only the date-first order, so "Errata – Spanish Edition (June 2, 2010)"
152
+ # lost its date to optional_suffix.
153
+ rule(:errata_date_and_suffix) do
154
+ (errata_suffix >> errata_date) |
155
+ (errata_date >> errata_suffix) |
156
+ errata_date |
157
+ errata_suffix
158
+ end
159
+
144
160
  # Errata suffix on addendum (e.g., "ASHRAE Addendum a to Standard 15-2001 Errata (July 6, 2021)")
145
161
  rule(:errata_suffix_on_addendum) do
146
162
  space >> str("Errata").as(:errata_keyword) >>
@@ -179,12 +195,7 @@ module Pubid
179
195
  ).as(:base) >>
180
196
  space >>
181
197
  str("Errata").as(:errata_keyword) >>
182
- (
183
- (errata_suffix >> errata_date) |
184
- (errata_date >> errata_suffix) |
185
- errata_date |
186
- errata_suffix
187
- ).maybe >>
198
+ errata_date_and_suffix.maybe >>
188
199
  optional_suffix.repeat(0, 2).as(:optional_suffixes)) |
189
200
  # Format with copublisher, missing type: ANSI/ASHRAE 51-1999 Errata (May 23, 2014)
190
201
  ((
@@ -201,12 +212,7 @@ module Pubid
201
212
  ).as(:base) >>
202
213
  space >>
203
214
  str("Errata").as(:errata_keyword) >>
204
- (
205
- (errata_suffix >> errata_date) |
206
- (errata_date >> errata_suffix) |
207
- errata_date |
208
- errata_suffix
209
- ).maybe >>
215
+ errata_date_and_suffix.maybe >>
210
216
  optional_suffix.repeat(0, 2).as(:optional_suffixes)) |
211
217
  # Format with publisher: ASHRAE Guideline 0-2005 Errata (September 28, 2011)
212
218
  ((
@@ -220,16 +226,22 @@ module Pubid
220
226
  ).as(:base) >>
221
227
  space >>
222
228
  str("Errata").as(:errata_keyword) >>
223
- errata_date.maybe >>
224
- errata_suffix.maybe >>
229
+ errata_date_and_suffix.maybe >>
225
230
  optional_suffix.repeat(0, 2).as(:optional_suffixes))
226
231
  end
227
232
 
228
233
  # Interpretation pattern ("Interpretations for Standard X-YYYY")
234
+ #
235
+ # The `.as(:interpretation_identifier)` tag is what Builder#build
236
+ # dispatches on. Without it the tree is a bare `{base: …}`, the builder
237
+ # takes the plain-identifier path, and the interpretation becomes the
238
+ # standard it interprets.
229
239
  rule(:interpretation_identifier) do
230
- str("Interpretations") >> space >>
240
+ (str("Interpretations") >> space >>
231
241
  str("for") >> space >>
232
- (type.as(:type) >> space >> code >> (dash >> year_digits.as(:year)).maybe).as(:base)
242
+ (type.as(:type) >> space >> code >>
243
+ (dash >> year_digits.as(:year)).maybe).as(:base))
244
+ .as(:interpretation_identifier)
233
245
  end
234
246
 
235
247
  # Combined Addenda pattern (multiple addendums grouped together)
@@ -242,9 +254,9 @@ module Pubid
242
254
  (
243
255
  (comma >> space >> str("and") >> space >> addendum_code) | # ", and b"
244
256
  (space >> str("and") >> space >> addendum_code) | # "and b"
245
- (comma >> space >> addendum_code).repeat(1, 10) | # comma-separated: "a, b, c"
246
- (comma >> addendum_code).repeat(1, 10) # comma without space: "a,b,c"
247
- ).repeat(0, 3).as(:additional_codes) >> # Multiple groups of codes (0-3 to allow single code)
257
+ (comma >> space >> addendum_code).repeat(1, 50) | # comma-separated: "a, b, c"
258
+ (comma >> addendum_code).repeat(1, 50) # comma without space: "a,b,c"
259
+ ).repeat(0, 20).as(:additional_codes) >> # Multiple groups of codes
248
260
  space >>
249
261
  (str("to") | str("for")) >> space >>
250
262
  ((str("ANSI") >> slash >> str("ASHRAE") >> (slash >> letter.repeat(3, 10)).repeat(
@@ -348,11 +360,11 @@ module Pubid
348
360
  # Range pattern: "a through z"
349
361
  (space >> str("through") >> space >> addendum_code) |
350
362
  # Space or comma separated codes (handles typos like "bl bq")
351
- ((space | (comma >> space)) >> addendum_code).repeat(1, 50) |
363
+ ((space | (comma >> space)) >> addendum_code).repeat(1, 200) |
352
364
  # Just match all comma-separated values
353
- (comma >> space >> addendum_code).repeat(1, 50) |
365
+ (comma >> space >> addendum_code).repeat(1, 200) |
354
366
  # Or comma without space
355
- (comma >> addendum_code).repeat(1, 50)
367
+ (comma >> addendum_code).repeat(1, 200)
356
368
  ).as(:additional_codes).maybe >>
357
369
  (space >> additional_copublisher).maybe >>
358
370
  addendum_date_suffix.maybe >>
@@ -486,6 +498,28 @@ module Pubid
486
498
  errata_suffix_on_addendum.maybe >>
487
499
  optional_suffix.repeat(0, 2)
488
500
  ).as(:publisher_base_addendum) |
501
+ # Format: [ANSI/ASHRAE] Addendum X to/for ASHRAE Standard/Guideline
502
+ # X-YYYY (leading copublisher + publisher-led base)
503
+ (
504
+ (
505
+ (str("ANSI") >> slash >> str("ASHRAE") >> (slash >> letter.repeat(3, 10)).repeat(
506
+ 0, 10
507
+ )).as(:copublisher) >>
508
+ space
509
+ ).maybe >>
510
+ str("Addendum") >> space >>
511
+ addendum_code >>
512
+ space >>
513
+ (str("to") | str("for")) >> space >>
514
+ publisher.as(:base_publisher) >> space >>
515
+ type.as(:type) >> space >>
516
+ code >>
517
+ (dash >> year_digits.as(:year)).maybe >>
518
+ additional_copublisher.maybe >>
519
+ addendum_date_suffix.maybe >>
520
+ errata_suffix_on_addendum.maybe >>
521
+ optional_suffix.repeat(0, 2)
522
+ ).as(:publisher_base_addendum) |
489
523
  # Format: ASHRAE Addendum X to ANSI/ASHRAE Standard/Guideline X-YYYY (ASHRAE publisher + copublisher base)
490
524
  (
491
525
  publisher.as(:publisher) >> space >>
@@ -664,16 +698,10 @@ module Pubid
664
698
 
665
699
  root(:identifier)
666
700
 
667
- def self.parse(string)
668
- unless string.is_a?(String)
669
- raise Pubid::Errors::InvalidInputError,
670
- Pubid::INPUT_NOT_A_STRING_MESSAGE
671
- end
672
-
673
- if string.length > Pubid::MAX_INPUT_LENGTH
674
- raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
675
- end
676
-
701
+ # Pre-parse ingestion normalizations (R2): every parse path —
702
+ # parslet and PG artifact alike — feeds the grammar the same
703
+ # normalized string.
704
+ def self.normalize_input(string)
677
705
  # Strip leading/trailing whitespace
678
706
  cleaned = string.strip
679
707
 
@@ -741,7 +769,20 @@ module Pubid
741
769
  "Addenda \\1, ")
742
770
  end
743
771
 
744
- new.parse(cleaned)
772
+ cleaned
773
+ end
774
+
775
+ def self.parse(string)
776
+ unless string.is_a?(String)
777
+ raise Pubid::Errors::InvalidInputError,
778
+ Pubid::INPUT_NOT_A_STRING_MESSAGE
779
+ end
780
+
781
+ if string.length > Pubid::MAX_INPUT_LENGTH
782
+ raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
783
+ end
784
+
785
+ new.parse(normalize_input(string))
745
786
  end
746
787
  end
747
788
  end
@@ -99,10 +99,41 @@ module Pubid
99
99
 
100
100
  result = id.base.to_s
101
101
  result += " Errata"
102
- result += " (#{id.errata_date})" if id.errata_date
102
+ date = long_date(id.date)
103
+ result += " (#{date})" if date
103
104
  result
104
105
  end
105
106
 
107
+ # The long date form ASHRAE prints on an errata sheet: "October 10,
108
+ # 2008". The component stores padded numbers, so the day loses its
109
+ # leading zero here.
110
+ #
111
+ # The parser always gives a month and a day together, so only the first
112
+ # two shapes come from a reference string. The others can come from a
113
+ # hand-built identifier or from a hash: a date with no day gives "June
114
+ # 2016", a date with only a year gives "2016", and a month number
115
+ # outside 1-12 falls back to the component's own "2016-13-01" form.
116
+ # They must all print something, because the date reaches `to_hash` and
117
+ # the MR slug, and a surface that drops it silently disagrees with them.
118
+ def long_date(date)
119
+ return nil unless date
120
+ return date.year&.to_s unless date.month
121
+
122
+ month = Builder::MONTH_NAMES[date.month.to_i - 1]
123
+ return date.to_s unless month
124
+
125
+ long_date_with_month(date, month)
126
+ end
127
+
128
+ # @param month [String] the month name
129
+ # @return [String] the date, with the day when the date carries one
130
+ def long_date_with_month(date, month)
131
+ return "#{month} #{date.year}".strip unless date.day
132
+
133
+ day = "#{month} #{date.day.to_i}"
134
+ date.year ? "#{day}, #{date.year}" : day
135
+ end
136
+
106
137
  # Interpretation: "Interpretations for Standard 15.2-2022"
107
138
  def render_interpretation(id)
108
139
  return id.base.to_s unless id.base
@@ -5,9 +5,9 @@ module Pubid
5
5
  class UrnGenerator < Pubid::UrnGenerator::Base
6
6
  # A supplement carries no number of its own — only the two
7
7
  # single-document leaves declare one — so reach it through #root, which
8
- # walks `base` to the standard the supplement attaches to. Without the
9
- # fallback every Errata/Addendum URN lost its number when `code` moved
10
- # off the shared base onto the leaves.
8
+ # walks `base` to the standard the supplement attaches to. #generate
9
+ # sends a supplement with a `base` to #supplement_urn, so only a
10
+ # supplement without a `base` reaches this fallback.
11
11
  def urn_number
12
12
  num = identifier.number || identifier.root.number
13
13
  return nil if num.nil? || num.to_s.empty?
@@ -15,6 +15,14 @@ module Pubid
15
15
  num.to_s
16
16
  end
17
17
 
18
+ # ASHRAE keeps the edition of a document in its own `year` string. The
19
+ # base hook reads the inherited `date` first, which on an Errata holds
20
+ # the date of the erratum — a different thing from the edition year of
21
+ # the standard, and misleading in the year slot. Read `year` only.
22
+ def urn_year
23
+ identifier.year&.to_s
24
+ end
25
+
18
26
  def urn_suffix
19
27
  identifier.suffix&.to_s&.downcase
20
28
  end
@@ -31,12 +39,17 @@ module Pubid
31
39
  "copub.#{identifier.copublisher.to_s.downcase}" if identifier.copublisher
32
40
  end
33
41
 
34
- def urn_addendum
35
- val = maybe(:addendum)
36
- "add.#{val}" if val
42
+ def generate
43
+ supplement? ? supplement_urn : document_urn
37
44
  end
38
45
 
39
- def generate
46
+ private
47
+
48
+ def supplement?
49
+ identifier.is_a?(SupplementIdentifier) && !identifier.base.nil?
50
+ end
51
+
52
+ def document_urn
40
53
  parts = ["urn", "ashrae"]
41
54
  parts << urn_number if urn_number
42
55
  parts << urn_year if urn_year
@@ -48,10 +61,20 @@ module Pubid
48
61
 
49
62
  parts[1] = identifier.publisher.to_s.downcase if identifier.publisher
50
63
 
51
- parts << urn_addendum if urn_addendum
52
-
53
64
  parts.join(":")
54
65
  end
66
+
67
+ # A supplement keeps the year, the type and the suffix on its `base`.
68
+ # Its URN is the URN of that base plus one segment that names the
69
+ # supplement ("add.e", "errata.2008-10-10", "interp"). The segment is
70
+ # the marker the MR slug uses, so the two surfaces cannot drift apart.
71
+ # Without this, every supplement of one document had the same bare
72
+ # "urn:ashrae:<number>" (hand-off ashrae-supplement-urn-collapse).
73
+ def supplement_urn
74
+ marker = identifier.mr_supplement_suffix
75
+ [self.class.new(identifier.base).generate, marker]
76
+ .reject { |part| part.nil? || part.empty? }.join(":")
77
+ end
55
78
  end
56
79
  end
57
80
  end
@@ -0,0 +1,25 @@
1
+ # ASME flavor notes
2
+
3
+ ASME index key and MR slug.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/asme/` or `spec/pubid/asme/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ ## From the root note "AMCA / ASME / ASTM index key (`root.number`): three flavors, three different shapes"
8
+
9
+ **(2) ASME — the whole printed code, not a split.** `Asme::Components::Code` is designator+number, so the IEEE split looks right — but **152 of 731 fixture ids are Boiler and Pressure Vessel Code documents whose entire identity IS the designator** (`BPVC COMPLETE CODE BIND`, `BPVC.CC.BPV`) with no numeric part at all; splitting leaves every one of them keyed `""`. So `number` holds the whole code (`"B18.3"`), declared on the leaf `Identifiers::Standard`. **`Asme::Components::Code` is now unused on the identifier path and there is no `#code` reader**: an earlier draft composed one so the renderer and URN generator would not have to change, but since `number` already holds the whole code that Code never carried a designator and `code.to_s` equalled `number` for all 731 ids — a string wrapped in an object whose only job was to unwrap to the same string. Both readers use `number` directly. **Contrast ASTM, which keeps its composed `#code`**: its renderer reads `code.letter`/`code.dual_m` field-by-field, so there the component carries real structure. The test for whether a derived `#code` earns its place is whether any caller reads a *field* of it rather than just `to_s`.
10
+
11
+ ## The trailing year and the BPVC designator (hand-off `asme-bpvc-and-amca-residue`)
12
+
13
+ **A designator with no number lost its year.** The dash branch of `number_part` (`dash >> [0-9A-Z]+`, there for `BTH-1` and `CA-1`) also matched the year in `BPVC.I-2021`, so `-2021` became the number. For a BPVC code the builder then discarded the number, so all 150 BPVC ids had no year and the 2021, 2023 and 2025 editions of one document had the same hash, `to_s`, URN and slug. For `BPE-2012`, `OM-2017` and `PASE-2019` (13 ids) the year stayed inside `number` (`"BPE-2012"`). The `trailing_year` guard in `lib/pubid/asme/parser.rb` refuses that branch for a dash, a 4-digit or draft year, and no further code character. `BTH-1-2020` still parses as `BTH-1`.
14
+
15
+ **Two BPVC render corruptions were in the same builder.** The case sub-code kept its leading dot (`BPVC.CC.BPV..I`, 15 ids), and the builder read the SSC sections one level above where the grammar put them (`BPVC.SSC.`, 2 ids). Every BPVC id now renders as it was written.
16
+
17
+ **`BPVC-CC-BPV` and `BPVC.CC.BPV` are two documents, not two spellings.** The hand-off suggested normalizing one to the other, and a draft of this branch did that. It was reverted: the ASME catalogue (asme.org, "Find codes & standards") lists both as separate documents: `BPVC.CC.BPV` is the code-case book, and `BPVC-CC-BPV` is the designation of each 2019 code-case supplement ("BPVC Code Cases 2019-Boilers and Pressure Vessels, Supplement 7"; the supplement number is only in the title, so pubid cannot tell the supplements apart). Each keeps its own spelling, index key, `to_s` and URN.
18
+
19
+ **The MR slug writes a literal `-` as `--`.** `mr_sanitize` wrote `.` and `-` both as `-`, so `BPVC.CC.BPV-2019` and `BPVC-CC-BPV-2019` shared `asme.bpvc-cc-bpv.2019`. Now the dash form is `asme.bpvc--cc--bpv.2019`. **`_` was the obvious choice and is wrong:** the MR format reserves `_` for supplement layers, and `Pubid::Parsers::MrString` splits on it first. The change moves only the slug, and only for the 113 corpus ids whose code has a literal dash (`BTH-1` → `bth--1`); `B18.3` stays `b18-3`.
20
+
21
+ **The SSC codes of the ASME catalogue all parse.** `BPVC.SSC.XI.II.V.IX` (Sections XI, II, V, IX) and `BPVC.SSC.VIII.XII.II.V.IX` (Sections VIII, XII, II, V, IX) are different documents; the spec table carries every SSC code the catalogue lists.
22
+
23
+ **Measured against a `main` baseline of the 731 fixture ids:** only the 150 BPVC ids and the 13 BPE/OM/PASE ids moved. No id stopped parsing. The `pubid-testsuite` corpus records the old outputs as debt (`tests/asme/_status.yaml` is `clean: false`), so it needs a re-export (hand-off `pubid__pubid-testsuite__asme-amca-reexport`).
24
+
25
+ **Not fixed, pre-existing:** `ASME PTC 1-2015` renders `ASME PTC1-2015` (the space before a PTC number is lost).
@@ -105,14 +105,18 @@ module Pubid
105
105
  # BPVC COMPLETE CODE BIND
106
106
  designator_str = "BPVC COMPLETE CODE BIND"
107
107
  elsif bpvc_data[:subdivision] && bpvc_data[:subdivision][:ssc_code]
108
- # BPVC.SSC.XI.II.V.IX pattern
109
- ssc_sections = bpvc_data[:subdivision][:ssc_sections]
110
- sections_str = if ssc_sections.is_a?(Array)
111
- ssc_sections.join(".")
112
- else
113
- ssc_sections.to_s
114
- end
115
- designator_str = "BPVC.SSC.#{sections_str}"
108
+ # BPVC.SSC.XI.II.V.IX pattern. The sections sit under `ssc_code`;
109
+ # reading them one level up rendered every SSC id as "BPVC.SSC.".
110
+ # A bare "BPVC.SSC." (the catalogue's series identity) parses
111
+ # with no inner capture, so `ssc_code` is the matched Slice.
112
+ ssc_code = bpvc_data[:subdivision][:ssc_code]
113
+ sections = ssc_code.is_a?(Hash) ? ssc_code[:ssc_sections] : nil
114
+ designator_str =
115
+ if sections.nil? || sections.to_s.empty?
116
+ "BPVC.SSC."
117
+ else
118
+ "BPVC.SSC.#{sections}"
119
+ end
116
120
  elsif bpvc_data[:subdivision] && bpvc_data[:subdivision][:case_code]
117
121
  # BPVC.CC.BPV or BPVC.CC.NC.XI - extract from subdivision hash
118
122
  cc = bpvc_data[:subdivision][:case_code].to_s
@@ -120,7 +124,8 @@ module Pubid
120
124
 
121
125
  designator_str = case_sub && !case_sub.empty? ? "BPVC.CC.#{cc}.#{case_sub}" : "BPVC.CC.#{cc}"
122
126
  elsif bpvc_data[:case_code]
123
- # Dash notation: BPVC-CC-BPV
127
+ # Dash notation: BPVC-CC-BPV. Keep the dashes: the ASME catalogue
128
+ # lists BPVC-CC-BPV and BPVC.CC.BPV as two separate documents.
124
129
  cc = bpvc_data[:case_code].to_s
125
130
  designator_str = "BPVC-CC-#{cc}"
126
131
  elsif bpvc_data[:subdivision]
@@ -160,3 +165,5 @@ module Pubid
160
165
  end
161
166
  end
162
167
  end
168
+
169
+ Pubid::Asme::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -10,6 +10,8 @@ module Pubid
10
10
  # Stays independent of Pubid::Components::Code because ASME uses
11
11
  # +designator+ (a flavor-specific letter prefix) plus +number+.
12
12
  class Code < Lutaml::Model::Serializable
13
+ include ::Pubid::SubsetMatch
14
+
13
15
  attribute :designator, :string
14
16
  attribute :number, :string
15
17
 
@@ -16,7 +16,7 @@ module Pubid
16
16
  parser = Parser.new
17
17
  builder = Builder.new
18
18
 
19
- parsed = parser.parse(str)
19
+ parsed = Pubid::Parg::Backend.parse(:asme, str)
20
20
  builder.build(parsed)
21
21
  end
22
22
  end