pubid 2.0.19 → 2.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/data/iho/s4-reserved-specs.yaml +15 -0
  3. data/data/parg/bipm.json +1 -1
  4. data/data/parg/bipm.parg +46 -11
  5. data/data/parg/iho.json +1 -1
  6. data/data/parg/iho.parg +128 -11
  7. data/data/parg/plateau.json +1 -1
  8. data/lib/pubid/bipm/CLAUDE.md +6 -1
  9. data/lib/pubid/bipm/builder.rb +20 -1
  10. data/lib/pubid/bipm/renderer.rb +14 -2
  11. data/lib/pubid/bipm/urn_generator.rb +80 -14
  12. data/lib/pubid/bipm/urn_parser.rb +70 -6
  13. data/lib/pubid/ecma/CLAUDE.md +12 -0
  14. data/lib/pubid/ecma/identifier.rb +1 -2
  15. data/lib/pubid/ecma/parser.rb +49 -0
  16. data/lib/pubid/identifier.rb +4 -3
  17. data/lib/pubid/iec/CLAUDE.md +20 -11
  18. data/lib/pubid/iec/builder.rb +19 -1
  19. data/lib/pubid/iec/identifier.rb +19 -70
  20. data/lib/pubid/iec/identifiers/fragment_identifier.rb +3 -3
  21. data/lib/pubid/iec/identifiers/test_report_form.rb +29 -0
  22. data/lib/pubid/iec/identifiers/working_document.rb +23 -6
  23. data/lib/pubid/iec/pubid_urn_schema.rb +15 -7
  24. data/lib/pubid/iec/renderer.rb +8 -4
  25. data/lib/pubid/iec/single_identifier.rb +69 -4
  26. data/lib/pubid/iec/supplement_identifier.rb +52 -76
  27. data/lib/pubid/iho/CLAUDE.md +52 -0
  28. data/lib/pubid/iho/builder.rb +105 -2
  29. data/lib/pubid/iho/identifiers/base.rb +52 -1
  30. data/lib/pubid/iho/pubid_urn_schema.rb +11 -4
  31. data/lib/pubid/iho/renderer.rb +39 -2
  32. data/lib/pubid/iho/reserved_specs.rb +54 -0
  33. data/lib/pubid/iho/urn_generator.rb +6 -0
  34. data/lib/pubid/iho/urn_parser.rb +50 -9
  35. data/lib/pubid/iho.rb +1 -0
  36. data/lib/pubid/nist/CLAUDE.md +11 -0
  37. data/lib/pubid/nist/preprocessor.rb +16 -0
  38. data/lib/pubid/plateau/CLAUDE.md +35 -0
  39. data/lib/pubid/plateau/builder.rb +4 -0
  40. data/lib/pubid/plateau/identifiers/handbook.rb +10 -2
  41. data/lib/pubid/plateau/pubid_urn_schema.rb +1 -0
  42. data/lib/pubid/plateau/renderer.rb +6 -1
  43. data/lib/pubid/sae/CLAUDE.md +11 -2
  44. data/lib/pubid/version.rb +1 -1
  45. metadata +18 -2
@@ -18,18 +18,34 @@ module Pubid
18
18
  part_edition part_year supplement_edition
19
19
  supplement_year annex_edition annex_year
20
20
  appendix_edition appendix_year volume_edition
21
- volume_year].freeze
21
+ volume_year
22
+ spec spec_sub spec_variant spec_part
23
+ spec_section].freeze
22
24
  # The independently-publishable components' own editions; the
23
25
  # base edition context is mutually exclusive with them (owner
24
26
  # ruling on the two publication modes).
25
27
  COMPONENT_EDITIONS = %i[part_edition supplement_edition annex_edition
26
28
  appendix_edition volume_edition].freeze
29
+ # The S-4 anchor: the loose-leaf publication every spec citation
30
+ # resolves through (bare "B-401" carries it implicitly).
31
+ S4_TYPE = "S"
32
+ S4_NUMBER = "4"
33
+ # A citation letter is one of S-4's lettered Parts; the section
34
+ # vocabulary (100-600, Part C 100-500) is theirs.
35
+ SPEC_LETTERS = %w[A B C].freeze
36
+ # Components an S-4 spec citation cannot carry: the citation is a
37
+ # leaf of the Part, not a wrappable publication.
38
+ SPEC_FOREIGN_COMPONENTS = %i[supplement annex appendix
39
+ appendix_subnumber volume issue].freeze
27
40
 
28
41
  def build(parsed)
29
42
  hash = parsed.is_a?(Array) ? parsed.reduce({}, :merge) : parsed
30
43
  attrs = stringified_attrs(hash)
