pubid 2.0.2 → 2.0.3

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/data/ieee/update_codes.yaml +2 -2
  3. data/data/parg/aiee.json +1 -1
  4. data/data/parg/aiee.parg +99 -9
  5. data/data/parg/iala.json +1 -1
  6. data/data/parg/ieee.json +1 -1
  7. data/data/parg/ire.json +1 -1
  8. data/data/parg/ire.parg +51 -12
  9. data/data/parg/iso.json +1 -1
  10. data/data/parg/iso.parg +751 -0
  11. data/data/parg/jis.json +1 -1
  12. data/data/parg/nesc.json +1 -1
  13. data/data/parg/nesc.parg +97 -12
  14. data/data/parg/parg.json +1 -1
  15. data/lib/pubid/iala/builder.rb +48 -39
  16. data/lib/pubid/iala/identifier.rb +1 -1
  17. data/lib/pubid/ieee/CLAUDE.md +2 -0
  18. data/lib/pubid/ieee/aiee/identifier.rb +1 -1
  19. data/lib/pubid/ieee/builder.rb +175 -28
  20. data/lib/pubid/ieee/components/code.rb +46 -7
  21. data/lib/pubid/ieee/identifiers/base.rb +4 -1
  22. data/lib/pubid/ieee/identifiers/joint_development.rb +7 -3
  23. data/lib/pubid/ieee/ire/identifier.rb +1 -1
  24. data/lib/pubid/ieee/nesc/builder.rb +1 -1
  25. data/lib/pubid/ieee/project_renderer.rb +5 -1
  26. data/lib/pubid/ieee/renderer.rb +4 -2
  27. data/lib/pubid/ieee/urn_generator.rb +4 -1
  28. data/lib/pubid/iho/CLAUDE.md +15 -6
  29. data/lib/pubid/iho/urn_generator.rb +9 -6
  30. data/lib/pubid/iho/urn_parser.rb +43 -40
  31. data/lib/pubid/iso/CLAUDE.md +66 -0
  32. data/lib/pubid/iso/builder.rb +228 -14
  33. data/lib/pubid/iso/identifier.rb +22 -12
  34. data/lib/pubid/iso/normalizer.rb +5 -4
  35. data/lib/pubid/iso/urn_parser.rb +13 -8
  36. data/lib/pubid/jis/CLAUDE.md +36 -0
  37. data/lib/pubid/jis/builder.rb +12 -12
  38. data/lib/pubid/jis/identifier.rb +11 -1
  39. data/lib/pubid/jis/identifiers/welding_engineering_standard.rb +35 -0
  40. data/lib/pubid/jis/identifiers.rb +2 -0
  41. data/lib/pubid/jis/renderer.rb +1 -1
  42. data/lib/pubid/jis/supplement_identifier.rb +5 -5
  43. data/lib/pubid/jis/urn_parser.rb +2 -1
  44. data/lib/pubid/jis.rb +3 -2
  45. data/lib/pubid/version.rb +1 -1
  46. metadata +5 -2
@@ -2,20 +2,20 @@
2
2
 
3
3
  module Pubid
4
4
  module Iala
5
- # Builds an IALA identifier object from the Parslet parse tree.
5
+ # Builds an IALA identifier object from the artifact's bound map —
6
+ # the flat attribute hash the grammar's bindings block declares
7
+ # (typed at the engine, one key per capture).
6
8
  class Builder
7
9
  def build(parsed)
8
- hash = parsed
10
+ return build_annex(parsed) if parsed[:annex_marker]
9
11
 
10
- return build_annex(hash) if hash[:annex_marker]
11
-
12
- type_letter = stringify(hash[:type_letter])&.upcase
12
+ type_letter = stringify(parsed[:type_letter])&.upcase
13
13
  klass = Iala.identifier_klass_for_type_letter(type_letter)
14
14
 
15
15
  attrs = {
16
- number: build_number(hash, klass),
17
- edition: stringify(hash.dig(:edition, :edition_value)),
18
- language: stringify(hash.dig(:language_group, :language))&.upcase,
16
+ number: build_number(parsed, klass),
17
+ edition: stringify(parsed[:edition_value]),
18
+ language: stringify(parsed[:language])&.upcase,
19
19
  }.compact
20
20
 
21
21
  klass.new(**attrs)
@@ -27,31 +27,34 @@ module Pubid
27
27
 
28
28
  private
29
29
 
30
- # Build the Annex wrapper from the parse tree. The base sub-tree
31
- # (captured under :base) is recursively built via the same logic as
32
- # a flat identifier, then wrapped.
30
+ # Build the Annex wrapper from the bound map. The base composite in
31
+ # the grammar stays unbound: its captures (type_letter, doc_number,
32
+ # …) are the only occurrence of each name on the annex path, so the
33
+ # harvest hoists them to the top level and this branch reads the
34
+ # same flat keys as the plain identifier.
33
35
  def build_annex(hash)
34
- base_hash = hash[:base]
35
- base_type = stringify(base_hash[:type_letter])&.upcase
36
+ base_type = stringify(hash[:type_letter])&.upcase
36
37
  base_klass = Iala.identifier_klass_for_type_letter(base_type)
37
- base = base_klass.new(number: build_number(base_hash, base_klass))
38
+ base = base_klass.new(number: build_number(hash, base_klass))
38
39
 
