pubid 2.0.3 → 2.0.5
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/data/bipm/update_codes.yaml +14 -0
- data/data/parg/iho.json +1 -1
- data/data/parg/iho.parg +358 -0
- data/data/parg/jis.json +1 -1
- data/data/parg/parg.json +1 -1
- data/data/parg/parg.parg +118 -0
- data/lib/pubid/bipm/CLAUDE.md +2 -0
- data/lib/pubid/bipm.rb +10 -4
- data/lib/pubid/bsi/CLAUDE.md +4 -0
- data/lib/pubid/bsi/single_identifier.rb +10 -1
- data/lib/pubid/ieee/builder.rb +6 -1
- data/lib/pubid/ietf/CLAUDE.md +2 -0
- data/lib/pubid/ietf.rb +5 -2
- data/lib/pubid/iho/CLAUDE.md +157 -81
- data/lib/pubid/iho/builder.rb +61 -16
- data/lib/pubid/iho/identifier.rb +2 -2
- data/lib/pubid/iho/identifiers/base.rb +134 -37
- data/lib/pubid/iho/renderer.rb +44 -22
- data/lib/pubid/iho/urn_generator.rb +56 -19
- data/lib/pubid/iho/urn_parser.rb +211 -40
- data/lib/pubid/iso/CLAUDE.md +32 -0
- data/lib/pubid/iso/urn_parser.rb +63 -6
- data/lib/pubid/jis/CLAUDE.md +9 -4
- data/lib/pubid/parg/backend.rb +1 -1
- data/lib/pubid/parg/model.rb +7 -2
- data/lib/pubid/parg.rb +13 -0
- data/lib/pubid/tgpp/CLAUDE.md +2 -0
- data/lib/pubid/tgpp.rb +11 -0
- data/lib/pubid/version.rb +1 -1
- metadata +8 -12
data/lib/pubid/ietf/CLAUDE.md
CHANGED
|
@@ -5,3 +5,5 @@ IETF readiness for the unified relaton index.
|
|
|
5
5
|
These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/ietf/` or `spec/pubid/ietf/`. The root file keeps the cross-flavor contract that every flavor obeys.
|
|
6
6
|
|
|
7
7
|
- **IETF readiness for the unified relaton index (`relaton-data-ietf`)**: relaton is collapsing RFCs, the RFC sub-series (BCP/STD/FYI) and Internet-Drafts into **one** index keyed on the structured pubid hash (`pubid_class: ::Pubid::Ietf::Identifier`). That index is **all-or-nothing** — `Relaton::Index::FileIO#deserialize_id` raises on the *first* unparseable row and `#load_index` then rejects the whole index — so one bad id out of 176,862 means no consumer resolves *any* IETF reference. Four changes, all verified against the published corpora (`relaton-data-{rfcs,rfcsubseries,ids}`, branch `v2`). **(1) `draft_rest` accepts `.` and `A-Z`** (`match['a-zA-Z0-9+_.\-']`): exactly 50 of the 166,740 published draft ids failed without them (21 dotted `draft-ietf-pilc-2.5g3g-12`, 29 uppercase `draft-chapin-clnp-ISO8473-00`, 0 both), and a character census confirms the class now covers the corpus **exactly** — the only chars outside `[a-z0-9-]` anywhere in it are `. _ +` and `A-Z`. Widening is safe because the top-level alternation is keyed on the leading token (`RFC`/`BCP|STD|FYI`/`draft-`), so a wider draft tail can never make another branch match; `Builder#split_draft_version` needed no change (it inspects only the last three characters). The set stays **closed** — a space, `/` or `&` in a slug is crawler junk and still fails (pinned by negative examples). **(2) Zero-padded ids parse, grammar-level**: `rfc-index.xml` writes sub-series membership as `<is-also><doc-id>STD0066</doc-id></is-also>` (369 padded doc-ids) and RFCs as `RFC0001`, and relaton#109 forbids relaton normalizing them itself. The space is `.maybe` on both the `rfc` and `subseries` rules and `Builder#unpad` strips the pad (`/\A0+(?=\d)/`, a look-ahead so an all-zero number keeps its last digit), so `STD0066`/`STD 0066`/`STD66` all render the canonical `STD 66`. `unpad` is applied unconditionally, so the **spaced** padded form is normalized too — `RFC 0001` previously parsed and rendered back verbatim, and now renders `RFC 1` with a correspondingly changed hash and URN. That is deliberate (one canonical spelling per document) and has **zero** effect on the published corpora, which contain no padded row. **This is a normalizing parse, so the padded spellings must NEVER go into the byte-exact `spec/fixtures/ietf/identifiers/pass/`** — they live in `spec/pubid/ietf/zero_padded_spec.rb`, the BIPM `CIPM/2005-06(REV)` precedent. **(3) The Internet-Draft slug moved from `name` into `number`, and `series` became derived**: relaton bsearches on `id.root.number.to_s`, so with the slug in `name` all 166,740 drafts keyed to `""` and the narrowing bought nothing (with the slug: 43,564 buckets, median 2, worst 101). `name` is **dropped entirely** (no alias); the slug — leading `draft-` included — is the key, deliberately shared by a draft's unversioned aggregator row and every one of its versions so a document and its variants cluster. `series` is likewise **not stored**: `_type: pubid:ietf:std` already encodes it, so `Bcp`/`Std`/`Fyi` expose a `SERIES` constant + a plain `#series` reader (safe — `::Pubid::Identifier` declares no `series` attribute and nothing generic reads `.series`) and `build_subseries` stops passing it. The whole serialized vocabulary is now **three keys**: `_type` + `number` always, `version` only on a versioned draft — a sub-series row (`{_type, number}`) has the same shape as an RFC's. **The attributes live on the five LEAVES, not the shared `Pubid::Ietf::Identifier`**, via the `Identifiers::Serialization` mixin (`number` + the `key_value` block; `InternetDraft` merges its own `version` mapping on top — lutaml combines a mixin-installed block with a second in-class one, the ITU precedent). This is the IEEE `number` determinism landmine: a `:string` redefinition of the parent's `Components::Code number` on a class the leaves *inherit* from resolves nondeterministically under multi-flavor load. It was invisible while drafts left `number` nil; it is load-bearing now. The base deliberately carries **no** `key_value` block — one there would be inherited-and-merged by every leaf. `spec/pubid/ietf/root_number_spec.rb` is the tripwire (asserts `number` is a `String`, the exact keys, the exact `to_hash` key sets) and **must run under the full `rake` suite** to mean anything. **(4) Round-trip is validated here because relaton structurally cannot**: `FileIO#id_supported?` **skips** its `to_hash`/`from_hash` check whenever the object is a concrete subclass (`return true unless obj.instance_of?(@pubid_class)`), and *every* IETF id is a subclass — so a lossy round-trip would pass validation silently and produce wrong lookups. Three layers: `fixtures_spec.rb` is now **zero-failure** (was a 90% threshold — wrong for an all-or-nothing index) and checks `to_s` byte-exactness, `from_hash(to_hash)` fidelity in both `to_s` and `to_hash`, **and** a non-empty `root.number` for every fixture line; the fixtures grew to ~2,500 lines (the complete 365-row sub-series corpus, a 139-id structural edge set, a deterministic 1-in-84 draft sample — each file's header records the regeneration command); and `spec/pubid/ietf/corpus_round_trip_spec.rb` runs the same four checks over all 176,862 published ids, **opt-in** via `PUBID_IETF_CORPUS=<dir with the three relaton-data checkouts> bundle exec rake test:corpus_ietf` (self-skips otherwise; ~100s; verified 0 failures). **relaton note — the indexes must be regenerated, there is no alias**: `name` and `series` are gone as serialized keys, so a pre-`number` row (`{_type, name, version}`) deserializes with **`number` nil** and no error at all — lutaml ignores unknown keys, and relaton's subclass skip means `id_supported?` won't catch it either, leaving every draft keyed under `""`. Rendering is therefore the loud failure: `Renderer#require_number!` raises `ArgumentError` on a nil/empty `number` rather than returning `nil` or `"-02"`, and the renderer's sub-series arm is an explicit `when Bcp, Std, Fyi` with an `else raise` instead of a catch-all (a bare `Pubid::Ietf::Identifier` has no `series` reader). Pinned by the "legacy `name`-shaped hash" block in `root_number_spec.rb`. **Pre-existing bug fixed in passing**: `UrnParser`'s bare `Errors::ParseError` does not resolve — `Errors` lives under `Pubid::UrnParser` (the module), which is neither in the lexical scope of `Pubid::Ietf::UrnParser` nor an ancestor of `Base` — so an invalid URN raised `NameError` instead, and the spec's `raise_error(StandardError)` accepted it. Now fully qualified as `Pubid::UrnParser::Errors::ParseError` (the adobe/easc/gost/iala convention) with the spec asserting that exact class. **`Pubid::Iec::UrnParser` has the identical bug and is untouched here.** Also: `Identifier.parse` gained the inline `MAX_INPUT_LENGTH` guard — relaton reaches that class-level funnel directly through `pubid_class:`, bypassing `Pubid::Ietf.parse` — and IETF was added to the three cross-flavor tables it was missing from (`identifier_roundtrip_spec`, `uniform_identifier_handle_spec`, `redos_guard_spec`), which is what exercises the leaf `number` under real multi-flavor load. (hand-off: ietf-index-readiness.)
|
|
8
|
+
|
|
9
|
+
- **`IETF` is a prefix (pubid#644)**: `PREFIXES` gained the publisher token itself. `strip_prefix` (`lib/pubid/ietf/identifiers/base.rb`) already parsed the external spellings `IETF RFC 8341` / `IETF I-D draft-…`, and `to_s(with_publisher: true)` prints that same `IETF `-prefixed form — but without "IETF" in the prefix table the flavor had **no claim** on the string, and the router's non-exact fallback (`IETF RFC 2119` normalizes to the canonical `RFC 2119`) is reserved for prefix owners. So `Pubid.parse("IETF RFC 2119")` raised while `Pubid::Ietf.parse` succeeded. With the prefix registered, the router routes it (fallback path) and the `with_publisher: true` render reproduces the input byte for byte. Bare `RFC 2119` keeps routing exactly as before (exact round trip). Locked by `spec/pubid/router_printed_forms_spec.rb`.
|
data/lib/pubid/ietf.rb
CHANGED
|
@@ -11,8 +11,11 @@ module Pubid
|
|
|
11
11
|
extend Pubid::PrefixesSupport
|
|
12
12
|
|
|
13
13
|
# Leading tokens a printed IETF id can begin with. "draft" covers the
|
|
14
|
-
# "draft-<slug>" Internet-Draft form.
|
|
15
|
-
|
|
14
|
+
# "draft-<slug>" Internet-Draft form. "IETF" covers the external
|
|
15
|
+
# publisher-prefixed spelling ("IETF RFC 2119", "IETF I-D draft-…") that
|
|
16
|
+
# the flavor's own strip_prefix parses and `to_s(with_publisher: true)`
|
|
17
|
+
# prints back. No joint/co-publication prefixes.
|
|
18
|
+
PREFIXES = ["IETF", "RFC", "BCP", "STD", "FYI", "draft"].freeze
|
|
16
19
|
|
|
17
20
|
autoload :Builder, "#{__dir__}/ietf/builder"
|
|
18
21
|
autoload :Identifier, "#{__dir__}/ietf/identifiers/base"
|
data/lib/pubid/iho/CLAUDE.md
CHANGED
|
@@ -1,98 +1,174 @@
|
|
|
1
1
|
# IHO flavor notes
|
|
2
2
|
|
|
3
3
|
IHO (International Hydrographic Organization) identifiers: the
|
|
4
|
-
S/P/M/B/C letter series, their parg-driven parse, and the
|
|
5
|
-
|
|
4
|
+
S/P/M/B/C letter series, their parg-driven parse, and the full
|
|
5
|
+
component model (edition, supplement, part, annex, appendix +
|
|
6
|
+
subnumber, volume, issue, with-year qualifier) from the owner rulings
|
|
7
|
+
of 2026-11-03.
|
|
6
8
|
|
|
7
9
|
Read this before you change `lib/pubid/iho/` or `spec/pubid/iho/`. The
|
|
8
10
|
root `CLAUDE.md` keeps the cross-flavor contract that every flavor
|
|
9
11
|
obeys.
|
|
10
12
|
|
|
11
|
-
## Parsing runs on the parg artifact
|
|
13
|
+
## Parsing runs on the parg artifact, through typed bindings
|
|
12
14
|
|
|
13
15
|
There is no `parser.rb` and no Parslet anywhere in this flavor: parse
|
|
14
|
-
goes `Pubid::Parg::
|
|
15
|
-
`
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
the artifact
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
The
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
`
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`
|
|
53
|
-
the
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
(`
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
16
|
+
goes `Pubid::Parg::Model.bound(:iho, ...)` — the artifact's
|
|
17
|
+
`bindings identifier` block delivers every component as an
|
|
18
|
+
attribute-named scalar, and `Builder` only dispatches the series
|
|
19
|
+
letter to its concrete class and pads one grammar-level alias (the
|
|
20
|
+
S-100 `v1`/`v5.1` shorthand fills `edition` as `1.0.0`/`5.1.0`). The
|
|
21
|
+
artifact is baked from `grammars/iho.parg` in pubid-grammar; the
|
|
22
|
+
vendored twin `data/parg/iho.parg` is the embedded source's provenance
|
|
23
|
+
copy and must move together with `data/parg/iho.json` (the ISO
|
|
24
|
+
pattern).
|
|
25
|
+
|
|
26
|
+
**Bake pin:** the artifact must be baked at parsanol 1.3.126 (the
|
|
27
|
+
grammar repo's `scripts/bake-one.rb` forces the gem version and
|
|
28
|
+
verifies the `compiler_version` stamp). 1.3.127 is broken fleet-wide
|
|
29
|
+
(parsanol-ruby#434 — its loud-loss check matches the binding PATH
|
|
30
|
+
instead of the CAPTURE name), so both repos pin below it: the grammar
|
|
31
|
+
repo's CI moved 1.3.121 → 1.3.126 with the component-model PR.
|
|
32
|
+
|
|
33
|
+
## The component model (owner rulings, 2026-11-03)
|
|
34
|
+
|
|
35
|
+
Every component is a FIRST-CLASS SEMANTIC component: a typed `:string`
|
|
36
|
+
attribute, independently addressable, URN-encodable and matchable —
|
|
37
|
+
never a string suffix. All of them live flat on `Pubid::Iho::Identifier`
|
|
38
|
+
(mapped through the flavor `key_value` block, no converters):
|
|
39
|
+
|
|
40
|
+
| component | attribute | canonical face | parse-only aliases |
|
|
41
|
+
|-------------|--------------------|-------------------|------------------------------|
|
|
42
|
+
| edition | `edition` | `1.0.0` (bare) | `v1`/`v5.1` (padded) |
|
|
43
|
+
| supplement | `supplement` | `Sup 1` | `Suppl 1` |
|
|
44
|
+
| part | `part` | `Part A`/`Part 1`/`Part S-102` | `S-5-B`, `S-100-1`, `S-158-S-102` (compact) |
|
|
45
|
+
| annex | `annex` | `Annex A` | — |
|
|
46
|
+
| appendix | `appendix` | `Appendix D` | `App`, `Ap.` |
|
|
47
|
+
| subnumber | `appendix_subnumber` | `-2` (joined) | — |
|
|
48
|
+
| volume | `volume` | `Vol. 30`/`Vol. 2024` | `/21` (slash chain), bare `2024` |
|
|
49
|
+
| issue | `issue` | `(2)` | `Issue 2` |
|
|
50
|
+
| year (qualifier) | `year` | `(2024)` | — |
|
|
51
|
+
| component edition | `<kind>_edition` (+ `<kind>_year`) | `<kind> x.y.z (2024)` | — |
|
|
52
|
+
|
|
53
|
+
- **Two main syntaxes:** `IHO S-100 1.0.0` (dated = edition-specific)
|
|
54
|
+
and `IHO S-100` (undated = edition-non-specific). The official IHO
|
|
55
|
+
edition is the dotted x.y.z WITHOUT a year; a year beside an edition
|
|
56
|
+
is the with-year qualifier, never a year-keyed docidentifier. The
|
|
57
|
+
canonical part face is edition-before-part: `IHO S-100 1.0.0 Part 2`.
|
|
58
|
+
- **The part's compact face is hyphenated** (`IHO S-5-B`,
|
|
59
|
+
`IHO S-100-1`) — it parses AND renders via `to_s(compact: true)`.
|
|
60
|
+
Gluing without a separator is disallowed as a form: it cannot extend
|
|
61
|
+
to numbered parts unambiguously (`S-1001`). The four lettered glued
|
|
62
|
+
bases in the records (S-5A, S-5B, S-8A, S-8B) are LEGACY parse-only
|
|
63
|
+
aliases, never rendered; the owner rules later whether to reject
|
|
64
|
+
them. The dash-digit suffix that used to join the number (`P-6-3` as
|
|
65
|
+
number "6-3") is now the part component.
|
|
66
|
+
- **One serial locator: the volume.** P-1 and P-6 are volume-keyed;
|
|
67
|
+
the yearbook year IS a volume (`IHO P-5 Vol. 2024`, short
|
|
68
|
+
`IHO P-5/2024`). The bare year (`IHO P-5 2024`) is a legacy alias
|
|
69
|
+
that binds INTO `volume` — `year` never carries a serial locator.
|
|
70
|
+
- **The with-year qualifier** (`IHO S-100 5.0.0 Part 2 (2024)`) is the
|
|
71
|
+
only base `year` face, always parenthesized beside an edition.
|
|
72
|
+
- **Component-edition duality** (owner: "this can happen with any
|
|
73
|
+
independently publishable artifacts"): supplement, part, annex,
|
|
74
|
+
appendix and volume each carry an optional `<kind>_edition` (+
|
|
75
|
+
`<kind>_year`) of their own, MUTUALLY EXCLUSIVE with the base edition
|
|
76
|
+
context — within-edition (`IHO P-6 1.0.0 Vol. 2`, the component rides
|
|
77
|
+
the base's edition) vs separately-published (`IHO P-6 Vol. 2 1.0.1`,
|
|
78
|
+
`IHO S-98 Part A 1.0.0`, `IHO S-158 Part S-102 0.2.0`; the base is
|
|
79
|
+
cited edition-less). The builder raises when both appear. Every
|
|
80
|
+
trailing edition now follows a component, so the historical
|
|
81
|
+
edition-last spellings reinterpret as component editions.
|
|
82
|
+
- **Series-reference parts**: a check standard applied to another IHO
|
|
83
|
+
series cites it as its part — `IHO S-158 0.2.0 Part S-102`, compact
|
|
84
|
+
`IHO S-158-S-102` (the part value vocabulary is letter | number |
|
|
85
|
+
`[SPMBC]-<digits>`). The wild glued `S-158-102` stays a plain number
|
|
86
|
+
part: the syntax cannot distinguish it from `S-100-1` = Part 1, and
|
|
87
|
+
normalizing it needs an IHO series table (owner call pending).
|
|
88
|
+
- **Supplement canonical face is `Sup`** (the M-2 spelling — why save
|
|
89
|
+
two characters when Annex and Appendix stay full-length); `Suppl`
|
|
90
|
+
(the S-66 wild spelling) is the parse-only alias, never rendered.
|
|
91
|
+
- **Appendix canonical face is `Appendix`**; the subnumber keeps its
|
|
92
|
+
own component (`Appendix D` + `appendix_subnumber` "2" render joined
|
|
93
|
+
as `Appendix D-2`).
|
|
94
|
+
|
|
95
|
+
The compact face (`compact: true`) folds the code-adjacent components
|
|
96
|
+
onto the code: supplement and part hyphenated, volume as the slash
|
|
97
|
+
chain — `IHO M-2-2`, `IHO S-100-2 5.0.0`, `IHO P-5/2024`.
|
|
98
|
+
|
|
99
|
+
## Identity semantics
|
|
100
|
+
|
|
101
|
+
- The components are identity-bearing in `==`/`matches?`; a bare
|
|
102
|
+
reference (`IHO P-5`) leaves them all nil and wildcards the series
|
|
103
|
+
(`spec/pubid/partial_ref_spec.rb` locks the omitted-key set:
|
|
104
|
+
edition/year/volume/issue).
|
|
105
|
+
- Scalar **clusters**: `exclude(:volume)` nils `issue` with it, and
|
|
106
|
+
`exclude(:appendix)` nils `appendix_subnumber` — a reference that
|
|
107
|
+
omits the finer component still matches a hit that carries one (the
|
|
108
|
+
base's additive `:year -> :date` alias shape).
|
|
109
|
+
- `all_parts_edition_keys` needs no override: the default
|
|
110
|
+
`%i[date year edition version]` already covers `:year` and
|
|
111
|
+
`:edition`.
|
|
112
|
+
|
|
113
|
+
## URN scheme — positional, containment-ordered
|
|
114
|
+
|
|
115
|
+
The URN mirrors the containment tree, one semantic atom per segment;
|
|
116
|
+
the segment grammar table lives in the grammar header
|
|
117
|
+
(`grammars/iho.parg`) and is implemented by
|
|
118
|
+
`urn_generator.rb`/`urn_parser.rb`:
|
|
119
|
+
|
|
120
|
+
urn:iho:<type>:<number>[:ed-<x.y.z>][:sup-<n>][:part-<X>]
|
|
121
|
+
[:annex-<A>][:appendix-<B>][:<sub>][:vol-<n>][:issue-<n>]
|
|
122
|
+
[:year-<yyyy>]
|
|
123
|
+
|
|
124
|
+
- Markers are the lowercased face tokens (`ed-` follows ISO's
|
|
125
|
+
RFC 5141 precedent); letters are case-preserved.
|
|
126
|
+
- `ed-` and `year-` are POSITIONAL: the edition attaches to the node it
|
|
127
|
+
follows — the base when no component precedes it, else that
|
|
128
|
+
component's own edition (`vol-2:ed-1.0.1` = separately-published
|
|
129
|
+
volume). A `year-` belongs to a component only when it follows that
|
|
130
|
+
component's own edition; otherwise it is the base with-year
|
|
131
|
+
qualifier.
|
|
132
|
+
- Containment: part ⊃ annex ⊃ appendix ⊃ subnumber (bare digit atom,
|
|
133
|
+
only directly after its appendix); vol ⊃ issue.
|
|
134
|
+
- The number occupies every leading bare segment up to the first
|
|
135
|
+
marker, so a colon number (`S-158:100` → `urn:iho:s:158:100`)
|
|
136
|
+
round-trips without escaping. A colon number and a volume stay
|
|
137
|
+
distinct because the volume is marker-tagged (`urn:iho:s:158:2024`
|
|
138
|
+
vs `urn:iho:s:158:vol-2024` — the #637 hazard, kept under the
|
|
139
|
+
positional scheme).
|
|
140
|
+
- The yearbook year emits as the volume (`urn:iho:p:5:vol-2024`);
|
|
141
|
+
`year-` exists only for the standards qualifier.
|
|
142
|
+
- The parser validates every marker value against its own pattern and
|
|
143
|
+
rejects unknown/reordered/duplicate/junk segments with
|
|
144
|
+
`Pubid::Errors::UrnParseError` (the ISO urn_parser strictness); it
|
|
145
|
+
builds through `Builder.build` from the decoded attributes, so URN
|
|
146
|
+
decode and string parse share one construction path.
|
|
147
|
+
|
|
148
|
+
**Breaking URN migrations this scheme carries** (legacy forms stay
|
|
149
|
+
decodable, never emitted): the dotted keys (`part.4a`, `suppl.1`,
|
|
150
|
+
`ap.A`, `annex.A`, `vol.30`, `issue.2`, `appendix.D-2`) and the bare
|
|
151
|
+
a.b.c edition segment move to the marker segments; the #635/#637
|
|
152
|
+
`y.2024` tag decodes into the **volume** (`urn:iho:p:5:y.2024` now
|
|
153
|
+
re-emits as `urn:iho:p:5:vol-2024`). relaton-data-iho needs a re-crawl
|
|
154
|
+
(it is already legacy pre-pubid-2 shape); the frozen testsuite corpus
|
|
155
|
+
rows are re-frozen in the coupled corpus round.
|
|
80
156
|
|
|
81
157
|
## Fixtures run through the classify pipeline
|
|
82
158
|
|
|
83
159
|
`spec/fixtures/iho/identifiers/full/identifiers.txt` is the input
|
|
84
|
-
corpus
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
160
|
+
corpus (seeded from the hand-curated pass files, the #407 serial
|
|
161
|
+
forms and the component-model matrix off the IHO publications
|
|
162
|
+
spreadsheet); `rake "validation:classify[iho]"` regenerates `pass/`
|
|
163
|
+
and `fail/` from it (the classifier deletes pass/fail first — never
|
|
164
|
+
edit them by hand, edit `full/` and reclassify). The normalizing
|
|
165
|
+
parses (Suppl→Sup, Ap.→Appendix, glued→expanded, bare year→Vol.,
|
|
166
|
+
v-shorthand→dotted) are recorded as `!input!rendered` entries.
|
|
91
167
|
|
|
92
168
|
## relaton-side follow-ups (not this repo)
|
|
93
169
|
|
|
94
|
-
|
|
95
|
-
`
|
|
96
|
-
|
|
97
|
-
`
|
|
98
|
-
in step with the relaton-data-iho re-key.
|
|
170
|
+
`Relaton::Iho::Bibliography#pubid_match?`/`version_key` need the
|
|
171
|
+
edition rename (the serialized key moved `version` → `edition`) and
|
|
172
|
+
the volume unification; relaton-cli's `MachineIndex::REFINABLE_KEYS`
|
|
173
|
+
needs `edition`/`volume` beside the retired `version`/`year` keys; the
|
|
174
|
+
pubid pin moves in step with the relaton-data-iho re-key.
|
data/lib/pubid/iho/builder.rb
CHANGED
|
@@ -2,25 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
module Pubid
|
|
4
4
|
module Iho
|
|
5
|
-
# Build IHO identifier objects from the
|
|
5
|
+
# Build IHO identifier objects from the grammar's typed bound map.
|
|
6
|
+
#
|
|
7
|
+
# Parse goes through {Pubid::Parg::Model.bound} — the artifact's
|
|
8
|
+
# `bindings identifier` block delivers every component as an
|
|
9
|
+
# attribute-named scalar (type, number, part, annex, appendix,
|
|
10
|
+
# appendix_subnumber, supplement, edition, edition_shorthand, year,
|
|
11
|
+
# volume, issue) — so the builder only dispatches the series letter
|
|
12
|
+
# to its concrete class and resolves the one grammar-level alias:
|
|
13
|
+
# the S-100 "v1"/"v5.1" edition shorthand pads to the official
|
|
14
|
+
# dotted x.y.z edition ("1" -> "1.0.0", "5.1" -> "5.1.0").
|
|
6
15
|
class Builder
|
|
16
|
+
ATTRIBUTES = %i[number appendix appendix_subnumber part annex
|
|
17
|
+
supplement year volume issue
|
|
18
|
+
part_edition part_year supplement_edition
|
|
19
|
+
supplement_year annex_edition annex_year
|
|
20
|
+
appendix_edition appendix_year volume_edition
|
|
21
|
+
volume_year].freeze
|
|
22
|
+
# The independently-publishable components' own editions; the
|
|
23
|
+
# base edition context is mutually exclusive with them (owner
|
|
24
|
+
# ruling on the two publication modes).
|
|
25
|
+
COMPONENT_EDITIONS = %i[part_edition supplement_edition annex_edition
|
|
26
|
+
appendix_edition volume_edition].freeze
|
|
27
|
+
|
|
7
28
|
def build(parsed)
|
|
8
29
|
hash = parsed.is_a?(Array) ? parsed.reduce({}, :merge) : parsed
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
appendix: stringify(hash[:appendix]),
|
|
14
|
-
part: stringify(hash[:part]),
|
|
15
|
-
annex: stringify(hash[:annex]),
|
|
16
|
-
supplement: stringify(hash[:supplement]),
|
|
17
|
-
version: stringify(hash[:version]),
|
|
18
|
-
year: stringify(hash[:year]),
|
|
19
|
-
volume: stringify(hash[:volume]),
|
|
20
|
-
issue: stringify(hash[:issue]),
|
|
21
|
-
}.compact
|
|
22
|
-
|
|
23
|
-
Pubid::Iho.identifier_klass_for_type_letter(type_letter).new(**attrs)
|
|
30
|
+
attrs = stringified_attrs(hash)
|
|
31
|
+
assert_edition_mode!(attrs)
|
|
32
|
+
klass = Pubid::Iho.identifier_klass_for_type_letter(stringify(hash[:type]))
|
|
33
|
+
klass.new(**attrs)
|
|
24
34
|
end
|
|
25
35
|
|
|
26
36
|
def self.build(parsed)
|
|
@@ -29,6 +39,41 @@ module Pubid
|
|
|
29
39
|
|
|
30
40
|
private
|
|
31
41
|
|
|
42
|
+
# The two publication modes are mutually exclusive: an identifier
|
|
43
|
+
# is either within-edition (a base edition its components ride) or
|
|
44
|
+
# separately-published (a component carries its own edition and
|
|
45
|
+
# the base is cited edition-less).
|
|
46
|
+
def assert_edition_mode!(attrs)
|
|
47
|
+
return unless attrs[:edition]
|
|
48
|
+
return unless COMPONENT_EDITIONS.any? { |key| attrs[key] }
|
|
49
|
+
|
|
50
|
+
owned = attrs.slice(*COMPONENT_EDITIONS).compact.keys.join(", ")
|
|
51
|
+
raise ArgumentError,
|
|
52
|
+
"a base edition and a component edition are mutually " \
|
|
53
|
+
"exclusive: #{owned} cannot ride edition #{attrs[:edition]}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stringified_attrs(hash)
|
|
57
|
+
attrs = ATTRIBUTES.to_h { |key| [key, stringify(hash[key])] }
|
|
58
|
+
attrs[:edition] = edition_from(hash[:edition], hash[:edition_shorthand])
|
|
59
|
+
attrs.compact
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The official IHO edition is the dotted x.y.z; the "v1"/"v5.1"
|
|
63
|
+
# shorthand is a parse-only alias that fills the same attribute.
|
|
64
|
+
def edition_from(edition, shorthand)
|
|
65
|
+
return stringify(edition) if edition
|
|
66
|
+
|
|
67
|
+
pad_edition_shorthand(shorthand)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def pad_edition_shorthand(shorthand)
|
|
71
|
+
return nil if shorthand.nil?
|
|
72
|
+
|
|
73
|
+
major, minor = stringify(shorthand).split(".")
|
|
74
|
+
"#{major}.#{minor || '0'}.0"
|
|
75
|
+
end
|
|
76
|
+
|
|
32
77
|
def stringify(value)
|
|
33
78
|
return nil if value.nil?
|
|
34
79
|
|
data/lib/pubid/iho/identifier.rb
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
# The IHO flavor base class is Pubid::Iho::Identifier — a real Pubid::Identifier
|
|
4
4
|
# subclass that every concrete IHO identifier descends from, so `is_a?` identity
|
|
5
5
|
# and the shared polymorphic `from_hash` work natively (no facade needed). Its
|
|
6
|
-
# body and `.parse` live in identifiers/base.rb; this file just ensures
|
|
7
|
-
# references Pubid::Iho::Identifier directly.
|
|
6
|
+
# body and `.parse` live in identifiers/base.rb; this file just ensures
|
|
7
|
+
# it is loaded when a consumer references Pubid::Iho::Identifier directly.
|
|
@@ -6,10 +6,17 @@ module Pubid
|
|
|
6
6
|
# every concrete IHO identifier (Identifiers::*) descends from it.
|
|
7
7
|
#
|
|
8
8
|
# IHO identifiers have the form:
|
|
9
|
-
# IHO {S|P|M|B|C}-{number}[
|
|
9
|
+
# IHO {S|P|M|B|C}-{number}[ {edition}| Vol. N [(issue)]]
|
|
10
|
+
# [ Sup N][ Part X][ Annex A][ Appendix B[-2]] [(2024)]
|
|
10
11
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
12
|
+
# Every tail component is a first-class semantic component: a typed
|
|
13
|
+
# attribute, independently addressable, URN-encodable and matchable.
|
|
14
|
+
# The canonical faces order the edition BEFORE the part
|
|
15
|
+
# (`IHO S-100 1.0.0 Part 2`), print the supplement as "Sup" and the
|
|
16
|
+
# appendix as "Appendix" (with the subnumber after a dash), the
|
|
17
|
+
# volume issue parenthesized (`IHO P-1 Vol. 30 (2)`), and the
|
|
18
|
+
# with-year qualifier parenthesized (`IHO S-100 5.0.0 Part 2 (2024)`
|
|
19
|
+
# — the only year face: a periodical year IS a volume).
|
|
13
20
|
class Identifier < ::Pubid::Identifier
|
|
14
21
|
# Parse an IHO identifier string into an identifier object
|
|
15
22
|
# @param identifier [String] The IHO identifier string to parse
|
|
@@ -25,32 +32,55 @@ module Pubid
|
|
|
25
32
|
raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
|
|
26
33
|
end
|
|
27
34
|
|
|
28
|
-
parsed = Pubid::Parg::
|
|
35
|
+
parsed = Pubid::Parg::Model.bound(:iho, identifier.strip)
|
|
29
36
|
Builder.build(parsed)
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
# `number` overrides the generic Components::Code attribute with a plain
|
|
33
|
-
# string (same pattern as
|
|
34
|
-
# number (e.g. "100", "1/21"
|
|
35
|
-
#
|
|
40
|
+
# string (same pattern as the other string components); it holds the IHO
|
|
41
|
+
# document number (e.g. "100", "1/21" until the slash-chain volume
|
|
42
|
+
# parse, "158:100"). Kept as the generic `number` so the relaton index
|
|
43
|
+
# sorts/binary-searches on it natively via `id.number`.
|
|
36
44
|
attribute :number, :string
|
|
37
45
|
attribute :publisher, :string, default: "IHO"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
attribute :
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
# (P-
|
|
46
|
-
#
|
|
47
|
-
# scalars, like every other IHO attribute; the grammar's tail
|
|
48
|
-
# picks exactly one discriminator, so only one of
|
|
49
|
-
# version/year/volume is ever present, with issue only
|
|
50
|
-
# alongside volume.
|
|
46
|
+
# The official IHO edition is the dotted x.y.z (a plain string — the
|
|
47
|
+
# ~16 string-edition flavors precede this). `IHO S-100 1.0.0` is
|
|
48
|
+
# edition-specific, `IHO S-100` edition-non-specific; a year beside an
|
|
49
|
+
# edition is the with-year qualifier, never a year-keyed docidentifier.
|
|
50
|
+
attribute :edition, :string
|
|
51
|
+
# The with-year qualifier on standards (`(2024)` beside an edition) —
|
|
52
|
+
# the only year component. A periodical year IS a volume: the bare
|
|
53
|
+
# legacy year input ("IHO P-5 2024") binds into `volume`, so this
|
|
54
|
+
# attribute never carries a serial locator.
|
|
51
55
|
attribute :year, :string
|
|
56
|
+
# Supplement, part, annex, appendix and volume are independently
|
|
57
|
+
# publishable (owner ruling: "this can happen with any independently
|
|
58
|
+
# publishable artifacts"), so each carries an optional edition (+ year
|
|
59
|
+
# qualifier) of its own, mutually exclusive with the base edition
|
|
60
|
+
# context: `IHO P-6 1.0.0 Vol. 2` (within-edition) vs
|
|
61
|
+
# `IHO P-6 Vol. 2 1.0.1` (separately-published). The URN attaches
|
|
62
|
+
# ed- to the node it follows, so one positional slot records both.
|
|
63
|
+
attribute :supplement, :string
|
|
64
|
+
attribute :supplement_edition, :string
|
|
65
|
+
attribute :supplement_year, :string
|
|
66
|
+
attribute :part, :string
|
|
67
|
+
attribute :part_edition, :string
|
|
68
|
+
attribute :part_year, :string
|
|
69
|
+
attribute :annex, :string
|
|
70
|
+
attribute :annex_edition, :string
|
|
71
|
+
attribute :annex_year, :string
|
|
72
|
+
# The appendix is a letter or a number; a subnumbered appendix
|
|
73
|
+
# ("Appendix D-2") keeps the letter in `appendix` and the "2" in
|
|
74
|
+
# `appendix_subnumber` — two independently addressable components,
|
|
75
|
+
# not one string.
|
|
76
|
+
attribute :appendix, :string
|
|
77
|
+
attribute :appendix_subnumber, :string
|
|
78
|
+
attribute :appendix_edition, :string
|
|
79
|
+
attribute :appendix_year, :string
|
|
52
80
|
attribute :volume, :string
|
|
53
81
|
attribute :issue, :string
|
|
82
|
+
attribute :volume_edition, :string
|
|
83
|
+
attribute :volume_year, :string
|
|
54
84
|
|
|
55
85
|
# Polymorphic type map for lutaml::Model key_value serialization: maps
|
|
56
86
|
# each subclass's polymorphic_name to its class name so `from_hash` can
|
|
@@ -71,31 +101,98 @@ module Pubid
|
|
|
71
101
|
key_value do
|
|
72
102
|
map "_type", to: :_type, polymorphic_map: IHO_TYPE_MAP
|
|
73
103
|
map "number", to: :number
|
|
74
|
-
map "
|
|
75
|
-
map "part", to: :part
|
|
76
|
-
map "annex", to: :annex
|
|
77
|
-
map "supplement", to: :supplement
|
|
78
|
-
map "version", to: :version
|
|
104
|
+
map "edition", to: :edition
|
|
79
105
|
map "year", to: :year
|
|
80
|
-
map "
|
|
81
|
-
map "
|
|
106
|
+
map "supplement", to: :supplement
|
|
107
|
+
map "supplement_edition", to: :supplement_edition
|
|
108
|
+
map "supplement_year", to: :supplement_year
|
|
109
|
+
map "part", to: :part
|
|
110
|
+
map "part_edition", to: :part_edition
|
|
111
|
+
map "part_year", to: :part_year
|
|
112
|
+
map "annex", to: :annex
|
|
113
|
+
map "annex_edition", to: :annex_edition
|
|
114
|
+
map "annex_year", to: :annex_year
|
|
115
|
+
map "appendix", to: :appendix
|
|
116
|
+
map "appendix_subnumber", to: :appendix_subnumber
|
|
117
|
+
map "appendix_edition", to: :appendix_edition
|
|
118
|
+
map "appendix_year", to: :appendix_year
|
|
119
|
+
map "volume", to: :volume
|
|
120
|
+
map "volume_edition", to: :volume_edition
|
|
121
|
+
map "volume_year", to: :volume_year
|
|
122
|
+
map "issue", to: :issue
|
|
82
123
|
end
|
|
83
124
|
|
|
84
125
|
# from_hash is the shared polymorphic dispatch on Pubid::Identifier.
|
|
85
|
-
# IHO_TYPE_MAP remains
|
|
86
|
-
|
|
87
|
-
# The volume/issue
|
|
88
|
-
# the
|
|
89
|
-
# vs "P-1 Vol. 30 (2)"), so excluding
|
|
90
|
-
#
|
|
91
|
-
# :year -> :date. A lone
|
|
92
|
-
#
|
|
93
|
-
# alias nils whichever name the
|
|
126
|
+
# IHO_TYPE_MAP remains the key_value polymorphic_map.
|
|
127
|
+
|
|
128
|
+
# The volume/issue and appendix/subnumber pairs are scalar clusters:
|
|
129
|
+
# a reference that omits the finer component must still match a hit
|
|
130
|
+
# that carries one ("P-1 Vol. 30" vs "P-1 Vol. 30 (2)"), so excluding
|
|
131
|
+
# the coarser component nils the finer one with it — the same
|
|
132
|
+
# additive-alias shape the base applies to :year -> :date. A lone
|
|
133
|
+
# scalar :year needs no override here: the base nils a declared :year
|
|
134
|
+
# directly (the additive :year -> :date alias nils whichever name the
|
|
135
|
+
# flavor declares).
|
|
94
136
|
def exclude(*args)
|
|
137
|
+
args = args.dup
|
|
95
138
|
args << :issue if args.include?(:volume)
|
|
139
|
+
args << :appendix_subnumber if args.include?(:appendix)
|
|
96
140
|
super
|
|
97
141
|
end
|
|
98
|
-
end
|
|
99
142
|
|
|
143
|
+
# Render the compact face: the code-adjacent components folded onto
|
|
144
|
+
# the code with separators — the supplement and the part hyphenated
|
|
145
|
+
# (`IHO M-2-2` for `IHO M-2 Sup 2`, `IHO S-5-B` for `IHO S-5 Part B`,
|
|
146
|
+
# `IHO S-100-1` for `IHO S-100 Part 1`) and the volume as the slash
|
|
147
|
+
# chain (`IHO P-5/2024` for `IHO P-5 Vol. 2024`). The canonical
|
|
148
|
+
# render stays expanded; compact is opt-in. The legacy faces (glued
|
|
149
|
+
# lettered bases, Suppl, bare years) parse but are never rendered in
|
|
150
|
+
# any form.
|
|
151
|
+
def render(format: :human, **opts)
|
|
152
|
+
compact = opts.delete(:compact)
|
|
153
|
+
result = super
|
|
154
|
+
compact && format == :human ? compact_face(result) : result
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The shared mr_string path reads `edition&.number` (the base
|
|
158
|
+
# declares edition as Components::Edition); IHO's edition is a plain
|
|
159
|
+
# string, so the segment is built from it directly.
|
|
160
|
+
def mr_edition
|
|
161
|
+
return nil unless edition
|
|
162
|
+
|
|
163
|
+
"ed#{edition}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# Fold " Sup N" / " Part X" / " Vol. N" into the code as "-N" /
|
|
169
|
+
# "-X" / "/N", keeping everything else in place (the edition
|
|
170
|
+
# follows the compact code, as in the legacy "S-5A 1.0.2" print
|
|
171
|
+
# order). The issue keeps its parenthesized print.
|
|
172
|
+
def compact_face(rendered)
|
|
173
|
+
result = rendered.sub(/\A#{publisher} #{base_code}(?= |$)/,
|
|
174
|
+
"#{publisher} #{compact_code}")
|
|
175
|
+
folded_segments.each { |segment| result = result.sub(segment, "") }
|
|
176
|
+
result
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def base_code
|
|
180
|
+
"#{self.class.type[:short]}-#{number}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def compact_code
|
|
184
|
+
code = base_code.dup
|
|
185
|
+
code << "-#{supplement}" if supplement
|
|
186
|
+
code << "-#{part}" if part
|
|
187
|
+
code << "/#{volume}" if volume
|
|
188
|
+
code
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def folded_segments
|
|
192
|
+
[supplement && " Sup #{supplement}",
|
|
193
|
+
part && " Part #{part}",
|
|
194
|
+
volume && " Vol. #{volume}"].compact
|
|
195
|
+
end
|
|
196
|
+
end
|
|
100
197
|
end
|
|
101
198
|
end
|