44
+ resolve_spec_citation!(attrs)
45
+ type = type_letter_from(hash, attrs)
46
+ assert_spec_anchor!(attrs, type)
31
47
  assert_edition_mode!(attrs)
32
- klass = Pubid::Iho.identifier_klass_for_type_letter(stringify(hash[:type]))
48
+ klass = Pubid::Iho.identifier_klass_for_type_letter(type)
33
49
  klass.new(**attrs)
34
50
  end
35
51
 
@@ -39,6 +55,93 @@ module Pubid
39
55
 
40
56
  private
41
57
 
58
+ # The citation captures resolve onto the model (see the grammar
59
+ # header): the citation letter folds into `part` (a Part-rule
60
+ # capture must agree), and the long form's spelled section becomes
61
+ # the spec alone — beside a citation it only cross-checks the
62
+ # citation's derived section ("Part B, Section 400, B-401" stores
63
+ # spec 401; "Part B, Section 500, B-401" is rejected).
64
+ def resolve_spec_citation!(attrs)
65
+ letter = attrs.delete(:spec_part)
66
+ spelled = attrs.delete(:spec_section)
67
+ attrs[:part] = merge_citation_letter!(attrs[:part], letter) if letter
68
+ apply_spelled_section!(attrs, spelled)
69
+ assert_citation_shape!(attrs) if attrs[:spec]
70
+ end
71
+
72
+ def apply_spelled_section!(attrs, spelled)
73
+ return if spelled.nil?
74
+ return attrs[:spec] = spelled unless attrs[:spec]
75
+
76
+ return if spelled == spec_section_of(attrs[:spec])
77
+
78
+ raise ArgumentError,
79
+ "the spelled section #{spelled} disagrees with the " \
80
+ "citation #{attrs[:part]}-#{attrs[:spec]}"
81
+ end
82
+
83
+ def spec_section_of(spec)
84
+ "#{spec[0]}00"
85
+ end
86
+
87
+ def merge_citation_letter!(part, letter)
88
+ return letter if part.nil? || part == letter
89
+
90
+ raise ArgumentError,
91
+ "the citation letter #{letter} conflicts with part #{part}"
92
+ end
93
+
94
+ def assert_citation_shape!(attrs)
95
+ assert_citation_letter!(attrs)
96
+ assert_citation_leaf!(attrs)
97
+ end
98
+
99
+ def assert_citation_letter!(attrs)
100
+ return if SPEC_LETTERS.include?(attrs[:part])
101
+
102
+ raise ArgumentError,
103
+ "an S-4 spec citation needs a lettered Part (A/B/C), " \
104
+ "not #{attrs[:part].inspect}"
105
+ end
106
+
107
+ # The citation is a leaf of its Part: it carries no component
108
+ # edition and none of the wrappable components.
109
+ def assert_citation_leaf!(attrs)
110
+ if attrs[:part_edition] || attrs[:part_year]
111
+ raise ArgumentError,
112
+ "a spec citation rides the S-4 edition, not a part edition"
113
+ end
114
+
115
+ foreign = attrs.slice(*SPEC_FOREIGN_COMPONENTS).compact.keys
116
+ return if foreign.empty?
117
+
118
+ raise ArgumentError,
119
+ "an S-4 spec citation is a leaf of its Part and cannot " \
120
+ "carry #{foreign.join(', ')}"
121
+ end
122
+
123
+ # The series letter: a bare citation branch parse has no series
124
+ # capture, and its anchor is implicitly the S-4 publication (the
125
+ # number binds with it).
126
+ def type_letter_from(hash, attrs)
127
+ type = stringify(hash[:type])
128
+ return type if type
129
+
130
+ raise ArgumentError, "unknown IHO series" unless attrs[:spec]
131
+
132
+ attrs[:number] = S4_NUMBER
133
+ S4_TYPE
134
+ end
135
+
136
+ def assert_spec_anchor!(attrs, type)
137
+ return unless attrs[:spec]
138
+ return if type == S4_TYPE && attrs[:number] == S4_NUMBER
139
+
140
+ raise ArgumentError,
141
+ "an S-4 spec citation requires the S-4 anchor: " \
142
+ "#{type}-#{attrs[:number]} cannot carry spec #{attrs[:spec]}"
143
+ end
144
+
42
145
  # The two publication modes are mutually exclusive: an identifier
43
146
  # is either within-edition (a base edition its components ride) or
44
147
  # separately-published (a component carries its own edition and
@@ -8,6 +8,11 @@ module Pubid
8
8
  # IHO identifiers have the form:
9
9
  # IHO {S|P|M|B|C}-{number}[ {edition}| Vol. N [(issue)]]
10
10
  # [ Sup N][ Part X][ Annex A][ Appendix B[-2]] [(2024)]