39
40
  attrs = {
40
41
  base: base,
41
- annex_form: stringify(hash[:annex_marker]),
42
- letter: stringify(hash[:annex_letter]),
43
- edition: stringify(hash.dig(:edition, :edition_value)),
44
- language: stringify(hash.dig(:language_group, :language))&.upcase,
42
+ annex_form: stringify(hash[:annex_marker]),
43
+ letter: stringify(hash[:annex_letter]),
44
+ edition: stringify(hash[:edition_value]),
45
+ language: stringify(hash[:language])&.upcase,
45
46
  }.compact
46
47
 
47
48
  Identifiers::Annex.new(**attrs)
48
49
  end
49
50
 
50
- # Compose the full number string from the leading doc_number plus any
51
- # dotted continuation (GA01.13 → "01.13", L2.1.11 → "2.1.11") plus
52
- # the optional dash-separated subpart (C0103-1 → "0103-1", L2.7.1-2
53
- # → "2.7.1-2"). Padding is class-driven: the resolved identifier
54
- # class declares its canonical IALA cover-page widths via
51
+ # Compose the full number string from the bound map's three number
52
+ # columns: the leading doc_number, the dotted continuation segments
53
+ # (doc_number_dots, each `{dotted_segment: "01"}`) and the dash
54
+ # subpart runs (subpart, each `{subpart_number: "1"}`). GA01.13 →
55
+ # "01.13", L2.1.11 → "2.1.11", C0103-1 → "0103-1", L2.7.1-2 →
56
+ # "2.7.1-2". Padding is class-driven: the resolved identifier class
57
+ # declares its canonical IALA cover-page widths via
55
58
  # `canonical_number_width` / `canonical_dotted_segment_width`, so a
56
59
  # new type declares its padding on its own class instead of editing
57
60
  # this builder's case statement.
@@ -61,34 +64,40 @@ module Pubid
61
64
 
62
65
  base = canonical_base(base, klass)
63
66
 
64
- dots = hash[:doc_number_dots]
65
- dots_str = dots.is_a?(Array) ? "" : canonical_dots(stringify(dots).to_s, klass)
67
+ dots = canonical_dots(
68
+ number_segments(hash[:doc_number_dots], :dotted_segment), klass
69
+ )
70
+ dots_str = dots.empty? ? "" : ".#{dots}"
71
+
72
+ subparts = number_segments(hash[:subpart], :subpart_number)
73
+ subpart_str = subparts.empty? ? "" : "-#{subparts.join('-')}"
66
74
 
67
- subpart_str = hash[:subpart] ? "-#{subpart_to_s(hash[:subpart])}" : ""
68
- subpart_str = "" if subpart_str == "-"
69
75
  "#{base}#{dots_str}#{subpart_str}"
70
76
  end
71
77
 
78
+ # The string values of a bound repetition column: doc_number_dots
79
+ # carries `{dotted_segment: …}` elements, subpart carries
80
+ # `{subpart_number: …}` elements.
81
+ def number_segments(column, key)
82
+ Array(column).filter_map do |element|
83
+ stringify(element[key]) if element.is_a?(Hash)
84
+ end
85
+ end
86
+
72
87
  def canonical_base(base, klass)
73
88
  width = klass&.canonical_number_width
74
89
  width ? base.rjust(width, "0") : base
75
90
  end
76
91
 
77
- def canonical_dots(str, klass)
78
- return "" if str.empty?
92
+ # Pad each dotted continuation segment to the class's canonical
93
+ # width; nil width preserves the segments verbatim (the L series).
94
+ def canonical_dots(segments, klass)
95
+ return "" if segments.empty?
79
96
 
80
97
  width = klass&.canonical_dotted_segment_width
81
- return str unless width
82
-
83
- str.split(".", -1).map { |seg| seg.empty? ? seg : seg.rjust(width, "0") }.join(".")
84
- end
85
-
86
- # The subpart capture may be an array of {subpart_number: "1"},
87
- # {subpart_number: "9"}, … (repeated matches). Join with "-".
88
- def subpart_to_s(subpart)
89
- return stringify(subpart).to_s unless subpart.is_a?(Array)
98
+ return segments.join(".") unless width
90
99
 
91
- subpart.filter_map { |h| h.is_a?(Hash) ? stringify(h[:subpart_number]) : nil }.join("-")
100
+ segments.map { |segment| segment.rjust(width, "0") }.join(".")
92
101
  end
93
102
 
94
103
  def stringify(value)
@@ -33,7 +33,7 @@ module Pubid
33
33
  if FormatDetector.detect(identifier) == :urn
34
34
  UrnParser.parse(identifier)
35
35
  else
36
- parsed = Pubid::Parg::Backend.parse(:iala, identifier)
36
+ parsed = Pubid::Parg::Model.bound(:iala, identifier)
37
37
  Builder.build(parsed)
38
38
  end
39
39
  end
