pubid 2.0.4 → 2.0.6
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/bipm.json +1 -1
- data/data/parg/bipm.parg +290 -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 +4 -0
- data/lib/pubid/bipm/builder.rb +26 -1
- data/lib/pubid/bipm/identifiers/committee_document.rb +13 -1
- data/lib/pubid/bipm/renderer.rb +18 -3
- data/lib/pubid/bipm/urn_generator.rb +14 -1
- data/lib/pubid/bipm/urn_parser.rb +14 -1
- 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 +5 -2
data/lib/pubid/iho/urn_parser.rb
CHANGED
|
@@ -2,69 +2,240 @@
|
|
|
2
2
|
|
|
3
3
|
module Pubid
|
|
4
4
|
module Iho
|
|
5
|
-
# Parses IHO URNs back into identifiers
|
|
5
|
+
# Parses IHO URNs back into identifiers through the documented
|
|
6
|
+
# positional segment grammar (the same grammar the generator emits;
|
|
7
|
+
# the segment table lives in the grammar header and the flavor notes):
|
|
6
8
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
+
# urn:iho:<type>:<number>[:ed-<x.y.z>][:sup-<n>][:part-<X>]
|
|
10
|
+
# [:annex-<A>][:appendix-<B>][:<sub>][:vol-<n>][:issue-<n>]
|
|
11
|
+
# [:year-<yyyy>]
|
|
9
12
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
+
# Segment discipline (mirrors the ISO URN parser's strictness):
|
|
14
|
+
# - the docnumber occupies every leading bare number segment up to
|
|
15
|
+
# the first marker, so a colon number ("158:100") round-trips
|
|
16
|
+
# without escaping;
|
|
17
|
+
# - every other component is a marker-prefixed segment validated
|
|
18
|
+
# against its own value pattern — unknown, reordered or junk
|
|
19
|
+
# segments raise {Pubid::Errors::UrnParseError}, never
|
|
20
|
+
# mis-consume;
|
|
21
|
+
# - the appendix subnumber is a bare digit atom allowed only
|
|
22
|
+
# directly after its appendix segment (a year is always
|
|
23
|
+
# marker-tagged, so a bare digit there can only be the subnumber);
|
|
24
|
+
# - the issue is allowed only beside its volume and validated as
|
|
25
|
+
# digits.
|
|
13
26
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
27
|
+
# Legacy 2.0.x spellings remain decodable but are never emitted:
|
|
28
|
+
# the bare a.b.c edition segment, the dotted keys part./annex./
|
|
29
|
+
# suppl./ap./appendix./vol./issue. and the single-letter y. year tag
|
|
30
|
+
# (the #637 scheme the positional grammar replaces; the legacy
|
|
31
|
+
# appendix values stored the subnumber inside one string, "D-2",
|
|
32
|
+
# which decodes into the two components).
|
|
17
33
|
#
|
|
18
34
|
# Examples:
|
|
19
|
-
# - urn:iho:s:44:5.0.0
|
|
20
|
-
# - urn:iho:s:100:
|
|
21
|
-
# - urn:iho:s:
|
|
22
|
-
# - urn:iho:
|
|
23
|
-
# - urn:iho:p:
|
|
24
|
-
# - urn:iho:
|
|
25
|
-
# - urn:iho:p:1:vol.30:issue.2 → IHO P-1 Vol. 30 (2)
|
|
35
|
+
# - urn:iho:s:44:ed-5.0.0 → IHO S-44 5.0.0
|
|
36
|
+
# - urn:iho:s:100:ed-5.0.0:part-2 → IHO S-100 5.0.0 Part 2
|
|
37
|
+
# - urn:iho:s:122:ed-1.0.0:appendix-D:2 → IHO S-122 1.0.0 Appendix D-2
|
|
38
|
+
# - urn:iho:p:5:vol-2024 → IHO P-5 Vol. 2024
|
|
39
|
+
# - urn:iho:p:1:vol-30:issue-2 → IHO P-1 Vol. 30 (2)
|
|
40
|
+
# - urn:iho:s:158:100 → IHO S-158:100
|
|
26
41
|
class UrnParser < Pubid::UrnParser::Base
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
42
|
+
TYPE_LETTERS = %w[s p m b c].freeze
|
|
43
|
+
|
|
44
|
+
# Marker => attribute, with the value pattern each marker's slot
|
|
45
|
+
# accepts. The patterns mirror the grammar's own production rules
|
|
46
|
+
# (upper-case letters are case-preserved; "part" allows the
|
|
47
|
+
# digits-then-letters compound like "4a" beside a lone letter; the
|
|
48
|
+
# volume takes any serial locator, yearbook years included).
|
|
49
|
+
MARKERS = {
|
|
50
|
+
"ed" => [:edition, /\A\d+\.\d+\.\d+\z/],
|
|
51
|
+
"sup" => [:supplement, /\A\d+\z/],
|
|
52
|
+
"part" => [:part, /\A(?:\d+[a-zA-Z]*|[A-Z]|[SPMBC]-\d+)\z/],
|
|
53
|
+
"annex" => [:annex, /\A[A-Z]\z/],
|
|
54
|
+
"appendix" => [:appendix, /\A(?:[A-Z]|\d+)\z/],
|
|
55
|
+
"vol" => [:volume, /\A\d+\z/],
|
|
56
|
+
"issue" => [:issue, /\A\d+\z/],
|
|
57
|
+
"year" => [:year, /\A(?:19|20)\d{2}\z/],
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
MARKER_RE = /\A(#{MARKERS.keys.join('|')})-(.+)\z/
|
|
61
|
+
|
|
62
|
+
# The positional markers: their attribute depends on the node they
|
|
63
|
+
# follow (the component-edition duality).
|
|
64
|
+
POSITIONAL_MARKERS = %i[edition year].freeze
|
|
65
|
+
|
|
66
|
+
# Legacy dotted keys (deprecated; decodable, never emitted).
|
|
67
|
+
# "ap" is the pre-positional appendix tag; "suppl" the pre-positional
|
|
68
|
+
# supplement tag; "y" the pre-positional year tag — under the
|
|
69
|
+
# serial-locator unification a year IS a volume, so the y. value
|
|
70
|
+
# decodes into the volume.
|
|
71
|
+
LEGACY_KEYS = {
|
|
72
|
+
"part" => :part,
|
|
73
|
+
"annex" => :annex,
|
|
74
|
+
"suppl" => :supplement,
|
|
75
|
+
"ap" => :appendix,
|
|
76
|
+
"appendix" => :appendix,
|
|
77
|
+
"vol" => :volume,
|
|
78
|
+
"issue" => :issue,
|
|
79
|
+
"y" => :volume,
|
|
36
80
|
}.freeze
|
|
37
81
|
|
|
38
|
-
|
|
82
|
+
LEGACY_RE = /\A([a-z]+)\.(.+)\z/
|
|
83
|
+
|
|
84
|
+
# A bare a.b.c segment is the legacy edition encoding.
|
|
85
|
+
LEGACY_EDITION_RE = /\A\d+\.\d+\.\d+\z/
|
|
86
|
+
SUBNUMBER_RE = /\A\d+\z/
|
|
87
|
+
# The docnumber's own charset: digits with : / - suffix runs.
|
|
88
|
+
NUMBER_RE = /\A\d+(?:[:\/-]\d+)*\z/
|
|
39
89
|
|
|
40
90
|
def parse_urn(urn)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
91
|
+
segments = split_parts(strip_namespace(urn))
|
|
92
|
+
type_token = read_type!(segments, urn)
|
|
93
|
+
attrs = { number: consume_number!(segments, urn) }
|
|
94
|
+
# Positional context: the component the upcoming ed-/year- would
|
|
95
|
+
# attach to, and the owner of the edition just consumed (a year
|
|
96
|
+
# belongs to a component only when it follows that component's
|
|
97
|
+
# own edition).
|
|
98
|
+
ctx = { component: nil, edition_owner: nil }
|
|
99
|
+
consume_segments!(attrs, segments, ctx, urn)
|
|
100
|
+
Builder.build({ type: type_token.upcase, **attrs })
|
|
45
101
|
end
|
|
46
102
|
|
|
47
103
|
private
|
|
48
104
|
|
|
49
|
-
def
|
|
50
|
-
|
|
105
|
+
def read_type!(segments, urn)
|
|
106
|
+
type_token = segments.shift.to_s.downcase
|
|
107
|
+
return type_token if TYPE_LETTERS.include?(type_token)
|
|
108
|
+
|
|
109
|
+
raise Pubid::Errors::UrnParseError,
|
|
110
|
+
"Unknown IHO series #{type_token.inspect} in #{urn.inspect}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def consume_segments!(attrs, segments, ctx, urn)
|
|
114
|
+
until segments.empty?
|
|
115
|
+
consume_segment!(attrs, segments, ctx, urn)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The number occupies every leading bare number segment; the first
|
|
120
|
+
# segment that is not number-shaped ends it (and the segment loop
|
|
121
|
+
# below decides whether it is a legal marker).
|
|
122
|
+
def consume_number!(segments, urn)
|
|
123
|
+
number = []
|
|
124
|
+
while segments.first&.match?(NUMBER_RE)
|
|
125
|
+
number << segments.shift
|
|
126
|
+
end
|
|
127
|
+
if number.empty?
|
|
128
|
+
raise Pubid::Errors::UrnParseError,
|
|
129
|
+
"Missing docnumber in #{urn.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
number.join(":")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def consume_segment!(attrs, segments, ctx, urn)
|
|
136
|
+
segment = segments.shift
|
|
137
|
+
case segment
|
|
138
|
+
when MARKER_RE then consume_marker!(segment, attrs, segments, ctx, urn)
|
|
139
|
+
when LEGACY_RE then consume_legacy!(segment, attrs, urn)
|
|
140
|
+
when LEGACY_EDITION_RE then store!(attrs, :edition, segment, urn)
|
|
141
|
+
else raise_unexpected!(segment, urn)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def raise_unexpected!(segment, urn)
|
|
146
|
+
raise Pubid::Errors::UrnParseError,
|
|
147
|
+
"Unexpected segment #{segment.inspect} in #{urn.inspect}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A marker segment: validate the value against the marker's own
|
|
151
|
+
# pattern, store it, and claim the bare atom that follows an
|
|
152
|
+
# appendix (its subnumber — the only bare atom allowed after a
|
|
153
|
+
# marker). The ed- and year- markers are POSITIONAL: the edition
|
|
154
|
+
# attaches to the component it follows (the base when no component
|
|
155
|
+
# precedes it), and a year belongs to a component only when it
|
|
156
|
+
# follows that component's own edition — the separately-published
|
|
157
|
+
# mode; otherwise it is the base with-year qualifier.
|
|
158
|
+
def consume_marker!(segment, attrs, segments, ctx, urn)
|
|
159
|
+
marker, value = MARKER_RE.match(segment).captures
|
|
160
|
+
key, pattern = MARKERS.fetch(marker)
|
|
161
|
+
raise_invalid!(marker, value, urn) unless value.match?(pattern)
|
|
162
|
+
|
|
163
|
+
if POSITIONAL_MARKERS.include?(key)
|
|
164
|
+
return consume_positional!(attrs, key, value, ctx, urn)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
store!(attrs, key, value, urn)
|
|
168
|
+
ctx[:component] = key
|
|
169
|
+
ctx[:edition_owner] = nil
|
|
170
|
+
consume_subnumber!(attrs, key, segments, ctx, urn)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def raise_invalid!(marker, value, urn)
|
|
174
|
+
raise Pubid::Errors::UrnParseError,
|
|
175
|
+
"Invalid #{marker} value #{value.inspect} in #{urn.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# ed- and year- are POSITIONAL markers (see the marker docs): the
|
|
179
|
+
# edition attaches to the component it follows, the year to the
|
|
180
|
+
# edition owner.
|
|
181
|
+
def consume_positional!(attrs, key, value, ctx, urn)
|
|
182
|
+
if key == :edition
|
|
183
|
+
return store_component_scoped!(attrs, :edition, value, ctx, urn)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
store_year!(attrs, value, ctx, urn)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def store_component_scoped!(attrs, stem, value, ctx, urn)
|
|
190
|
+
owner = ctx[:component]
|
|
191
|
+
store!(attrs, owner ? :"#{owner}_#{stem}" : stem, value, urn)
|
|
192
|
+
ctx[:edition_owner] = owner
|
|
51
193
|
end
|
|
52
194
|
|
|
53
|
-
def
|
|
54
|
-
|
|
195
|
+
def store_year!(attrs, value, ctx, urn)
|
|
196
|
+
owner = ctx[:edition_owner]
|
|
197
|
+
attrs_key = owner && owner != :base ? :"#{owner}_year" : :year
|
|
198
|
+
ctx[:edition_owner] = nil
|
|
199
|
+
store!(attrs, attrs_key, value, urn)
|
|
55
200
|
end
|
|
56
201
|
|
|
57
|
-
def
|
|
58
|
-
return
|
|
202
|
+
def consume_subnumber!(attrs, key, segments, ctx, urn)
|
|
203
|
+
return unless key == :appendix && segments.first&.match?(SUBNUMBER_RE)
|
|
204
|
+
|
|
205
|
+
store!(attrs, :appendix_subnumber, segments.shift, urn)
|
|
206
|
+
end
|
|
59
207
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
208
|
+
# Legacy dotted-key decode. The legacy appendix value stored the
|
|
209
|
+
# subnumber inside one string ("appendix.D-2"); it decodes into the
|
|
210
|
+
# two components.
|
|
211
|
+
def consume_legacy!(segment, attrs, urn)
|
|
212
|
+
match = LEGACY_RE.match(segment)
|
|
213
|
+
key = LEGACY_KEYS.fetch(match[1]) do
|
|
63
214
|
raise Pubid::Errors::UrnParseError,
|
|
64
215
|
"Unexpected segment #{segment.inspect} in #{urn.inspect}"
|
|
65
216
|
end
|
|
66
217
|
|
|
67
|
-
|
|
218
|
+
store_legacy!(attrs, key, match[2], urn)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def store_legacy!(attrs, key, value, urn)
|
|
222
|
+
sub = /\A([A-Z])-(\d+)\z/.match(value) if key == :appendix
|
|
223
|
+
return store!(attrs, key, value, urn) unless sub
|
|
224
|
+
|
|
225
|
+
store!(attrs, :appendix, sub[1], urn)
|
|
226
|
+
store!(attrs, :appendix_subnumber, sub[2], urn)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def store!(attrs, key, value, urn)
|
|
230
|
+
raise_if_duplicate(key, attrs, urn)
|
|
231
|
+
attrs[key] = value
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def raise_if_duplicate(key, attrs, urn)
|
|
235
|
+
return unless attrs[key]
|
|
236
|
+
|
|
237
|
+
raise Pubid::Errors::UrnParseError,
|
|
238
|
+
"Duplicate #{key} segment in #{urn.inspect}"
|
|
68
239
|
end
|
|
69
240
|
end
|
|
70
241
|
end
|
data/lib/pubid/iso/CLAUDE.md
CHANGED
|
@@ -46,6 +46,38 @@
|
|
|
46
46
|
different attributes — the first pass stripped `edition.number.value` too,
|
|
47
47
|
and two corrigendum examples caught it.
|
|
48
48
|
|
|
49
|
+
- **URN item paths and document versions are cut, not parsed (issue #645).**
|
|
50
|
+
Glossarist-rendered corpora cite parent documents with URNs like
|
|
51
|
+
`urn:iso:std:iso:10303:-2:ed-1:v1:en:tech:ACTIVITY_MIM` and a duplicated
|
|
52
|
+
`:tech:X:tech:X:X` extraction tail. Neither tail is document identity, so
|
|
53
|
+
`UrnParser` drops both: the bare document-version token (`v1` right after
|
|
54
|
+
`ed-N` — the base identifier has no version attribute, and before this fix
|
|
55
|
+
the supplement loop consumed it as a type-less supplement number, which
|
|
56
|
+
wrapped the document as that phantom supplement and collapsed
|
|
57
|
+
"ISO 10303-2" to "ISO 1"), and the item path, cut at the first
|
|
58
|
+
supplement-loop token that can be nothing else (`item_path_segment?`).
|
|
59
|
+
Two invariants to keep when editing this loop:
|
|
60
|
+
**the cut's allow-list must mirror exactly what the loop consumes** —
|
|
61
|
+
`stage-`, a bare typed-stage abbr, the `ABBR.N` form, a supplement type,
|
|
62
|
+
digits, a `\Av\d` version, or a language field (`LANGUAGE_FIELD_RE`,
|
|
63
|
+
2-3-letter ISO 639 codes, comma-separated). A token allowed through that
|
|
64
|
+
no branch consumes spins the loop forever: the language branch used to be
|
|
65
|
+
the loose catch-all that guaranteed progress, and gating it on
|
|
66
|
+
`LANGUAGE_FIELD_RE` (so item-path segments stop overwriting the base
|
|
67
|
+
language as a bogus "(ACTIVITY_MIM)" suffix) removed that guarantee —
|
|
68
|
+
`FDAM.3` in `urn:iso:std:iso:17301:-1:FDAM.3:amd:2022:v1.3` exposed it.
|
|
69
|
+
That is also why the loop gained an explicit `ABBR.N` arm: the base stage
|
|
70
|
+
block deliberately skips a supplement-family stage when its own supplement
|
|
71
|
+
follows, and the loop had to consume the token instead. It is consumed
|
|
72
|
+
**and dropped** — the supplement layer has no stage-iteration slot, and
|
|
73
|
+
the old catch-all carried it as a bogus base language visible only in the
|
|
74
|
+
generated URN (`…:fdam.3:amd:1:2022:v1` → now `…:amd:1:2022:v1`; the
|
|
75
|
+
locked `to_s` "ISO 17301-1/AMD 1:2022" is unchanged). Net effect:
|
|
76
|
+
`parse(render(parse(urn)))` round-trips for both item-path forms — the
|
|
77
|
+
face renders `(EN)` (a form the grammar accepts) instead of the
|
|
78
|
+
unparseable `(ACTIVITY_MIM)` — and identifiers without an item path
|
|
79
|
+
render byte-identically. No grammar or renderer change was needed.
|
|
80
|
+
|
|
49
81
|
- **Typed model bindings (Phase D): the builder consumes the grammar's bound
|
|
50
82
|
map, not a parse tree.** `Identifier.parse` and `Normalizer` parse through
|
|
51
83
|
`Pubid::Parg::Model.bound(:iso, …)`; `UrnParser#build_identifier` constructs
|
data/lib/pubid/iso/urn_parser.rb
CHANGED
|
@@ -55,6 +55,12 @@ module Pubid
|
|
|
55
55
|
"data" => :data,
|
|
56
56
|
}.freeze
|
|
57
57
|
|
|
58
|
+
# An ISO 639 language field: 2-3 letter codes, comma-separated
|
|
59
|
+
# ("en", "fr", "en,fr"). Item-path segments ("tech", "ACTIVITY_MIM")
|
|
60
|
+
# do not match it and are cut as the URN's item path instead
|
|
61
|
+
# (issue #645).
|
|
62
|
+
LANGUAGE_FIELD_RE = /\A[a-zA-Z]{2,3}(?:,[a-zA-Z]{2,3})*\z/
|
|
63
|
+
|
|
58
64
|
# Parse ISO URN string
|
|
59
65
|
# @param urn [String] URN string to parse
|
|
60
66
|
# @return [Identifier] parsed identifier
|
|
@@ -126,12 +132,21 @@ module Pubid
|
|
|
126
132
|
edition = parts.shift.sub("ed-", "").to_i
|
|
127
133
|
end
|
|
128
134
|
|
|
135
|
+
# RFC 5141-bis document version token ("v1" in "…:ed-1:v1:en"). The
|
|
136
|
+
# base identifier has no version attribute -- the edition is the
|
|
137
|
+
# version axis -- so the token is dropped here, before the
|
|
138
|
+
# supplement loop mistakes it for a type-less supplement number,
|
|
139
|
+
# which wrapped the document as that phantom supplement and
|
|
140
|
+
# collapsed "ISO 10303-2" to "ISO 1" (issue #645).
|
|
141
|
+
parts.shift if parts.first&.match?(/\Av\d/)
|
|
142
|
+
|
|
129
143
|
# Parse language if present (2-letter codes like "en" or comma-separated)
|
|
130
144
|
languages = nil
|
|
131
145
|
if parts.first && !parts.first.match(/^\d+$/) &&
|
|
132
146
|
!parts.first.match?(/^(amd|cor|sup|add|v\d+|stage-|ed-)/i) &&
|
|
133
147
|
!TYPED_STAGE_REVERSE_MAP.key?(parts.first.upcase) &&
|
|
134
|
-
!parts.first.match?(/^[A-Z]+\.\d+$/i)
|
|
148
|
+
!parts.first.match?(/^[A-Z]+\.\d+$/i) &&
|
|
149
|
+
parts.first.match?(LANGUAGE_FIELD_RE)
|
|
135
150
|
languages = parse_languages(parts.shift)
|
|
136
151
|
end
|
|
137
152
|
|
|
@@ -181,6 +196,17 @@ module Pubid
|
|
|
181
196
|
# Check for supplements (amd, cor, sup, add)
|
|
182
197
|
supplements = []
|
|
183
198
|
while parts.any?
|
|
199
|
+
# Item path (issue #645): trailing URN segments naming a resource
|
|
200
|
+
# inside the document ("...:en:tech:ACTIVITY_MIM", and the
|
|
201
|
+
# Glossarist-era duplicated ":tech:X:tech:X:X" extraction
|
|
202
|
+
# tails). They are not document identity: the first token that
|
|
203
|
+
# cannot be a stage, supplement field, version or language
|
|
204
|
+
# starts the path, and it and everything after it are dropped so
|
|
205
|
+
# the duplication cannot derail the fields parsed before it.
|
|
206
|
+
if item_path_segment?(parts.first)
|
|
207
|
+
break
|
|
208
|
+
end
|
|
209
|
+
|
|
184
210
|
supp_type = nil
|
|
185
211
|
supp_number = nil
|
|
186
212
|
supp_version = nil
|
|
@@ -194,6 +220,16 @@ module Pubid
|
|
|
194
220
|
elsif TYPED_STAGE_REVERSE_MAP.key?(parts.first&.upcase)
|
|
195
221
|
supp_stage_abbr = parts.shift.upcase
|
|
196
222
|
supp_stage = TYPED_STAGE_REVERSE_MAP[supp_stage_abbr]
|
|
223
|
+
elsif parts.first&.match?(/\A[A-Za-z]+\.\d+\z/) &&
|
|
224
|
+
TYPED_STAGE_REVERSE_MAP.key?(parts.first.upcase.split(".").first)
|
|
225
|
+
# "FDAM.3" -- a supplement-family stage abbreviation with an
|
|
226
|
+
# iteration, meant for the supplement that follows
|
|
227
|
+
# ("...:FDAM.3:amd:..."). The supplement layer has no
|
|
228
|
+
# stage-iteration slot, so the token is consumed here and
|
|
229
|
+
# dropped: the previous catch-all language branch carried it
|
|
230
|
+
# as a bogus base language, surfacing only in the generated
|
|
231
|
+
# URN (issue #645).
|
|
232
|
+
parts.shift
|
|
197
233
|
end
|
|
198
234
|
|
|
199
235
|
# Check for supplement type (amd, cor, sup, add)
|
|
@@ -218,8 +254,8 @@ module Pubid
|
|
|
218
254
|
# the NUMBER. The RFC 5141 additive extension ("amd:1:2007:v2")
|
|
219
255
|
# keeps the number in its own slot, so a v-token after a number
|
|
220
256
|
# is the true <supplversion>.
|
|
221
|
-
if parts.first&.
|
|
222
|
-
version_str = parts.shift.
|
|
257
|
+
if parts.first&.match?(/\Av\d/)
|
|
258
|
+
version_str = parts.shift.delete_prefix("v")
|
|
223
259
|
value = version_str.split(".").first.to_i
|
|
224
260
|
if supp_number.nil?
|
|
225
261
|
supp_number = value
|
|
@@ -236,8 +272,8 @@ module Pubid
|
|
|
236
272
|
# A v-token after the year ("amd:1:2007:v1") would otherwise be
|
|
237
273
|
# left for the next loop iteration, wrapping a type-less
|
|
238
274
|
# supplement around the real one and losing the base.
|
|
239
|
-
if parts.first&.
|
|
240
|
-
version_str = parts.shift.
|
|
275
|
+
if parts.first&.match?(/\Av\d/)
|
|
276
|
+
version_str = parts.shift.delete_prefix("v")
|
|
241
277
|
value = version_str.split(".").first.to_i
|
|
242
278
|
if supp_number.nil?
|
|
243
279
|
supp_number = value
|
|
@@ -248,7 +284,9 @@ module Pubid
|
|
|
248
284
|
|
|
249
285
|
# Check for language after supplement
|
|
250
286
|
supp_languages = nil
|
|
251
|
-
if parts.first && !parts.first.match(/^\d+$/) &&
|
|
287
|
+
if parts.first && !parts.first.match(/^\d+$/) &&
|
|
288
|
+
!parts.first.match?(/^(amd|cor|sup|add|v\d+|stage-)/i) &&
|
|
289
|
+
parts.first.match?(LANGUAGE_FIELD_RE)
|
|
252
290
|
supp_languages = parse_languages(parts.shift)
|
|
253
291
|
end
|
|
254
292
|
|
|
@@ -270,6 +308,25 @@ module Pubid
|
|
|
270
308
|
|
|
271
309
|
private
|
|
272
310
|
|
|
311
|
+
# True when the token can only be an item-path segment (issue #645):
|
|
312
|
+
# it is not a stage, supplement type, number/year, version or
|
|
313
|
+
# language field, so it -- and everything after it -- names a
|
|
314
|
+
# resource inside the document rather than a component of the
|
|
315
|
+
# identifier. The allow-list mirrors exactly what the supplement
|
|
316
|
+
# loop can consume, so every token it lets through makes progress.
|
|
317
|
+
def item_path_segment?(token)
|
|
318
|
+
return false if token.nil?
|
|
319
|
+
return false if token.match?(/\A\d+\z/)
|
|
320
|
+
return false if token.match?(/\Av\d/)
|
|
321
|
+
return false if token.start_with?("stage-")
|
|
322
|
+
return false if TYPED_STAGE_REVERSE_MAP.key?(token.upcase)
|
|
323
|
+
return false if token.match?(/\A[A-Za-z]+\.\d+\z/) &&
|
|
324
|
+
TYPED_STAGE_REVERSE_MAP.key?(token.upcase.split(".").first)
|
|
325
|
+
return false if SUPPLEMENT_TYPE_MAP.key?(token.downcase)
|
|
326
|
+
|
|
327
|
+
!token.match?(LANGUAGE_FIELD_RE)
|
|
328
|
+
end
|
|
329
|
+
|
|
273
330
|
# Helper to find IS-type TypedStage by harmonized stage code
|
|
274
331
|
# Used when URN doesn't specify a type code (defaults to IS)
|
|
275
332
|
# Prefers the most specific (non-published) stage
|
data/lib/pubid/jis/CLAUDE.md
CHANGED
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
jis_identifier`. The WES branch is `"WES" space number parts …` with the same
|
|
12
12
|
tail as JIS (year, `R`, language, 規格群, supplements, SYMBOL) and binds a
|
|
13
13
|
`wes` capture, which the builder keys on. `JIS WES 7700` and `WES X 7700`
|
|
14
|
-
are rejected: the JIS prefix and a series letter belong only to JIS.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
are rejected: the JIS prefix and a series letter belong only to JIS.
|
|
15
|
+
- **The artifact is vendored, not baked here.** `data/parg/jis.json` is a
|
|
16
|
+
verbatim copy of pubid/pubid-grammar `artifacts/jis.json` (main `31fb965`,
|
|
17
|
+
parsanol **1.3.121**). Change the grammar there and copy the artifact back;
|
|
18
|
+
`spec/pubid/jis/parg_reaffirmed_spec.rb` pins its `source_hash`, so a local
|
|
19
|
+
rebake fails that spec.
|
|
20
|
+
- **`reaffirmed` binds the whole run of R's** (`":1997R"` → `"R"`, `":1997RR"`
|
|
21
|
+
→ `"RR"`; same for `amd_reaffirmed`/`corr_reaffirmed`). The builder reads
|
|
22
|
+
each only for truth (`true if data[:reaffirmed]`), so `RR` renders as `R`.
|
|
18
23
|
- **`series` is nil** for WES (JIS keeps its `""`-coerced String), so the hash
|
|
19
24
|
carries no `series` key. `number` is the bare number (`"7700"`), the index
|
|
20
25
|
key.
|
data/lib/pubid/parg/backend.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Pubid
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
to_builder_hash(Artifact.for(flavor).parse(entry, input), merge: merge_top_sequence)
|
|
27
|
-
rescue
|
|
27
|
+
rescue *ENGINE_ERRORS => e
|
|
28
28
|
raise Pubid::Errors::ParseError.new(e.message, nil,
|
|
29
29
|
input: input,
|
|
30
30
|
flavor: registered_name(flavor))
|
data/lib/pubid/parg/model.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Pubid
|
|
|
27
27
|
source = all_parts ? input.sub(ALL_PARTS_PATTERN, "") : input
|
|
28
28
|
hash = Artifact.for(flavor).parse_and_bind(entry, source)
|
|
29
29
|
all_parts ? Backend.mark_all_parts(hash) : hash
|
|
30
|
-
rescue
|
|
30
|
+
rescue *ENGINE_ERRORS => e
|
|
31
31
|
parse_error(e, input, flavor)
|
|
32
32
|
end
|
|
33
33
|
|
|
@@ -38,12 +38,17 @@ module Pubid
|
|
|
38
38
|
register(flavor, model_class, entry)
|
|
39
39
|
model, all_parts = materialize(flavor, model_class, input)
|
|
40
40
|
all_parts ? model.to_all_parts : model
|
|
41
|
-
rescue
|
|
41
|
+
rescue *ENGINE_ERRORS => e
|
|
42
42
|
parse_error(e, input, flavor)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
private
|
|
46
46
|
|
|
47
|
+
# The uniform error contract (CLAUDE.md "One error namespace"):
|
|
48
|
+
# every grammar/binding failure surfaces as Pubid::Errors::ParseError
|
|
49
|
+
# so `rescue Pubid::Errors::Error` stays exhaustive. Binding failures
|
|
50
|
+
# raise Parsanol::PARG::BindError (< PARG::Error, NOT ParseFailed) —
|
|
51
|
+
# left unrescued it escaped Pubid.parse raw (issue #646).
|
|
47
52
|
def parse_error(error, input, flavor)
|
|
48
53
|
raise Pubid::Errors::ParseError.new(
|
|
49
54
|
error.message,
|
data/lib/pubid/parg.rb
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "parsanol"
|
|
4
|
+
|
|
3
5
|
module Pubid
|
|
4
6
|
module Parg
|
|
5
7
|
autoload :Artifact, "pubid/parg/artifact"
|
|
6
8
|
autoload :Backend, "pubid/parg/backend"
|
|
7
9
|
autoload :Model, "pubid/parg/model"
|
|
10
|
+
|
|
11
|
+
# The engine errors a PG artifact can raise while parsing or binding an
|
|
12
|
+
# input. Every one of them must surface as Pubid::Errors::ParseError —
|
|
13
|
+
# the uniform error contract (CLAUDE.md "One error namespace") makes
|
|
14
|
+
# `rescue Pubid::Errors::Error` exhaustive, and a raw Parsanol class
|
|
15
|
+
# escaping Pubid.parse bypasses every such guard (issue #646: the JIS
|
|
16
|
+
# year forms raised Parsanol::PARG::BindError raw, which is NOT a
|
|
17
|
+
# Parsanol::ParseFailed). PARG::Error carries BindError
|
|
18
|
+
# (< ArtifactError < Error) and the PARG Parse/Compile/Artifact errors;
|
|
19
|
+
# Parsanol::ParseFailed is the ruby reference engine's parse failure.
|
|
20
|
+
ENGINE_ERRORS = [Parsanol::ParseFailed, Parsanol::PARG::Error].freeze
|
|
8
21
|
end
|
|
9
22
|
end
|
data/lib/pubid/tgpp/CLAUDE.md
CHANGED
|
@@ -41,3 +41,5 @@ keeps `#matches?(other, ignore:)`.
|
|
|
41
41
|
paths and the strict comparison treats that `[]` and a nil alike — the
|
|
42
42
|
same asymmetry the `initialize_empty` note above is about, seen from the
|
|
43
43
|
matching side. relaton had two committed specs breaking on this.
|
|
44
|
+
|
|
45
|
+
- **The flavor key is `:"3gpp"`, not the derived `:tgpp` (pubid#644)**: `PrefixesSupport#prefix_flavor_key` derives the key from the Ruby constant's name (`Pubid::Tgpp` → `:tgpp`), but the external key is "3gpp" — the registry registration, the `pubid:3gpp:*` `_type` namespace and the `urn:3gpp:` URN namespace all spell it with the digit. The derived mismatch made `Pubid.prefix_flavors` attribute the "3GPP" prefix to a key **nothing is registered under**, so the router's routing table resolved `Registry.get(:tgpp)` to `nil`, `candidates_by_prefix`'s `compact` silently dropped the owner, and `Pubid.parse("3GPP TS 38.101")` — which `Pubid::Tgpp.parse` handles fine (the prefixed spelling is a normalizing parse; canonical render drops the publisher, `with_publisher: true` puts it back) — lost its prefix-owner fallback and raised. The fix is a one-line `prefix_flavor_key` override in `lib/pubid/tgpp.rb` returning `:"3gpp"`; `Pubid.prefix_flavors["3GPP"]` now reads `[:\"3gpp\"]` and `Registry.canonical_name(Pubid::Tgpp)` answers the real key. The registry-driven tripwire `spec/pubid/parse_routing_spec.rb` ("resolves every prefix table entry to a registered flavor module") is the guard that turns any future key/registry mismatch of this shape red at the table level.
|
data/lib/pubid/tgpp.rb
CHANGED
|
@@ -16,6 +16,17 @@ module Pubid
|
|
|
16
16
|
# excluded. The parser still accepts an optional leading "3GPP ".
|
|
17
17
|
PREFIXES = ["3GPP"].freeze
|
|
18
18
|
|
|
19
|
+
# The external flavor key is "3gpp" (the registry key, the `pubid:3gpp:*`
|
|
20
|
+
# `_type` namespace and the `urn:3gpp:` URN namespace), but the default
|
|
21
|
+
# derivation spells it from the Ruby constant as :tgpp. That mismatch made
|
|
22
|
+
# {Pubid.prefix_flavors} attribute "3GPP" to a key nothing is registered
|
|
23
|
+
# under, so {Pubid}'s routing table resolved the prefix to no module and
|
|
24
|
+
# the "3GPP TS …" printed form lost its prefix-owner status (pubid#644).
|
|
25
|
+
# Return the real external key, matching the registry registration below.
|
|
26
|
+
def self.prefix_flavor_key
|
|
27
|
+
:"3gpp"
|
|
28
|
+
end
|
|
29
|
+
|
|
19
30
|
autoload :Builder, "#{__dir__}/tgpp/builder"
|
|
20
31
|
autoload :Identifier, "#{__dir__}/tgpp/identifier"
|
|
21
32
|
autoload :Identifiers, "#{__dir__}/tgpp/identifiers"
|
data/lib/pubid/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pubid
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.
|
|
4
|
+
version: 2.0.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|
|
@@ -71,6 +71,7 @@ files:
|
|
|
71
71
|
- data/parg/asme.json
|
|
72
72
|
- data/parg/astm.json
|
|
73
73
|
- data/parg/bipm.json
|
|
74
|
+
- data/parg/bipm.parg
|
|
74
75
|
- data/parg/bsi.json
|
|
75
76
|
- data/parg/calconnect.json
|
|
76
77
|
- data/parg/ccsds.json
|
|
@@ -93,6 +94,7 @@ files:
|
|
|
93
94
|
- data/parg/ieee.json
|
|
94
95
|
- data/parg/ietf.json
|
|
95
96
|
- data/parg/iho.json
|
|
97
|
+
- data/parg/iho.parg
|
|
96
98
|
- data/parg/ire.json
|
|
97
99
|
- data/parg/ire.parg
|
|
98
100
|
- data/parg/isbn.json
|
|
@@ -109,6 +111,7 @@ files:
|
|
|
109
111
|
- data/parg/oiml.json
|
|
110
112
|
- data/parg/omg.json
|
|
111
113
|
- data/parg/parg.json
|
|
114
|
+
- data/parg/parg.parg
|
|
112
115
|
- data/parg/plateau.json
|
|
113
116
|
- data/parg/sae.json
|
|
114
117
|
- data/parg/tables/bipm_groups.yaml
|