11
+ # The S-4 loose-leaf publication adds the spec citation
12
+ # IHO S-4 [edition] <letter>-<hundreds><seq>[.sub][(variant)]
13
+ # ("IHO S-4 B-401", the long form "S-4 Part B, Section 400" and the
14
+ # bare "B-401" all parse to it; `spec` stores the leaf number, the
15
+ # citation letter rides `part`, the section is a derived reader).
11
16
  #
12
17
  # Every tail component is a first-class semantic component: a typed
13
18
  # attribute, independently addressable, URN-encodable and matchable.
@@ -83,6 +88,15 @@ module Pubid
83
88
  attribute :issue, :string
84
89
  attribute :volume_edition, :string
85
90
  attribute :volume_year, :string
91
+ # The S-4 loose-leaf spec citation (issue #691): `spec` is the leaf
92
+ # number ("401" of B-401), the citation letter rides `part` (the
93
+ # builder folds it in), the dotted sub and the parenthesized
94
+ # variant keep their own components. The SECTION (the hundreds
95
+ # digit) is never stored — a derived reader — and the `00` slot
96
+ # (B-400) IS the section-level citation.
97
+ attribute :spec, :string
98
+ attribute :spec_sub, :string
99
+ attribute :spec_variant, :string
86
100
 
87
101
  # Polymorphic type map for lutaml::Model key_value serialization: maps
88
102
  # each subclass's polymorphic_name to its class name so `from_hash` can
@@ -122,6 +136,9 @@ module Pubid
122
136
  map "volume_edition", to: :volume_edition
123
137
  map "volume_year", to: :volume_year
124
138
  map "issue", to: :issue
139
+ map "spec", to: :spec
140
+ map "spec_sub", to: :spec_sub
141
+ map "spec_variant", to: :spec_variant
125
142
  end
126
143
 
127
144
  # from_hash is the shared polymorphic dispatch on Pubid::Identifier.
@@ -139,9 +156,40 @@ module Pubid
139
156
  args = args.dup
140
157
  args << :issue if args.include?(:volume)
141
158
  args << :appendix_subnumber if args.include?(:appendix)
159
+ # The spec citation is the leaf: a reference that omits the spec
160
+ # nils its sub and variant with it.
161
+ args.push(:spec_sub, :spec_variant) if args.include?(:spec)
142
162
  super
143
163
  end
144
164
 
165
+ # The citation letter of an S-4 spec (nil unless the identifier IS
166
+ # a spec citation): the part carries the letter, the same way the
167
+ # long form spells it ("Part B" of "B-401").
168
+ def spec_part
169
+ spec && part
170
+ end
171
+
172
+ # The section an S-4 spec belongs to: the hundreds digit of the
173
+ # spec number, zero-padded (B-401 -> "400"; B-400 derives its own
174
+ # section). DERIVED, never stored, never emitted — the owner
175
+ # ruling keeps the spec number the leaf.
176
+ def spec_section
177
+ return nil unless spec
178
+
179
+ "#{spec[0]}00"
180
+ end
181
+
182
+ # True when the spec number is reserved ("NOT CURRENTLY USED") in
183
+ # the edition's S-4 contents (data/iho/s4-reserved-specs.yaml). An
184
+ # edition-less reference is reserved when any seeded edition
185
+ # reserves the number. A reserved citation parses and resolves AS
186
+ # RESERVED, never invalid (issue #691).
187
+ def reserved?
188
+ return false unless spec
189
+
190
+ ReservedSpecs.reserved?(edition, part, spec)
191
+ end
192
+
145
193
  # Render the compact face: the code-adjacent components folded onto
146
194
  # the code with separators — the supplement and the part hyphenated
147
195
  # (`IHO M-2-2` for `IHO M-2 Sup 2`, `IHO S-5-B` for `IHO S-5 Part B`,
@@ -153,7 +201,10 @@ module Pubid
153
201
  def render(format: :human, **opts)
154
202
  compact = opts.delete(:compact)
155
203
  result = super
156
- compact && format == :human ? compact_face(result) : result
204
+ # A spec citation's canonical face IS the compact form (the
205
+ # citation is already folded onto the code: "IHO S-4 B-401"), so
206
+ # the compact flag is a no-op for it.
207
+ compact && format == :human && !spec ? compact_face(result) : result
157
208
  end
158
209
 
159
210
  # The shared mr_string path reads `edition&.number` (the base
@@ -13,10 +13,11 @@ module Pubid
13
13
  # edition/year right after it — supplement, part, annex, appendix (the
14
14
  # two-level Appendix D-2 keeps the subnumber as its own atom), volume
15
15
  # (the unified locator, yearbook volumes included: `vol-2024`), issue.
