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.
- checksums.yaml +4 -4
- data/README.adoc +43 -1
- data/data/ieee/update_codes.yaml +17 -4
- data/data/nist/update_codes.yaml +7 -3
- data/lib/pubid/adobe/builder.rb +2 -0
- data/lib/pubid/all_parts.rb +201 -0
- data/lib/pubid/all_parts_identifier.rb +19 -0
- data/lib/pubid/amca/CLAUDE.md +47 -0
- data/lib/pubid/amca/builder.rb +3 -5
- data/lib/pubid/amca/identifiers/base.rb +10 -0
- data/lib/pubid/amca/identifiers/publication.rb +13 -0
- data/lib/pubid/amca/parser.rb +2 -1
- data/lib/pubid/amca/renderer.rb +22 -33
- data/lib/pubid/amca/urn_generator.rb +21 -2
- data/lib/pubid/amca/urn_parser.rb +36 -10
- data/lib/pubid/ansi/builder.rb +6 -0
- data/lib/pubid/api/CLAUDE.md +23 -0
- data/lib/pubid/api/builder.rb +2 -0
- data/lib/pubid/ashrae/CLAUDE.md +13 -0
- data/lib/pubid/ashrae/builder.rb +58 -14
- data/lib/pubid/ashrae/identifiers/errata.rb +14 -2
- data/lib/pubid/ashrae/identifiers/interpretation.rb +2 -10
- data/lib/pubid/ashrae/parser.rb +62 -28
- data/lib/pubid/ashrae/renderer.rb +32 -1
- data/lib/pubid/ashrae/urn_generator.rb +32 -9
- data/lib/pubid/asme/CLAUDE.md +25 -0
- data/lib/pubid/asme/builder.rb +16 -9
- data/lib/pubid/asme/components/code.rb +2 -0
- data/lib/pubid/asme/identifiers/standard.rb +6 -1
- data/lib/pubid/asme/parser.rb +41 -14
- data/lib/pubid/astm/CLAUDE.md +9 -0
- data/lib/pubid/astm/builder.rb +2 -0
- data/lib/pubid/astm/components/code.rb +2 -0
- data/lib/pubid/astm/parser.rb +4 -1
- data/lib/pubid/bipm/CLAUDE.md +11 -0
- data/lib/pubid/bipm/builder.rb +2 -0
- data/lib/pubid/bsi/CLAUDE.md +93 -0
- data/lib/pubid/bsi/builder.rb +13 -11
- data/lib/pubid/bsi/components/date.rb +10 -4
- data/lib/pubid/bsi/identifiers/addendum_document.rb +2 -0
- data/lib/pubid/bsi/identifiers/adopted_european_norm.rb +6 -54
- data/lib/pubid/bsi/identifiers/adopted_international_standard.rb +5 -22
- data/lib/pubid/bsi/identifiers/amendment.rb +36 -12
- data/lib/pubid/bsi/identifiers/british_industrial_practice.rb +1 -1
- data/lib/pubid/bsi/identifiers/bundled_identifier.rb +2 -0
- data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +23 -26
- data/lib/pubid/bsi/identifiers/corrigendum.rb +29 -12
- data/lib/pubid/bsi/identifiers/expert_commentary.rb +6 -7
- data/lib/pubid/bsi/identifiers/handbook.rb +1 -1
- data/lib/pubid/bsi/identifiers/national_annex.rb +18 -20
- data/lib/pubid/bsi/identifiers/practice_guide.rb +1 -1
- data/lib/pubid/bsi/identifiers/root_identity.rb +31 -0
- data/lib/pubid/bsi/identifiers/set.rb +2 -0
- data/lib/pubid/bsi/identifiers/supplement_document.rb +2 -0
- data/lib/pubid/bsi/identifiers.rb +1 -0
- data/lib/pubid/bsi/parser.rb +8 -8
- data/lib/pubid/bsi/renderer.rb +20 -20
- data/lib/pubid/bsi/single_identifier.rb +11 -2
- data/lib/pubid/bsi/urn_generator.rb +28 -18
- data/lib/pubid/builder/base.rb +27 -0
- data/lib/pubid/calconnect/builder.rb +2 -0
- data/lib/pubid/calconnect/identifier.rb +4 -0
- data/lib/pubid/ccsds/builder.rb +2 -0
- data/lib/pubid/ccsds/identifier.rb +12 -0
- data/lib/pubid/ccsds/single_identifier.rb +14 -10
- data/lib/pubid/cen_cenelec/CLAUDE.md +59 -0
- data/lib/pubid/cen_cenelec/builder.rb +6 -1
- data/lib/pubid/cen_cenelec/identifier.rb +10 -27
- data/lib/pubid/cen_cenelec/identifiers/amendment.rb +3 -10
- data/lib/pubid/cen_cenelec/identifiers/corrigendum.rb +3 -10
- data/lib/pubid/cen_cenelec/parser.rb +11 -3
- data/lib/pubid/cie/CLAUDE.md +58 -0
- data/lib/pubid/cie/builder.rb +2 -0
- data/lib/pubid/cie/components/language.rb +2 -0
- data/lib/pubid/components/adoption.rb +2 -0
- data/lib/pubid/components/code.rb +2 -0
- data/lib/pubid/components/date.rb +8 -6
- data/lib/pubid/components/edition.rb +2 -0
- data/lib/pubid/components/iteration.rb +2 -0
- data/lib/pubid/components/language.rb +2 -0
- data/lib/pubid/components/locality.rb +2 -0
- data/lib/pubid/components/publisher.rb +2 -0
- data/lib/pubid/components/relationship.rb +2 -0
- data/lib/pubid/components/stage.rb +2 -0
- data/lib/pubid/components/supplement.rb +2 -0
- data/lib/pubid/components/type.rb +2 -0
- data/lib/pubid/components/typed_stage.rb +8 -0
- data/lib/pubid/csa/CLAUDE.md +41 -0
- data/lib/pubid/csa/builder.rb +5 -3
- data/lib/pubid/csa/identifier.rb +13 -1
- data/lib/pubid/csa/identifiers/bundled.rb +2 -2
- data/lib/pubid/csa/identifiers/cec.rb +1 -1
- data/lib/pubid/csa/renderer.rb +12 -12
- data/lib/pubid/csa/single_identifier.rb +18 -1
- data/lib/pubid/csa/urn_generator.rb +1 -1
- data/lib/pubid/doi/builder.rb +2 -0
- data/lib/pubid/easc/builder.rb +2 -0
- data/lib/pubid/ecma/CLAUDE.md +28 -0
- data/lib/pubid/ecma/builder.rb +2 -0
- data/lib/pubid/ecma/identifier.rb +7 -0
- data/lib/pubid/etsi/CLAUDE.md +34 -0
- data/lib/pubid/etsi/builder.rb +2 -0
- data/lib/pubid/etsi/components/code.rb +6 -0
- data/lib/pubid/etsi/components/version.rb +2 -0
- data/lib/pubid/etsi/identifiers/etsi_standard.rb +7 -0
- data/lib/pubid/evs/CLAUDE.md +58 -0
- data/lib/pubid/evs/builder.rb +3 -1
- data/lib/pubid/evs/identifier.rb +6 -6
- data/lib/pubid/evs/identifiers/national_adoption.rb +7 -2
- data/lib/pubid/evs/renderer.rb +1 -1
- data/lib/pubid/evs/urn_generator.rb +1 -1
- data/lib/pubid/evs/urn_parser.rb +1 -1
- data/lib/pubid/gb/CLAUDE.md +140 -0
- data/lib/pubid/gb/builder.rb +21 -11
- data/lib/pubid/gb/identifier.rb +28 -7
- data/lib/pubid/gb/identifiers/all_parts.rb +17 -0
- data/lib/pubid/gb/identifiers.rb +1 -0
- data/lib/pubid/gb/parser.rb +4 -0
- data/lib/pubid/gb/renderer.rb +4 -4
- data/lib/pubid/gb.rb +1 -0
- data/lib/pubid/gost/CLAUDE.md +64 -0
- data/lib/pubid/gost/builder.rb +3 -1
- data/lib/pubid/gost/identifier.rb +5 -0
- data/lib/pubid/iala/CLAUDE.md +82 -0
- data/lib/pubid/iala/builder.rb +2 -0
- data/lib/pubid/iana/CLAUDE.md +7 -0
- data/lib/pubid/iana/builder.rb +2 -0
- data/lib/pubid/identifier.rb +172 -20
- data/lib/pubid/idf/builder.rb +6 -1
- data/lib/pubid/idf/identifier.rb +12 -0
- data/lib/pubid/idf/identifiers/all_parts.rb +17 -0
- data/lib/pubid/idf/identifiers/amendment.rb +1 -1
- data/lib/pubid/idf/identifiers/corrigendum.rb +1 -1
- data/lib/pubid/idf/identifiers/international_standard.rb +1 -1
- data/lib/pubid/idf/identifiers/reviewed_method.rb +1 -1
- data/lib/pubid/idf/identifiers.rb +1 -0
- data/lib/pubid/idf/single_identifier.rb +1 -1
- data/lib/pubid/iec/CLAUDE.md +31 -0
- data/lib/pubid/iec/builder.rb +7 -1
- data/lib/pubid/iec/components/consolidated_amendment.rb +4 -0
- data/lib/pubid/iec/components/sheet.rb +2 -0
- data/lib/pubid/iec/components/trf_info.rb +2 -0
- data/lib/pubid/iec/components/vap_suffix.rb +2 -0
- data/lib/pubid/iec/identifier.rb +7 -2
- data/lib/pubid/iec/identifiers/all_parts.rb +19 -0
- data/lib/pubid/iec/identifiers.rb +1 -0
- data/lib/pubid/iec/renderer.rb +0 -1
- data/lib/pubid/iec/urn_generator.rb +9 -1
- data/lib/pubid/iec/urn_parser.rb +3 -2
- data/lib/pubid/ieee/CLAUDE.md +97 -0
- data/lib/pubid/ieee/builder.rb +176 -7
- data/lib/pubid/ieee/components/code.rb +2 -0
- data/lib/pubid/ieee/components/draft.rb +35 -2
- data/lib/pubid/ieee/components/typed_stage.rb +2 -0
- data/lib/pubid/ieee/identifiers/base.rb +20 -0
- data/lib/pubid/ieee/identifiers/iec_ieee_copublished.rb +9 -0
- data/lib/pubid/ieee/identifiers/joint_development.rb +17 -10
- data/lib/pubid/ieee/identifiers/project_draft_identifier.rb +8 -1
- data/lib/pubid/ieee/identifiers/si_standard.rb +5 -2
- data/lib/pubid/ieee/ire/parser.rb +19 -1
- data/lib/pubid/ieee/parser.rb +337 -49
- data/lib/pubid/ieee/project_renderer.rb +46 -0
- data/lib/pubid/ieee/renderer.rb +46 -14
- data/lib/pubid/ieee/urn_generator.rb +31 -0
- data/lib/pubid/ieee.rb +2 -0
- data/lib/pubid/ietf/CLAUDE.md +7 -0
- data/lib/pubid/ietf/builder.rb +2 -0
- data/lib/pubid/iho/builder.rb +2 -0
- data/lib/pubid/isbn/builder.rb +2 -0
- data/lib/pubid/iso/CLAUDE.md +47 -0
- data/lib/pubid/iso/builder.rb +29 -16
- data/lib/pubid/iso/components/publisher.rb +2 -0
- data/lib/pubid/iso/components.rb +0 -1
- data/lib/pubid/iso/identifier.rb +17 -18
- data/lib/pubid/iso/identifiers/all_parts.rb +19 -0
- data/lib/pubid/iso/identifiers/directives.rb +7 -4
- data/lib/pubid/iso/identifiers/directives_supplement.rb +4 -2
- data/lib/pubid/iso/identifiers/tc_document.rb +31 -46
- data/lib/pubid/iso/identifiers.rb +1 -0
- data/lib/pubid/iso/normalizer.rb +1 -1
- data/lib/pubid/iso/rendering_style.rb +0 -1
- data/lib/pubid/iso/urn_generator.rb +10 -10
- data/lib/pubid/iso.rb +5 -4
- data/lib/pubid/itu/CLAUDE.md +69 -0
- data/lib/pubid/itu/builder.rb +12 -0
- data/lib/pubid/itu/components/code.rb +2 -0
- data/lib/pubid/itu/components/designation.rb +2 -0
- data/lib/pubid/itu/components/sector.rb +2 -0
- data/lib/pubid/itu/components/series.rb +2 -0
- data/lib/pubid/itu/identifiers/base.rb +10 -0
- data/lib/pubid/itu/identifiers/contribution.rb +31 -0
- data/lib/pubid/itu/identifiers/supplement.rb +15 -0
- data/lib/pubid/itu/identifiers.rb +1 -0
- data/lib/pubid/itu/parser.rb +19 -2
- data/lib/pubid/jcgm/CLAUDE.md +7 -0
- data/lib/pubid/jcgm/builder.rb +2 -0
- data/lib/pubid/jcgm/components/publisher.rb +2 -0
- data/lib/pubid/jis/builder.rb +5 -1
- data/lib/pubid/jis/identifier.rb +5 -17
- data/lib/pubid/jis/identifiers/all_parts.rb +19 -0
- data/lib/pubid/jis/identifiers.rb +1 -0
- data/lib/pubid/jis/renderer.rb +0 -2
- data/lib/pubid/jis/urn_generator.rb +0 -1
- data/lib/pubid/nist/CLAUDE.md +56 -0
- data/lib/pubid/nist/builder.rb +14 -12
- data/lib/pubid/nist/components/edition.rb +2 -0
- data/lib/pubid/nist/components/issue_number.rb +2 -0
- data/lib/pubid/nist/components/part.rb +2 -0
- data/lib/pubid/nist/components/stage.rb +2 -0
- data/lib/pubid/nist/components/supplement.rb +2 -0
- data/lib/pubid/nist/components/translation.rb +2 -0
- data/lib/pubid/nist/components/update.rb +2 -0
- data/lib/pubid/nist/components/version.rb +2 -0
- data/lib/pubid/nist/components/volume.rb +2 -0
- data/lib/pubid/nist/identifiers/base.rb +34 -6
- data/lib/pubid/nist/identifiers/commercial_standard_emergency.rb +1 -1
- data/lib/pubid/nist/identifiers/crpl_report.rb +5 -6
- data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +2 -2
- data/lib/pubid/nist/identifiers/miscellaneous_publication.rb +2 -2
- data/lib/pubid/nist/parser.rb +15 -2
- data/lib/pubid/nist/preprocessor.rb +53 -2
- data/lib/pubid/nist/series/ir.rb +3 -7
- data/lib/pubid/oasis/CLAUDE.md +19 -0
- data/lib/pubid/oasis/builder.rb +2 -0
- data/lib/pubid/oasis/identifier.rb +19 -0
- data/lib/pubid/ogc/CLAUDE.md +34 -0
- data/lib/pubid/ogc/builder.rb +2 -0
- data/lib/pubid/ogc/identifier.rb +11 -0
- data/lib/pubid/oiml/CLAUDE.md +189 -0
- data/lib/pubid/oiml/builder.rb +22 -2
- data/lib/pubid/oiml/components/code.rb +6 -0
- data/lib/pubid/oiml/identifier.rb +13 -0
- data/lib/pubid/oiml/identifiers/annex.rb +4 -0
- data/lib/pubid/oiml/identifiers/bulletin.rb +30 -36
- data/lib/pubid/oiml/identifiers/certification_system.rb +34 -0
- data/lib/pubid/oiml/identifiers/code_number.rb +10 -2
- data/lib/pubid/oiml/identifiers/dual_published.rb +174 -0
- data/lib/pubid/oiml/identifiers.rb +2 -0
- data/lib/pubid/oiml/parser.rb +41 -5
- data/lib/pubid/oiml/renderer.rb +26 -4
- data/lib/pubid/oiml/single_identifier.rb +5 -1
- data/lib/pubid/oiml/supplement_identifier.rb +7 -0
- data/lib/pubid/oiml/urn_generator.rb +32 -4
- data/lib/pubid/oiml.rb +5 -0
- data/lib/pubid/omg/CLAUDE.md +15 -0
- data/lib/pubid/omg/builder.rb +2 -0
- data/lib/pubid/parser/grammar.rb +23 -0
- data/lib/pubid/plateau/builder.rb +2 -0
- data/lib/pubid/plateau/identifiers/base.rb +4 -0
- data/lib/pubid/plateau/supplement_identifier.rb +14 -2
- data/lib/pubid/plateau/urn_generator.rb +7 -1
- data/lib/pubid/renderers/directives_renderer.rb +3 -1
- data/lib/pubid/renderers/human_readable.rb +0 -1
- data/lib/pubid/sae/builder.rb +3 -1
- data/lib/pubid/sae/components/date.rb +2 -0
- data/lib/pubid/sae/components/type.rb +2 -0
- data/lib/pubid/sae/identifiers/base.rb +4 -1
- data/lib/pubid/sae/urn_generator.rb +1 -1
- data/lib/pubid/subset_match.rb +197 -0
- data/lib/pubid/tgpp/CLAUDE.md +43 -0
- data/lib/pubid/tgpp/builder.rb +2 -0
- data/lib/pubid/tgpp/identifier.rb +14 -0
- data/lib/pubid/type_resolver.rb +14 -2
- data/lib/pubid/un/builder.rb +2 -0
- data/lib/pubid/version.rb +1 -1
- data/lib/pubid/w3c/CLAUDE.md +7 -0
- data/lib/pubid/w3c/builder.rb +2 -0
- data/lib/pubid/xsf/CLAUDE.md +11 -0
- data/lib/pubid/xsf/builder.rb +2 -0
- data/lib/pubid.rb +16 -3
- data/lib/tasks/docs.rake +13 -11
- metadata +46 -4
- data/lib/pubid/ccsds/identifiers/base_BASE_88929.rb +0 -70
- 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`.)
|
data/lib/pubid/omg/builder.rb
CHANGED
data/lib/pubid/parser/grammar.rb
CHANGED
|
@@ -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]
|
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
if @id.subgroup
|
|
41
|
+
subgroup_str = render_component(@id.subgroup, context)
|
|
42
|
+
end
|
|
41
43
|
|
|
42
44
|
[
|
|
43
45
|
pub_str,
|
data/lib/pubid/sae/builder.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Pubid
|
|
|
20
20
|
when :type
|
|
21
21
|
Components::Type.new(abbr: value.to_s)
|
|
22
22
|
when :number
|
|
23
|
-
|
|
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)
|
|
@@ -22,7 +22,10 @@ module Pubid
|
|
|
22
22
|
|
|
23
23
|
attribute :publisher, :string, default: -> { "SAE" }
|
|
24
24
|
attribute :type, Sae::Components::Type
|
|
25
|
-
|
|
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
|
|
@@ -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.
|
data/lib/pubid/tgpp/builder.rb
CHANGED
|
@@ -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 = {
|
data/lib/pubid/type_resolver.rb
CHANGED
|
@@ -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
|
data/lib/pubid/un/builder.rb
CHANGED
data/lib/pubid/version.rb
CHANGED
|
@@ -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.)
|
data/lib/pubid/w3c/builder.rb
CHANGED
|
@@ -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.
|
data/lib/pubid/xsf/builder.rb
CHANGED
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
|
-
#
|
|
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"
|