@@ -97,3 +97,5 @@ The lesson BSI paid for: a wrapper's hand-written `#number` delegation makes the
97
97
  - **The project designator format, and the bare-P parse that dropped it (pubid#18).** IEEE's own draft documents are cited by their short designator — "P1201/D0.3", "P10000/D1.2" — and metanorma-ieee had to post-process the full rendering to produce it. Two edits. **(1) `format: :project`** registers `Pubid::Ieee::ProjectRenderer` in the IEEE format registry: the code (with parts), a PREPENDED "P" when the identifier is a project but the code does not already carry it, and "/D<draft>"; no publisher, no type word, no date. "Project" is detected in all three places a marker can live depending on the building path — `code_obj.prefix` (the bare spelling), the "P" type code, or a `typed_stage` with `project_status` (D1-D6). **(2) The bare-P data loss**: `ieee_p_identifier` consumed its leading "P" with a bare `str("P")`, so "P1201/D0.3" parsed WITHOUT its P anywhere (the number was "1201", prefix nil) — the issue's complaint verbatim. The rule now lets `number` capture the P (the #318 pattern), so `code.prefix` survives and the designator has something to read. The DEFAULT rendering is unchanged — the corpus keeps the P in every unapproved-draft canonical, and #318's idempotence work locked that; the issue's suggestion that the default drop the P predates the corpus and is not adopted. A published (non-project) id renders its bare number under `:project` — the format is a draft-designator view.
98
98
 
99
99
  - **Joint stage-first forms are lead ISO, and the ISO face is lossless (pubid#477)**: a stage word printed in the ISO position (`IEEE FCD 15026.3:2010`, `IEEE/IEC CD2 P62582-2017-05`) parses as printed (#469): `Builder#build_joint_development` sets `lead_party "ISO"` whatever the publisher order, so `canonical_format` is `:iso`. PR #472 had made the lead the first printed publisher and rendered an IEEE-first form in the `D=` face — but the grammar artifact cannot parse most of those renders (`D=FCD`, `D=CD.2`, a non-P number, a month), and `D=CD-2020` parses into a *different* id (`ieee_draft`, no `iso_stage`), so 19 published `relaton-data-ieee` rows stopped matching themselves. The `D=` face now prints only for an id that carries a `D=` designator, or on an explicit `to_s(format: :ieee)`. `JointDevelopment#to_iso_format` prints `iso_stage` as stored (`DIS2`, `CD4` — #472's bare `CD` lost the iteration) and the date in the spelling that parses back (`#iso_date_suffix`: `:2018`, `-2018-05`, `, April 2019`). **The test is `parse(to_s) == id`, not a stable `to_s`**: a lossy render reaches a fixed point and round-trips its *hash* (`iso_stage_spellings_spec` held both while dropping `CD4`→`CD`), yet the reparse is a different identifier. Measured over the 118 joint stage rows of `relaton-data-ieee`: `to_s` stable 100 → 118, `==` 51 → 106; the other 12 carry an `ieee_draft` or an edition that the ISO face still drops. **PARG separator-in-capture, again (#471)**: the draft `revision` capture is `".12"`, and `Draft#to_s` adds its own dot, so `handle_draft` strips the leading dot (`D2022.10.12` rendered `D2022.10..12`). Check every capture a renderer re-joins with a separator. Locked by `spec/pubid/ieee/joint_stage_roundtrip_spec.rb` (stored index hashes → `parse(to_s) == row`).
100
+
101
+ - **IEEE parses through the edition-2 artifact's typed model bindings (the last converter wave)**: the three `Backend.parse` sites (`identifiers/base.rb` `parse_single`, `Aiee::Identifier.parse`, `Ire::Identifier.parse`) route through `Pubid::Parg::Model.bound` — the grammar's `bindings` block produces the builder-ready hash at the engine, replacing the tree normalization. The grammar side (pubid-grammar `grammars/{ieee,nesc}.parg` edition 2 + bindings, plus a class-ref inlining pass over the merged `aiee.parg`/`ire.parg`) keeps every capture the builder reads: leaf scalars bind `(string)`; the branch/wrapper captures the builder dispatches on (`draft`, `parameters`, `publishers`, `base`, `relationship_type`, `related_ids`, the aiee/ire/nesc wrappers, …) bind `(raw)` — raw subtrees carry `Parsanol::Slice` leaves whose `to_s`/`==` behave like strings, so `extract_value` and the `.is_a?(Hash)` probes are unchanged. **Load-bearing grammar deviations** (documented in the `.parg` headers): the zero-width markers are re-spelled as real-token captures (`printed_dash_year`/`printed_month_year` capture their branch's leading separator, `iec_label_dash` the dash, `iso_published` the whole joint head choice — every read sits behind a nil-`iso_stage` gate, so the wider presence set is inert exactly where v1's was narrow); the `([dash]) as draft_dash` / `([rev_sep] as rev_sep)` capture-over-OPTIONAL landmines are `[ (x) as y ]` (parsanol-ruby#394); nested-only captures that collide with a top-level name rename with the builder (`draft_date_month/year/day`, `draft_version_dash_*`, `fdraft_month/year`, and `reaffirmed_year` — unbound, because binding it would have surfaced the reaffirmation year v1 kept absent). **Class RULE refs (`digit`/`lower`/`upper`, and multi-char ones like `alnum_char`/`identifier_string`'s `non_nl`) materialize per-token named nodes inside captures when the runtime recompiles the `use`-importing source — the vendored `data/parg/{aiee,ire,nesc}.parg` copies are the live import sources, so they must be re-vendored together with the artifacts** (a stale `.parg` beside a fresh `.json` silently parses through the old grammar). **Builder accommodations** (all dual-key with the v1 spellings as fallback, so HEAD trees and bound maps read alike): the renamed nested keys; and the multi-segment draft-version normalization at the top of `build_single_identifier` — edition-2 trees materialize a "D2.1.1" designator as per-token nodes with no `:draft_version`, where v1 degraded to a whole-clause leaf (reconstructed: the leaf string → `Draft.parse` inverse) or to a date-only hash when a draft date follows (no draft built). **Measured**: fixture-corpus parity (old artifact + tree backend + builder vs new artifact + bound + builder) over all 9,054 pass-fixture inputs → 1 diff, and standalone aiee/ire/nesc sweeps → 0 diffs each. **The one residue is an engine wall, documented not chased**: `IEC/IEEE PC37.60/P62271-111_D6.1` (testsuite case `ieee.project_draft_identifier.0946`, one corpus input, one `draft_compact_convergence` example) — v1's edition-1 tree degraded its whole draft clause ("DP62271-111_D6.1, Aug 2016", two P-numbers plus date) into a single leaf, and edition-2's fully-named trees cannot reproduce that collapse at any binding spelling (isolated rule probes + programmatic branch bisect); the row is one of the two-P-number joint one-offs already recorded as deferred above. The conversion also **fixed** two testsuite rows (`ieee.project_draft_identifier.9263` "ISO/IEC/IEEE P42010.2019/D4" / "P42010.WD4:2019(E)" — the lettered `draft_iso_iteration` capture now binds, so the D= face composes), at the cost of the 0946 residue above.
@@ -35,7 +35,7 @@ module Pubid
35
35
 
36
36
  # Parse AIEE identifier string
37
37
  def self.parse(input)
38
- parsed = Pubid::Parg::Backend.parse(:aiee, input,
38
+ parsed = Pubid::Parg::Model.bound(:aiee, input,
39
39
  entry: "aiee_identifier")
40
40
  Builder.new.build(parsed)
41
41
  end
@@ -283,21 +283,90 @@ module Pubid
283
283
  # Parslet can return array of hashes - merge them
284
284
  parsed_hash = parsed.is_a?(Array) ? merge_parsed_array(parsed) : parsed
285
285
 
286
+ # The grammar binds the bare top-level draft clause as
287
+ # :draft_version_scalar (string) — the only cross-engine-stable
288
+ # scalar — and the composite draft rule as :draft_version_composite
289
+ # (raw). Surface the scalar under the top-level key the rest of the
290
+ # builder has always read, and the composite under the interior key
291
+ # the draft-element walks read.
292
+ if parsed_hash[:draft_version].nil? && parsed_hash[:draft_version_scalar]
293
+ parsed_hash[:draft_version] = parsed_hash.delete(:draft_version_scalar)
294
+ end
295
+ draft_elems = parsed_hash[:draft].is_a?(Array) ? parsed_hash[:draft] : [parsed_hash[:draft]]
296
+ draft_elems.each do |e|
297
+ next unless e.is_a?(Hash) && e[:draft_version].nil? && e[:draft_version_composite]
298
+
299
+ e[:draft_version] = e.delete(:draft_version_composite)
300
+ end
301
+ # The subpart run ("7.4-3-2" carries two) rides the raw
302
+ # part_subpart_year wrapper — the subparts[] array harvest emitted
303
+ # empty hashes for non-firing rep iterations on wasm
304
+ # (parsanol-ruby#399), so the run no longer binds top-level.
305
+ # Hoist the wrapper's part/year and deep-collect its interior
306
+ # subparts so every consumer — and every raw subtree built through
307
+ # here (the CSA dual's ieee_portion, the multi-numbered primary,
308
+ # the supplement bases) — sees the flat keys it has always read.
309
+ parsed_hash = unwrap_part_subpart_year(parsed_hash)
310
+
286
311
  # Grammar-captured dash dates (relaton's "/D-3-2017-07" and
287
312
  # "/R-x-2011-04" tails) land on the identifier's year/month exactly
288
313
  # like the number's own date clause. The revision-suffix form sits at
289
314
  # the top level; the draft tail nests under :draft — either as its
290
315
  # own element or wrapped in a draft_version hash.
316
+ # A revision element INSIDE the draft array is the draft rule's own
317
+ # split artifact (the version's trailing ".1" segment) — the spine
318
+ # captured the same value, and leaving it there makes the REV-date
319
+ # embed glue the draft date into the designator. A rev_inline
320
+ # capture never lands inside the draft array, so this only ever
321
+ # drops the artifact.
322
+ if parsed_hash[:draft].is_a?(Array) && parsed_hash[:revision]
323
+ artifact = parsed_hash[:draft].any? do |e|
324
+ e.is_a?(Hash) && e.key?(:revision) && e[:revision].to_s == parsed_hash[:revision].to_s
325
+ end
326
+ parsed_hash.delete(:revision) if artifact
327
+ end
328
+ # The edition-2 tree materializes a multi-segment draft version
329
+ # ("D2.1.1") as per-token nodes carrying no :draft_version key. The
330
+ # v1 tree degraded such a clause to a whole-clause LEAF string when
331
+ # no draft date follows (the designator survives through the
332
+ # Draft.parse inverse), and to a date-only hash when it does (the
333
+ # version is dropped, no draft built). Normalize to the v1 shape
334
+ # here — before the stage derivation and draft handling read it —
335
+ # so every consumer sees what v1 saw.
336
+ if parsed_hash[:draft].is_a?(Array)
337
+ draft_elems = parsed_hash[:draft].select { |e| e.is_a?(Hash) }
338
+ if !draft_elems.empty? && draft_elems.none? { |e| e.key?(:draft_version) }
339
+ has_date = draft_elems.any? { |e| e.key?(:draft_date_month) || e.key?(:draft_date_year) }
340
+ if has_date
341
+ date_elems = draft_elems.select do |e|
342
+ e.key?(:draft_date_month) || e.key?(:draft_date_year) || e.key?(:draft_date_day)
343
+ end
344
+ parsed_hash[:draft] = date_elems.inject({}, :merge)
345
+ else
346
+ parsed_hash[:draft] = draft_elems.flat_map { |e| e.values }.join
347
+ end
348
+ end
349
+ end
291
350
  drafts = parsed_hash[:draft].is_a?(Array) ? parsed_hash[:draft] : [parsed_hash[:draft]].compact
292
351
  drafts.each do |d|
293
352
  next unless d.is_a?(Hash) && d[:draft_version].is_a?(Hash)
294
353
  dv = d[:draft_version]
295
- d[:dash_year] = dv[:dash_year] if dv[:dash_year]
296
- d[:dash_month] = dv[:dash_month] if dv[:dash_month]
354
+ # The edition-2 grammar renames the nested draft-version dash
355
+ # captures (draft_version_dash_year/month) to keep them out of the
356
+ # flat bound map; the v1 tree spelling stays as the fallback.
357
+ d[:dash_year] = (dv[:draft_version_dash_year] || dv[:dash_year]) if
358
+ dv[:draft_version_dash_year] || dv[:dash_year]
359
+ d[:dash_month] = (dv[:draft_version_dash_month] || dv[:dash_month]) if
360
+ dv[:draft_version_dash_month] || dv[:dash_month]
297
361
  d.delete(:draft_version)
298
362
  end
299
363
  dash = parsed_hash[:dash_year] ? parsed_hash : nil
300
- dash ||= drafts.find { |d| d.is_a?(Hash) && d[:dash_year] }
364
+ # The draft_date captures rename with the draft_date_ prefix on the
365
+ # edition-2 grammar (nested-only, kept off the identity keys); v1
366
+ # spelling stays as the fallback.
367
+ dash ||= drafts.find do |d|
368
+ d.is_a?(Hash) && (d[:draft_date_dash_year] || d[:dash_year])
369
+ end
301
370
  if dash.nil? && parsed_hash[:draft]
302
371
  # A numeric month trailing a draft is the draft's own date: it
303
372
  # embeds in the designator ("D09, 04 2011" -> "D09-2011-04")
@@ -323,25 +392,32 @@ module Pubid
323
392
  if dash
324
393
  joined = drafts.filter_map { |d| d.is_a?(Hash) ? d[:draft_version] : nil }
325
394
  .map { |v| v.is_a?(Hash) ? v[:value] : v.to_s }.join
395
+ # dash is either the top-level spine (dash_year/dash_month) or the
396
+ # draft's own hash (draft_date_dash_year/month on the edition-2
397
+ # grammar, dash_year/month on v1) — read both spellings.
398
+ dash_y = dash[:dash_year] || dash[:draft_date_dash_year]
399
+ dash_m = dash[:dash_month] || dash[:draft_date_dash_month]
326
400
  bare_joint_draft = parsed_hash[:joint_publishers] &&
327
401
  parsed_hash[:part].nil? &&
328
402
  parsed_hash[:iso_stage].nil? &&
329
403
  drafts.any? { |d| d.is_a?(Hash) && d[:draft_version] }
330
404
  dash_led = joined.start_with?("-")
331
405
  if bare_joint_draft && dash_led
332
- parsed_hash[:part] = { value: dash[:dash_year] }
333
- elsif dash[:dash_month] &&
406
+ parsed_hash[:part] = { value: dash_y }
407
+ elsif dash_m &&
334
408
  (host = drafts.find { |d| d.is_a?(Hash) && d[:draft_version] })
335
409
  # The draft's own numeric date embeds in the designator
336
410
  # ("D2-2015-04") — the "D2, 04 2015" comma spelling
337
411
  # normalizes onto it (pubid ruling 2026-10-13).
338
412
  host[:draft_version] =
339
- [joined, dash[:dash_year], dash[:dash_month]].compact.join("-")
413
+ [joined, dash_y, dash_m].compact.join("-")
340
414
  dash.delete(:dash_year)
341
415
  dash.delete(:dash_month)
342
- elsif (dash[:dash_month] || dash_led) && parsed_hash[:year].nil?
343
- parsed_hash[:year] = dash[:dash_year]
344
- parsed_hash[:month] = dash[:dash_month]
416
+ dash.delete(:draft_date_dash_year)
417
+ dash.delete(:draft_date_dash_month)
418
+ elsif (dash_m || dash_led) && parsed_hash[:year].nil?
419
+ parsed_hash[:year] = dash_y
420
+ parsed_hash[:month] = dash_m
345
421
  else
346
422
  # Year-only tail embeds in the draft face ("D9-2006"), the model
347
423
  # the relaton-pinned renders use for corrupted update_codes
@@ -353,15 +429,17 @@ module Pubid
353
429
  # ("D9-2006", and with its month "D2-2015-04") — the
354
430
  # "D2, 04 2015" comma spelling normalizes onto it.
355
431
  host[:draft_version] =
356
- [joined, dash[:dash_year], dash[:dash_month]].compact.join("-")
432
+ [joined, dash_y, dash_m].compact.join("-")
357
433
  dash.delete(:dash_year)
358
434
  dash.delete(:dash_month)
435
+ dash.delete(:draft_date_dash_year)
436
+ dash.delete(:draft_date_dash_month)
359
437
  # the hyphenated "/D-2-2007" spelling's year the spine
360
438
  # captured at top level is the draft's own — vacate it
361
- parsed_hash.delete(:year) if parsed_hash[:year] == dash[:dash_year]
439
+ parsed_hash.delete(:year) if parsed_hash[:year] == dash_y
362
440
  else
363
- parsed_hash[:year] = dash[:dash_year]
364
- parsed_hash[:month] = dash[:dash_month]
441
+ parsed_hash[:year] = dash_y
442
+ parsed_hash[:month] = dash_m
365
443
  end
366
444
  end
367
445
  end
@@ -456,8 +534,11 @@ module Pubid
456
534
  # "IEEE P15026.2/D=FDIS-201008" (pubid#203 ruling). The
457
535
  # stage and date ride the designator; the ISO face is not
458
536
  # elected.
459
- year = extract_value(fd[:year])
460
- month = extract_value(fd[:month])
537
+ # fdraft's month/year rename with the fdraft_ prefix on the
538
+ # edition-2 grammar (nested-only); v1 spelling stays as the
539
+ # fallback.
540
+ year = extract_value(fd[:fdraft_year] || fd[:year])
541
+ month = extract_value(fd[:fdraft_month] || fd[:month])
461
542
  date = ""
462
543
  if year
463
544
  date = "-#{year}"
@@ -500,6 +581,11 @@ module Pubid
500
581
  def reconstruct_base_string(base_data)
501
582
  base_parts = []
502
583
 
584
+ # The part/subpart/year captures sit one level down, inside the
585
+ # raw part_subpart_year wrapper (parsanol-ruby#399) — hoist them
586
+ # before the flat reads below.
587
+ base_data = unwrap_part_subpart_year(base_data)
588
+
503
589
  # Extract publisher
504
590
  if base_data[:publishers]
505
591
  pub_data = base_data[:publishers]
@@ -536,11 +622,14 @@ module Pubid
536
622
  number_str += part_val if part_val
537
623
  end
538
624
 
539
- # Add subpart if present
625
+ # Add subpart if present — the bound run arrives as subparts[]
626
+ # harvest hashes ({subpart: …}); v1's flat spelling stays as the
627
+ # fallback.
540
628
  if base_data[:subpart]
541
629
  subparts = base_data[:subpart]
542
630
  subparts = [subparts] unless subparts.is_a?(Array)
543
631
  subparts.each do |sp|
632
+ sp = sp[:subpart] if sp.is_a?(Hash) && sp.key?(:subpart)
544
633
  subpart_val = extract_value(sp)
545
634
  number_str += subpart_val if subpart_val
546
635
  end
@@ -681,7 +770,7 @@ module Pubid
681
770
  def build_interpretation_supplement(parsed_hash)
682
771
  # Reconstruct base identifier string from parsed components (same logic as corrigendum)
683
772
  base_parts = []
684
- base_data = parsed_hash[:base]
773
+ base_data = unwrap_part_subpart_year(parsed_hash[:base])
685
774
 
686
775
  # Extract publisher
687
776
  if base_data[:publishers]
@@ -714,11 +803,14 @@ module Pubid
714
803
  number_str += part_val if part_val
715
804
  end
716
805
 
717
- # Add subpart if present
806
+ # Add subpart if present — the bound run arrives as subparts[]
807
+ # harvest hashes ({subpart: …}); v1's flat spelling stays as the
808
+ # fallback.
718
809
  if base_data[:subpart]
719
810
  subparts = base_data[:subpart]
720
811
  subparts = [subparts] unless subparts.is_a?(Array)
721
812
  subparts.each do |sp|
813
+ sp = sp[:subpart] if sp.is_a?(Hash) && sp.key?(:subpart)
722
814
  subpart_val = extract_value(sp)
723
815
  number_str += subpart_val if subpart_val
724
816
  end
@@ -754,7 +846,7 @@ module Pubid
754
846
  def build_conformance_supplement(parsed_hash)
755
847
  # Reconstruct base identifier string from parsed components (same logic as corrigendum)
756
848
  base_parts = []
757
- base_data = parsed_hash[:base]
849
+ base_data = unwrap_part_subpart_year(parsed_hash[:base])
758
850
 
759
851
  # Extract publisher
760
852
  if base_data[:publishers]
@@ -787,11 +879,14 @@ module Pubid
787
879
  number_str += part_val if part_val
788
880
  end
789
881
 
790
- # Add subpart if present
882
+ # Add subpart if present — the bound run arrives as subparts[]
883
+ # harvest hashes ({subpart: …}); v1's flat spelling stays as the
884
+ # fallback.
791
885
  if base_data[:subpart]
792
886
  subparts = base_data[:subpart]
793
887
  subparts = [subparts] unless subparts.is_a?(Array)
794
888
  subparts.each do |sp|
889
+ sp = sp[:subpart] if sp.is_a?(Hash) && sp.key?(:subpart)
795
890
  subpart_val = extract_value(sp)
796
891
  number_str += subpart_val if subpart_val
797
892
  end
@@ -1320,9 +1415,12 @@ module Pubid
1320
1415
  # Extract SI type (SI or PSI)
1321
1416
  si_type = extract_value(parsed[:si_type])
1322
1417
 
1323
- # Extract publishers (should be "IEEE/ASTM")
1324
- if parsed[:publishers]
1325
- publishers_str = extract_value(parsed[:publishers])
1418
+ # Extract publishers (should be "IEEE/ASTM") — the SI rule's bare
1419
+ # publishers clause binds as publishers_scalar (string); the
1420
+ # composite joint rows deliver :publishers (raw).
1421
+ pub_src = parsed[:publishers] || parsed[:publishers_scalar]
1422
+ if pub_src
1423
+ publishers_str = extract_value(pub_src)
1326
1424
  # Split by slash to get individual publishers
1327
1425
  pubs = publishers_str.split("/")
1328
1426
  attributes[:publisher] = pubs.first
@@ -1334,6 +1432,10 @@ module Pubid
1334
1432
 
1335
1433
  # For PSI (draft), create proper Draft component object
1336
1434
  if si_type == "PSI"
1435
+ # The bare top-level draft clause binds as :draft_version_scalar
1436
+ # (string) — surface it under the key this builder reads.
1437
+ parsed[:draft_version] = parsed.delete(:draft_version_scalar) if
1438
+ parsed[:draft_version].nil? && parsed[:draft_version_scalar]
1337
1439
  if parsed[:draft_version]
1338
1440
  draft_version = extract_value(parsed[:draft_version])
1339
1441
  # Create Draft component (Lutaml::Model object)
@@ -1904,6 +2006,42 @@ module Pubid
1904
2006
  str_value.length.positive? ? str_value : nil
1905
2007
  end
1906
2008
 
2009
+ # Deep-first collection of every :subpart node under a raw
2010
+ # part_subpart_year wrapper (Hash or Array, any nesting).
2011
+ def collect_subparts(node, out = [])
2012
+ case node
2013
+ when Hash
2014
+ node.each do |key, value|
2015
+ if key.to_sym == :subpart
2016
+ out << value
2017
+ else
2018
+ collect_subparts(value, out)
2019
+ end
2020
+ end
2021
+ when Array then node.each { |item| collect_subparts(item, out) }
2022
+ end
2023
+ out
2024
+ end
2025
+
2026
+ # The part/subpart/year captures ride the raw part_subpart_year
2027
+ # wrapper inside raw subtrees (parsanol-ruby#399: the subparts[]
2028
+ # array harvest is not cross-engine stable, so the run does not
2029
+ # bind top-level). Hoist the wrapper's part/year onto a copy of the
2030
+ # subtree and surface the interior subpart run under :subpart, so
2031
+ # the flat reads downstream see the shapes they always read.
2032
+ def unwrap_part_subpart_year(node)
2033
+ return node unless node.is_a?(Hash) && (run = node[:part_subpart_year])
2034
+
2035
+ nodes = run.is_a?(Array) ? run : [run]
2036
+ flat = nodes.select { |item| item.is_a?(Hash) }.inject({}, :merge)
2037
+ node = node.merge(flat.slice(:part, :year))
2038
+ unless node[:subpart]
2039
+ collected = collect_subparts(run)
2040
+ node = node.merge(subpart: collected) unless collected.empty?
2041
+ end
2042
+ node
2043
+ end
2044
+
1907
2045
  # Extract optional attribute if present
1908
2046
  def extract_optional(parsed, attributes, key)
1909
2047
  value = extract_value(parsed[key])
@@ -2004,15 +2142,20 @@ module Pubid
2004
2142
  iso_stage ||= extract_value(draft_data[:draft_iso_stage]) if draft_data[:draft_iso_stage]
2005
2143
  iso_iteration = extract_value(draft_data[:draft_iso_iteration]) if draft_data[:draft_iso_iteration]
2006
2144
 
2007
- # Extract date information from draft data
2008
- month_slice = draft_data[:month]
2145
+ # Extract date information from draft data. The edition-2 grammar
2146
+ # renames draft_date's nested month/year/day captures with the
2147
+ # draft_date_ prefix (kept off the identity keys); the v1
2148
+ # spellings stay as the fallback.
2149
+ month_slice = draft_data[:draft_date_month] || draft_data[:month]
2009
2150
  month = extract_value(month_slice) if month_slice
2010
- year = extract_value(draft_data[:year]) if draft_data[:year]
2151
+ year = extract_value(draft_data[:draft_date_year] || draft_data[:year]) if
2152
+ draft_data[:draft_date_year] || draft_data[:year]
2011
2153
  # The apostrophe-year catalogue convention ("P1484.20.1/D7,
2012
2154
  # May 07"): the 2-digit year expands with the <30 pivot so the
2013
2155
  # hash carries an unambiguous year.
2014
2156
  year = "#{year.to_i < 30 ? 20 : 19}#{year}" if year.to_s.match?(/\A\d{2}\z/)
2015
- day = extract_value(draft_data[:day]) if draft_data[:day]
2157
+ day = extract_value(draft_data[:draft_date_day] || draft_data[:day]) if
2158
+ draft_data[:draft_date_day] || draft_data[:day]
2016
2159
 
2017
2160
  # A REV-carrying draft embeds its print date into the designator
2018
2161
  # with the month canonicalized numeric (pubid ruling 2026-10-14):
@@ -2103,7 +2246,11 @@ module Pubid
2103
2246
  def handle_reaffirmed(parsed, attributes)
2104
2247
  if parsed[:reaffirmed].is_a?(Hash)
2105
2248
  reaf_data = parsed[:reaffirmed]
2106
- attributes[:reaffirmed] = extract_value(reaf_data[:year])
2249
+ # The edition-2 grammar renames the nested year capture
2250
+ # (reaffirmed_year) so the reaffirmation year cannot leak onto
2251
+ # the identity year key; v1 spelling stays as the fallback.
2252
+ attributes[:reaffirmed] =
2253
+ extract_value(reaf_data[:reaffirmed_year] || reaf_data[:year])
2107
2254
  elsif parsed[:reaffirmed]
2108
2255
  attributes[:reaffirmed] = extract_value(parsed[:reaffirmed])
2109
2256
  end
@@ -23,13 +23,21 @@ module Pubid
23
23
  attribute :parts, :string, collection: true # Array of part strings
24
24
  attribute :original_separator, :string # Original separator (. or -) from parsing
25
25
 
26
+ # The separator BEFORE each part, when the source mixed them
27
+ # ("7.4-3-2" — dot after the number, dashes before the subparts).
28
+ # Runtime-only (a plain accessor, not a lutaml attribute): the
29
+ # serialized columns keep the single original_separator, and only
30
+ # a fresh parse knows the per-part spelling.
31
+ attr_accessor :original_separators
32
+
26
33
  def initialize(prefix: nil, number: nil, parts: nil,
27
- original_separator: nil)
34
+ original_separator: nil, original_separators: nil)
28
35
  super()
29
36
  self.prefix = prefix
30
37
  self.number = number
31
38
  self.parts = parts || []
32
39
  self.original_separator = original_separator
40
+ self.original_separators = original_separators
33
41
  end
34
42
 
35
43
  # Parse code string into components
@@ -55,15 +63,45 @@ original_separator: nil)
55
63
  components = remainder.split(/[.-]/)
56
64
  number = components.shift
57
65
 
66
+ # The separators AS PRINTED, one per part boundary — a code may
67
+ # mix them ("7.4-3-2": dot after the number, dashes before the
68
+ # subparts), and to_s re-joins with these instead of the uniform
69
+ # original_separator when they were captured.
70
+ separators = remainder.scan(/[.-]/)
71
+ per_part = separators.length == components.length ? separators : nil
72
+
58
73
  new(
59
74
  prefix: prefix,
60
75
  number: number,
61
76
  parts: components.empty? ? nil : components,
62
77
  original_separator: separator,
78
+ original_separators: per_part,
63
79
  )
64
80
  end
65
81
 
82
+ # The printed face: keeps the separator each part was printed
83
+ # with ("7.4-3-2"), per Pubid::SubsetMatch. Spellings that differ
84
+ # only in part separators denote the same document, so faces that
85
+ # must be spelling-stable — the URN and the joint IEEE/ISO faces —
86
+ # render through #canonical_to_s instead.
66
87
  def to_s
88
+ render_code(per_part: true)
89
+ end
90
+
91
+ # The canonical face: one uniform separator across every part
92
+ # boundary (the first distinct one the source printed, the same
93
+ # face this class rendered before per-part separators were kept).
94
+ def canonical_to_s
95
+ render_code(per_part: false)
96
+ end
97
+
98
+ def render(context: nil)
99
+ to_s
100
+ end
101
+
102
+ private
103
+
104
+ def render_code(per_part:)
67
105
  result = ""
68
106
  result += prefix if prefix
69
107
  result += number if number # Guard against nil number (e.g., "ANSI X")
@@ -95,17 +133,18 @@ original_separator: nil)
95
133
  separator = "."
96
134
  end
97
135
 
98
- parts.each do |part|
99
- result += separator + part
136
+ parts.each_with_index do |part, idx|
137
+ # A fresh parse of mixed-separator codes ("7.4-3-2") carries
138
+ # the separator each part was printed with; the canonical
139
+ # face (and an unknown per-part spelling) falls back to the
140
+ # uniform original separator (the serialized shape).
141
+ sep = (per_part && original_separators&.at(idx)) || separator
142
+ result += sep + part
100
143
  end
101
144
  end
102
145
 
103
146
  result
104
147
  end
105
-
106
- def render(context: nil)
107
- to_s
108
- end
109
148
  end
110
149
  end
111
150
  end
@@ -403,7 +403,10 @@ module Pubid
403
403
  return build_dual(result.parts) if result.dispatch == :dual_semicolon
404
404
  end
405
405
  parser_input = Parser.normalize_input(normalized)
406
- parsed = Pubid::Parg::Backend.parse(:ieee, parser_input)
406
+ # The edition-2 artifact's bindings block produces the builder-ready
407
+ # hash at the engine (typed scalars + raw wrappers); the tree
408
+ # backend's normalization it replaces read the edition-1 shapes.
409
+ parsed = Pubid::Parg::Model.bound(:ieee, parser_input)
407
410
  builder = Builder.new(Identifier)
408
411
  # Pass the original input string to builder for context
409
412
  builder.original_input = input
@@ -164,7 +164,10 @@ module Pubid
164
164
  # P11073.10418", #216); a plain ISO/IEC number keeps the
165
165
  # printed separator ("IEC/IEEE FDIS 60079-30-2", "ISO/IEC
166
166
  # 8802-3").
167
- code_str = code.to_s
167
+ # Canonical (uniform-separator) code: a joint document parses
168
+ # from both the dotted IEEE face and the dashed IEC face, and
169
+ # both spellings must render — and URN — identically.
170
+ code_str = code.canonical_to_s
168
171
  code_str = code_str.tr("-", ".") if code_str.start_with?("P")
169
172
  code_str += mark unless code_str.empty?
170
173
  parts << code_str if code_str && !code_str.empty?
@@ -213,8 +216,9 @@ module Pubid
213
216
  parts << draft_status if draft_status
214
217
 
215
218
  # Build code part — the P-state prints as spelled (P = project
216
- # draft; no P = standard). Never added or stripped.
217
- code_str = code.to_s
219
+ # draft; no P = standard). Never added or stripped. Canonical
220
+ # code for the same both-faces reason as the ISO face above.
221
+ code_str = code.canonical_to_s
218
222
 
219
223
  # Mark after the number, before the draft and the year
220
224
  code_str += mark unless code_str.empty?