16
- # Letters stay case-preserved (`part-A`). The legacy `y.` yearbook
17
- # marker is decodable in the official URN parser, never emitted here:
18
- # a periodical has no `year-` atom value, its volume carries the year.
19
- # The official urn:mrn:iho:… form stays on #to_urn.
16
+ # The S-4 spec citation sits inside its part (`spec-`, `specsub-`,
17
+ # `specvar-`). Letters stay case-preserved (`part-A`). The legacy
18
+ # `y.` yearbook marker is decodable in the official URN parser, never
19
+ # emitted here: a periodical has no `year-` atom value, its volume
20
+ # carries the year. The official urn:mrn:iho:… form stays on #to_urn.
20
21
  PubidUrnSchema = Pubid::PubidUrn.define("iho") do
21
22
  atom "edition", "ed"
22
23
  atom "year", "year"
@@ -26,6 +27,12 @@ module Pubid
26
27
  atom "part", "part"
27
28
  atom "part_edition", "parted"
28
29
  atom "part_year", "party"
30
+ # The S-4 loose-leaf spec citation (issue #691): a child of the
31
+ # part (the citation letter rides `part-`); the section is
32
+ # derivable from the spec's hundreds digit — never an atom.
33
+ atom "spec", "spec"
34
+ atom "spec_sub", "specsub"
35
+ atom "spec_variant", "specvar"
29
36
  atom "annex", "annex"
30
37
  atom "annex_edition", "anned"
31
38
  atom "annex_year", "annyear"
@@ -19,6 +19,13 @@ module Pubid
19
19
  # beside an edition is the date
20
20
  # qualifier on standards,
21
21
  # parenthesized — the only year face
22
+ # "IHO S-4 B-401" the S-4 spec citation (the long
23
+ # form "S-4 Part B, Section 400"
24
+ # parses to the same identity and
25
+ # renders compact: the citation
26
+ # replaces the Part face; the
27
+ # section is derivable, never
28
+ # printed)
22
29
  #
23
30
  # The renderer is registered as the `:human` format in the IHO format
24
31
  # registry and invoked via `render(format: :human)`; the compact face
@@ -32,12 +39,17 @@ module Pubid
32
39
  # edition (+ parenthesized year) right after it; the appendix joins
33
40
  # its subnumber; the issue rides parenthesized after its volume;
34
41
  # the base year prints only as the parenthesized with-year
35
- # qualifier.
42
+ # qualifier. The S-4 spec citation face (" B-401") sits where the
43
+ # Part face sits — it REPLACES the Part face when a spec is
44
+ # present (the citation letter is the part), printing
45
+ # "<letter>-<number>" with the dotted sub and the parenthesized
46
+ # variant folded on.
36
47
  FACES = [
37
48
  [:edition, " %s"],
38
49
  [:supplement, " Sup %s"], [:supplement_edition, " %s"],
39
50
  [:supplement_year, " (%s)"],
40
51
  [:part, " Part %s"], [:part_edition, " %s"], [:part_year, " (%s)"],
52
+ [:spec, " %s"],
41
53
  [:annex, " Annex %s"], [:annex_edition, " %s"], [:annex_year, " (%s)"],
42
54
  [:appendix, " Appendix %s"], [:appendix_subnumber, "-%s"],
43
55
  [:appendix_edition, " %s"], [:appendix_year, " (%s)"],
@@ -50,11 +62,36 @@ module Pubid
50
62
  id = @id
51
63
  rendered = "#{id.publisher} #{id.class.type[:short]}-#{id.number}"
52
64
  FACES.each do |component, face|
53
- value = id.public_send(component)
65
+ value = face_value(id, component)
54
66
  rendered << (face % value) if value
55
67
  end
56
68
  rendered
57
69
  end
70
+
71
+ private
72
+
73
+ # nil = the face does not print. The citation face replaces the
74
+ # Part face (same containment slot: the citation letter IS the
75
+ # part), and it prints only on a spec citation.
76
+ def face_value(id, component)
77
+ if component == :spec
78
+ return nil unless id.spec
79
+
80
+ return spec_citation(id)
81
+ end
82
+ return nil if component == :part && id.spec
83
+
84
+ id.public_send(component)
85
+ end
86
+
87
+ # "B-401", "B-122.1", "B-521(I)", "B-351.1(I)" — the citation the
88
+ # S-4 contents and cross-references print.
89
+ def spec_citation(id)
90
+ citation = "#{id.part}-#{id.spec}"
91
+ citation << ".#{id.spec_sub}" if id.spec_sub
92
+ citation << "(#{id.spec_variant})" if id.spec_variant
93
+ citation
94
+ end
58
95
  end
59
96
  end
60
97
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Pubid
6
+ module Iho
7
+ # The S-4 reserved specification registry
8
+ # (data/iho/s4-reserved-specs.yaml): the "NOT CURRENTLY USED"
9
+ # numbers, edition-keyed (owner rulings of 2026-11-11, issue #691).
10
+ # {Reserved?} answers from this table — a reserved number parses and
11
+ # resolves AS RESERVED, never invalid.
12
+ module ReservedSpecs
13
+ DATA_PATH =
14
+ File.expand_path("../../../data/iho/s4-reserved-specs.yaml", __dir__)
15
+
16
+ class << self
17
+ # True when the part-lettered spec number is reserved in the
18
+ # given S-4 edition. An edition-less reference is reserved when
19
+ # ANY seeded edition reserves the number: the number is out of
20
+ # circulation in every edition that names it.
21
+ def reserved?(edition, part, spec)
22
+ return false unless part && spec
23
+
24
+ if edition
25
+ specs_for(edition, part).include?(spec)
26
+ else
27
+ registry.values.any? do |parts|
28
+ specs_for_parts(parts, part).include?(spec)
29
+ end
30
+ end
31
+ end
32
+
33
+ # The edition-keyed registry: edition => part letter => spec
34
+ # numbers.
35
+ def registry
36
+ @registry ||=
37
+ (YAML.safe_load_file(DATA_PATH) || {}).transform_values do |parts|
38
+ parts.transform_values { |specs| Array(specs) }
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def specs_for(edition, part)
45
+ specs_for_parts(registry.fetch(edition, {}), part)
46
+ end
47
+
48
+ def specs_for_parts(parts, part)
49
+ parts.fetch(part, [])
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -31,6 +31,7 @@ module Pubid
31
31
  # - urn:iho:m:2:sup-1 IHO M-2 Sup 1
32
32
  # - urn:iho:p:5:vol-2024 IHO P-5 Vol. 2024
33
33
  # - urn:iho:p:1:vol-30:issue-2 IHO P-1 Vol. 30 (2)
34
+ # - urn:iho:s:4:ed-4.10.0:part-B:spec-401 IHO S-4 4.10.0 B-401
34
35
  class UrnGenerator
35
36
  attr_reader :identifier
36
37
 
@@ -49,6 +50,11 @@ module Pubid
49
50
  [:supplement, "sup"], [:supplement_edition, "ed"],
50
51
  [:supplement_year, "year"],
51
52
  [:part, "part"], [:part_edition, "ed"], [:part_year, "year"],
53
+ # the S-4 spec citation: a child of its part (the citation
54
+ # letter rides the part- atom), the dotted sub and the
55
+ # parenthesized variant its own children. The SECTION is NOT
56
+ # emitted — derivable from the spec's hundreds digit.
57
+ [:spec, "spec"], [:spec_sub, "specsub"], [:spec_variant, "specvar"],
52
58
  [:annex, "annex"], [:annex_edition, "ed"], [:annex_year, "year"],
53
59
  [:appendix, "appendix"], [:appendix_subnumber, nil],
54
60
  [:appendix_edition, "ed"], [:appendix_year, "year"],
@@ -7,8 +7,8 @@ module Pubid
7
7
  # the segment table lives in the grammar header and the flavor notes):
8
8
  #
9
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>]
10
+ # [:spec-<n>[:specsub-<n>][:specvar-<X>]][:annex-<A>]
11
+ # [:appendix-<B>][:<sub>][:vol-<n>][:issue-<n>][:year-<yyyy>]
12
12
  #
13
13
  # Segment discipline (mirrors the ISO URN parser's strictness):
14
14
  # - the docnumber occupies every leading bare number segment up to
@@ -22,7 +22,11 @@ module Pubid
22
22
  # directly after its appendix segment (a year is always
23
23
  # marker-tagged, so a bare digit there can only be the subnumber);
24
24
  # - the issue is allowed only beside its volume and validated as
25
- # digits.
25
+ # digits;
26
+ # - the S-4 spec atom is a child of its part (the citation letter
27
+ # rides the part- atom): specsub-/specvar- only after spec-, and
28
+ # no ed-/year- after a spec node (the spec rides its publication's
29
+ # edition; the section is derivable, never emitted).
26
30
  #
27
31
  # Legacy 2.0.x spellings remain decodable but are never emitted:
28
32
  # the bare a.b.c edition segment, the dotted keys part./annex./
@@ -38,6 +42,7 @@ module Pubid
38
42
  # - urn:iho:p:5:vol-2024 → IHO P-5 Vol. 2024
39
43
  # - urn:iho:p:1:vol-30:issue-2 → IHO P-1 Vol. 30 (2)
40
44
  # - urn:iho:s:158:100 → IHO S-158:100
45
+ # - urn:iho:s:4:ed-4.10.0:part-B:spec-401 → IHO S-4 4.10.0 B-401
41
46
  class UrnParser < Pubid::UrnParser::Base
42
47
  TYPE_LETTERS = %w[s p m b c].freeze
43
48
 
@@ -50,6 +55,9 @@ module Pubid
50
55
  "ed" => [:edition, /\A\d+\.\d+\.\d+\z/],
51
56
  "sup" => [:supplement, /\A\d+\z/],
52
57
  "part" => [:part, /\A(?:\d+[a-zA-Z]*|[A-Z]|[SPMBC]-\d+)\z/],
58
+ "spec" => [:spec, /\A[1-6]\d\d\z/],
59
+ "specsub" => [:spec_sub, /\A\d+\z/],
60
+ "specvar" => [:spec_variant, /\A[A-Z]+\z/],
53
61
  "annex" => [:annex, /\A[A-Z]\z/],
54
62
  "appendix" => [:appendix, /\A(?:[A-Z]|\d+)\z/],
55
63
  "vol" => [:volume, /\A\d+\z/],
@@ -63,6 +71,12 @@ module Pubid
63
71
  # follow (the component-edition duality).
64
72
  POSITIONAL_MARKERS = %i[edition year].freeze
65
73
 
74
+ # The S-4 spec node and its children: specsub-/specvar- are only
75
+ # allowed after their parent, and an S-4 spec rides its
76
+ # publication's edition — no ed-/year- inside a spec.
77
+ SPEC_NODES = %i[spec spec_sub spec_variant].freeze
78
+ SPEC_PARENTS = %i[spec spec_sub].freeze
79
+
66
80
  # Legacy dotted keys (deprecated; decodable, never emitted).
67
81
  # "ap" is the pre-positional appendix tag; "suppl" the pre-positional
68
82
  # supplement tag; "y" the pre-positional year tag — under the
@@ -97,11 +111,22 @@ module Pubid
97
111
  # own edition).
98
112
  ctx = { component: nil, edition_owner: nil }
99
113
  consume_segments!(attrs, segments, ctx, urn)
100
- Builder.build({ type: type_token.upcase, **attrs })
114
+ build_identifier({ type: type_token.upcase, **attrs }, urn)
101
115
  end
102
116
 
103
117
  private
104
118
 
119
+ # The semantic validations the builder applies to the decoded shape
120
+ # (the S-4 anchor, the citation letter) are URN validation at this
121
+ # layer: a URN that asserts a citation it cannot carry is a
122
+ # malformed URN, and the raw ArgumentError stays as the cause.
123
+ def build_identifier(hash, urn)
124
+ Builder.build(hash)
125
+ rescue ArgumentError => e
126
+ raise Pubid::Errors::UrnParseError,
127
+ "#{e.message} in #{urn.inspect}"
128
+ end
129
+
105
130
  def read_type!(segments, urn)
106
131
  type_token = segments.shift.to_s.downcase
107
132
  return type_token if TYPE_LETTERS.include?(type_token)
@@ -159,10 +184,9 @@ module Pubid
159
184
  marker, value = MARKER_RE.match(segment).captures
160
185
  key, pattern = MARKERS.fetch(marker)
161
186
  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
187
+ assert_spec_parent!(key, ctx, urn)
188
+ return consume_positional!(attrs, key, value, ctx, urn) if
189
+ POSITIONAL_MARKERS.include?(key)
166
190
 
167
191
  store!(attrs, key, value, urn)
168
192
  ctx[:component] = key
@@ -170,6 +194,16 @@ module Pubid
170
194
  consume_subnumber!(attrs, key, segments, ctx, urn)
171
195
  end
172
196
 
197
+ # specsub-/specvar- are children of the spec atom — allowed only
198
+ # in their parent's slot (the SEGMENTS table in the generator).
199
+ def assert_spec_parent!(key, ctx, urn)
200
+ return unless %i[spec_sub spec_variant].include?(key)
201
+ return if SPEC_PARENTS.include?(ctx[:component])
202
+
203
+ raise Pubid::Errors::UrnParseError,
204
+ "#{key}- is only allowed after spec- in #{urn.inspect}"
205
+ end
206
+
173
207
  def raise_invalid!(marker, value, urn)
174
208
  raise Pubid::Errors::UrnParseError,
175
209
  "Invalid #{marker} value #{value.inspect} in #{urn.inspect}"
@@ -177,8 +211,15 @@ module Pubid
177
211
 
178
212
  # ed- and year- are POSITIONAL markers (see the marker docs): the
179
213
  # edition attaches to the component it follows, the year to the
180
- # edition owner.
214
+ # edition owner. An S-4 spec carries neither — it rides its
215
+ # publication's edition.
181
216
  def consume_positional!(attrs, key, value, ctx, urn)
217
+ if SPEC_NODES.include?(ctx[:component])
218
+ raise Pubid::Errors::UrnParseError,
219
+ "an S-4 spec rides its publication's edition: #{key}- " \
220
+ "cannot follow a spec segment in #{urn.inspect}"
221
+ end
222
+
182
223
  if key == :edition
183
224
  return store_component_scoped!(attrs, :edition, value, ctx, urn)
184
225
  end
data/lib/pubid/iho.rb CHANGED
@@ -12,6 +12,7 @@ module Pubid
12
12
  autoload :Identifier, "#{__dir__}/iho/identifiers/base"
13
13
  autoload :Identifiers, "#{__dir__}/iho/identifiers"
14
14
  autoload :Renderer, "#{__dir__}/iho/renderer"
15
+ autoload :ReservedSpecs, "#{__dir__}/iho/reserved_specs"
15
16
  autoload :UrnGenerator, "#{__dir__}/iho/urn_generator"
16
17
  autoload :UrnParser, "#{__dir__}/iho/urn_parser"
17
18
  autoload :PubidUrnSchema, "#{__dir__}/iho/pubid_urn_schema"
@@ -135,3 +135,14 @@ Read this before you change `lib/pubid/nist/` or `spec/pubid/nist/`. The root
135
135
  `spec/pubid/nist/bare_series_reference_spec.rb`; the corpus round froze
136
136
  the six rows (`NBS CS`, `NBS CSM`, `NBS CIS`, `NBS CS-E`, `NIST PS`,
137
137
  `NIST DCI`) into the testsuite with the gem twin in lockstep.
138
+
139
+ - **"FIPS PUB <number>" is a preprocessor alias, not a grammar face
140
+ (pubid#697).** NIST prints the series as "FIPS PUB <number>" but the
141
+ grammar reads "NIST FIPS <number>", so `normalize_fips_pub_alias!`
142
+ (head-anchored, immediately after the publisher/series stage) rewrites
143
+ the printed prefix and the parse lands on exactly the canonical model —
144
+ hash-, render-, URN- and MR-identical to "NIST FIPS <number>". The
145
+ hyphenated numbers and the revision forms the grammar models
146
+ ("FIPS PUB 199 Rev. 1" with its `original_prefix` facet, compact
147
+ "rev1") ride the prefix alias; bare "FIPS <number>"
148
+ (publisher_was_parsed: false) and NBS FIPS are untouched.
@@ -68,6 +68,7 @@ module Pubid
68
68
  end
69
69
  normalize_spurious_u_suffix!
70
70
  normalize_publisher_and_series!
71
+ normalize_fips_pub_alias!
71
72
  normalize_lcirc_supplement_contexts!
72
73
  normalize_revision_spacing!
73
74
  normalize_letter_suffix_casing!
@@ -124,6 +125,21 @@ module Pubid
124
125
  @cleaned = @cleaned.gsub(/\bLC\b(?!IRC)/, "LCIRC")
125
126
  end
126
127
 
128
+ # The FIPS series' own printed face: NIST prints the series as
129
+ # "FIPS PUB <number>" (document cover and catalogue style), so the
130
+ # grammar accepts the publisher-prefixed canonical form only through
131
+ # this alias (pubid#697). Rewrites the prefix to "NIST FIPS", the
132
+ # spelling the grammar already reads, and the printed-form parse
133
+ # therefore lands on exactly the model "NIST FIPS <number>" builds
134
+ # (same to_hash, same render, revision spellings like
135
+ # "FIPS PUB 199 Rev. 1" included). Anchored at the head and run
136
+ # before every other stage so later digit/marker stages see the
137
+ # canonical face; "FIPS" alone (publisher_was_parsed: false) is
138
+ # untouched.
139
+ def normalize_fips_pub_alias!
140
+ @cleaned = @cleaned.sub(/\AFIPS PUB\b/i, "NIST FIPS")
141
+ end
142
+
127
143
  # LCIRC series: combine with NBS prefix when a supplement marker
128
144
  # follows, and convert MR-format dots to spaces so the grammar's
129
145
  # circ_supplement_identifier rule can match.
@@ -11,3 +11,38 @@ Integer, so its core is `:int` (`core: [["number", :int]]`): `#00` is the core
11
11
  `0`, and `00` in a URN is not canonical. The Annex type has no `number`; it
12
12
  has no core, its document comes first along `base`, and its `letter` is an
13
13
  atom. No input makes an Annex today, so the spec builds one by hand.
14
+
15
+ ## Handbook volume label (本編 / 本編+付録)
16
+
17
+ MLIT publishes Handbook #00 edition 5.0 twice on one date: `第5.0版 本編`
18
+ (main volume) and `第5.0版 本編+付録` (main volume and appendix, one PDF);
19
+ edition 6.0 only as `本編+付録`. `Identifiers::Handbook#volume` keeps that
20
+ label as Japanese text, as `edition` keeps `第X.Y版`. The grammar accepts it
21
+ only after an edition, separated by an ASCII or a full-width (U+3000) space,
22
+ with `+` or ASCII `+`; the builder normalizes `+` to `+`, and `to_s` writes
23
+ one ASCII space before it. The renderer prints the volume only after an
24
+ edition, so `exclude(:edition)` still renders a string that parses.
25
+ `volume` is in `==` and is `subset_strict`, so `#00 第5.0版` matches neither
26
+ labelled publication. A nil volume leaves no key in `to_hash`, so the
27
+ existing index-v2 rows keep their hash. The pubid-scheme URN carries it as
28
+ the `vol-` atom; `to_urn` is unchanged (it has no edition either). Locked by
29
+ `spec/pubid/plateau/handbook_volume_spec.rb`.
30
+
31
+ Known gaps, not caused by this change: a Technical Report drops an edition
32
+ and a volume without a diagnostic (the `handbook` rule matches any
33
+ `doc_type`), and `to_mr_string` raises for every Handbook with an edition
34
+ (the base `mr_edition` calls `edition.number` on a String).
35
+
36
+ ## The grammar is baked here ahead of pubid-grammar
37
+
38
+ `data/parg/plateau.json` was compiled here from its own embedded `source`
39
+ plus the volume rule, at parsanol **1.3.114** (the version of the earlier
40
+ bake; force it with `gem "parsanol", "1.3.114"`, as
41
+ `pubid-grammar/scripts/bake-one.rb` does, because the binstub resolves the
42
+ latest installed gem). Until pubid-grammar takes hand-off
43
+ `pubid__pubid-grammar__plateau-handbook-volume`, its `grammars/plateau.parg`
44
+ is the OLD grammar. To change the grammar, extract the source with
45
+ `jq -j .source data/parg/plateau.json` (`-j`: the source has no trailing
46
+ newline, and one extra byte changes the checksum), edit, re-bake, then run
47
+ `bundle exec parsanol parg test data/parg/plateau.json`. Read the `.parg`
48
+ file as UTF-8: under a US-ASCII locale the parsanol lexer raises on `第`.
@@ -34,6 +34,10 @@ module Pubid
34
34
  if parsed[:edition] && klass == Identifiers::Handbook
35
35
  params[:edition] = parsed[:edition].to_s
36
36
  end
37
+ # The grammar accepts an ASCII "+"; MLIT prints the full-width "+".
38
+ if parsed[:volume] && klass == Identifiers::Handbook
39
+ params[:volume] = parsed[:volume].to_s.tr("+", "+")
40
+ end
37
41
 
38
42
  klass.new(**params)
39
43
  end
@@ -4,10 +4,18 @@ module Pubid
4
4
  module Plateau
5
5
  module Identifiers
6
6
  # PLATEAU Handbook
7
- # Format: PLATEAU Handbook #NN[-annex] [第X.Y版]
7
+ # Format: PLATEAU Handbook #NN[-annex] [第X.Y版[ volume]]
8
8
  # Example: PLATEAU Handbook #00 第1.0版
9
+ # Example: PLATEAU Handbook #00 第5.0版 本編+付録
9
10
  class Handbook < Identifier
10
11
  attribute :edition, :string, default: -> {}
12
+ # The volume label MLIT prints after the edition: "本編" (the main
13
+ # volume alone) or "本編+付録" (main volume and appendix, one PDF).
14
+ attribute :volume, :string, default: -> {}
15
+
16
+ # A nil `volume` means the publication has no volume label:
17
+ # `#00 第5.0版` does not subsume `#00 第5.0版 本編+付録`.
18
+ subset_strict :volume
11
19
 
12
20
  def type_string
13
21
  "Handbook"
@@ -20,7 +28,7 @@ module Pubid
20
28
  def ==(other)
21
29
  return false unless other.is_a?(Handbook)
22
30
 
23
- super && edition == other.edition
31
+ super && edition == other.edition && volume == other.volume
24
32
  end
25
33
  end
26
34
  end
@@ -13,6 +13,7 @@ module Pubid
13
13
  atom "publisher", "pub"
14
14
  atom "edition", "ed"
15
15
  atom "annex", "annex", kind: :int
16
+ atom "volume", "vol"
16
17
 
17
18
  number = [["number", :int]]
18
19
  type "pubid:plateau:annex", "annex", core: [],