pubid 2.0.13 → 2.0.15

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.
@@ -237,16 +237,114 @@ module Pubid
237
237
  # deserializes, so nested bases rebuild their component too. `draft` needs
238
238
  # no expansion (Draft.parse accepts the slashless form).
239
239
  def self.from_hash(data, options = {})
240
- data = Compaction.expand(Compaction.deep_dup(data)) if data.is_a?(::Hash)
240
+ if data.is_a?(::Hash)
241
+ # The canonical comparison reads the wire AS STORED — the expand
242
+ # below rewrites `stage` into the typed_stage sub-hash that
243
+ # to_hash re-collapses, so the expanded form never equals a
244
+ # to_hash and would brand every canonical row legacy.
245
+ original_wire = Compaction.deep_dup(data)
246
+ # expand mutates its argument, so it works on another dup —
247
+ # original_wire must stay as the wire was stored.
248
+ data = Compaction.expand(Compaction.deep_dup(data))
249
+ end
241
250
  identifier = super
242
251
  # == compares the raw ivars: a rebuild whose lazy readers have not
243
252
  # run carries @code_obj/@draft_obj nil while a parse has them set,
244
253
  # so the objects compare unequal although their serialized hashes
245
254
  # agree (pubid#596). Materialize the runtime components eagerly.
246
255
  identifier.materialize_runtime_components
256
+ if original_wire
257
+ identifier = normalize_legacy_wire_shape(identifier, original_wire)
258
+ end
247
259
  identifier
248
260
  end
249
261
 
262
+ # The legacy crawls stored shapes the 1.3.x-era renderer printed that
263
+ # the current grammar reads differently (the stage-token drafts the D=
264
+ # designator face superseded — "DCD1" —, the project drafts stored as
265
+ # plain standards, the draft-less month dates the joint IEEE face now
266
+ # prints). A row whose deserialized canonical hash differs from the
267
+ # wire hash it came from is such a legacy shape: re-derive the
268
+ # identifier from its own render — the shape a parse of the printed
269
+ # form produces — so the deserialized row answers the corpus laws
270
+ # (render(parse(x)) == x, parse(render(x)) == x) like every
271
+ # parse-built one. The re-derived shape is accepted only when it is
272
+ # itself stable (parse∘render idempotent on string and hash), so the
273
+ # normalization never lands on a drifting spelling. A canonical wire
274
+ # hash — the current serializer's output, `to_hash == wire` — never
275
+ # heals, so `from_hash(to_hash(id)) == id` keeps holding for every
276
+ # parse-built identifier (the relaton-index round-trip invariant).
277
+ MAX_NORMALIZE_CYCLES = 3
278
+
279
+ def self.normalize_legacy_wire_shape(identifier, wire_hash)
280
+ # The base class dispatches this hash to the concrete class's
281
+ # from_hash AFTER expand rewrote `stage` into the typed_stage
282
+ # sub-hash, so the comparison collapses the wire back to the
283
+ # serialized shape to_hash emits (collapse is lossless and a no-op
284
+ # on an already-collapsed wire).
285
+ wire = canonical_wire_hash(wire_hash)
286
+ return identifier if identifier.to_hash == wire
287
+
288
+ original = identifier
289
+ MAX_NORMALIZE_CYCLES.times do
290
+ round = reparse(identifier)
291
+ return original unless round
292
+
293
+ same = round.to_s == identifier.to_s &&
294
+ round.to_hash == identifier.to_hash
295
+ return identifier if same
296
+
297
+ # Land only on a shape that is itself parse∘render-stable, so the
298
+ # healed identifier satisfies the corpus laws; an unstable round
299
+ # becomes the vantage of the next cycle, and an exhausted cycle
300
+ # budget keeps the legacy shape (never a drifting spelling).
301
+ return round if stable_shape?(round)
302
+
303
+ identifier = round
304
+ end
305
+ original
306
+ end
307
+
308
+ # The wire as the canonicalizer emits it: key-normalized (string and
309
+ # symbol callers alike) and collapsed back from the expand rewrite.
310
+ def self.canonical_wire_hash(wire_hash)
311
+ normalized = normalize_wire_keys(Compaction.deep_dup(wire_hash))
312
+ Compaction.collapse(normalized)
313
+ end
314
+
315
+ # One parse of the identifier's own render, or nil when the render is
316
+ # not grammar-readable (the legacy shape is kept, as recorded).
317
+ def self.reparse(identifier)
318
+ Pubid::Ieee::Identifier.parse(identifier.to_s)
319
+ rescue StandardError
320
+ nil
321
+ end
322
+
323
+ # A shape is landable when parse∘render is idempotent on it, string
324
+ # and hash — the Law 1a + Law 1b pair the corpus gate asserts.
325
+ def self.stable_shape?(round)
326
+ again = reparse(round)
327
+ !again.nil? && again.to_s == round.to_s &&
328
+ again.to_hash == round.to_hash
329
+ end
330
+
331
+ # Wire hashes arrive string-keyed (the published indexes) or
332
+ # symbol-keyed (direct spec calls); to_hash emits string keys, so the
333
+ # canonical comparison normalizes the wire keys the same way,
334
+ # recursing into nested bases.
335
+ def self.normalize_wire_keys(node)
336
+ case node
337
+ when ::Hash
338
+ node.each_with_object({}) do |(key, value), out|
339
+ out[key.to_s] = normalize_wire_keys(value)
340
+ end
341
+ when ::Array
342
+ node.map { |item| normalize_wire_keys(item) }
343
+ else
344
+ node
345
+ end
346
+ end
347
+
250
348
  # Rebuild the runtime components from the wire strings. Safe on every
251
349
  # construction path: the readers' ||= no-ops when already set, and the
252
350
  # CodeNumber rebuild covers the split-column rows that carry no code
@@ -253,11 +253,24 @@ module Pubid
253
253
 
254
254
  parts << code_str if code_str && !code_str.empty?
255
255
 
256
- # Join with space and add year with dash — unless the D=
257
- # designator face already carried it above.
256
+ # Join with space and add the date — unless the D= designator face
257
+ # already carried the year. The month face follows the standing
258
+ # month rule: a text month is the comma long form (", February
259
+ # 2018", which re-parses through the joint date clause), a numeric
260
+ # month the glued short form ("-2018-05"); dropping the month
261
+ # published a different date ("P90003, February 2018 (E)" printed
262
+ # as "P90003-2018").
258
263
  result = parts.join(" ")
259
- result += "-#{year}" if year && !@designator_carries_year &&
260
- !ieee_draft.to_s.end_with?("-#{year}")
264
+ if year && !@designator_carries_year &&
265
+ !ieee_draft.to_s.end_with?("-#{year}")
266
+ result += if month.to_s.match?(/\A\d+\z/)
267
+ "-#{year}-#{month.to_s.rjust(2, '0')}"
268
+ elsif month
269
+ ", #{month} #{year}"
270
+ else
271
+ "-#{year}"
272
+ end
273
+ end
261
274
  # Lossless on the IEEE face too: the parenthetical rides after
262
275
  # the year, before the redline marker.
263
276
  result += " (#{parenthetical_content})" if parenthetical_content
@@ -146,7 +146,16 @@ module Pubid
146
146
  should_render_type = id.publisher&.match?(/^(IEEE|AIEE)/) ||
147
147
  id.is_a?(Identifiers::ProjectDraftIdentifier)
148
148
 
149
- if should_render_type && !id.typed_stage&.project_status &&
149
+ # An ISO/IEC stage-token draft (CD1, FDIS — the draft_obj carries the
150
+ # stage) is its own draft marker: the designator face it renders
151
+ # ("…/D=CD1", pubid#203) never takes the word Draft, so the type word
152
+ # is skipped whatever the publisher arrangement — the joint rows
153
+ # ("IEEE/IEC Draft P60076-57-129/D=FDIS-201707") would otherwise print
154
+ # Draft on one construction path and drop it on the re-parsed
155
+ # designation path, and the render would not re-parse to itself.
156
+ stage_draft = id.draft_obj&.iso_stage && true
157
+
158
+ if should_render_type && !stage_draft && !id.typed_stage&.project_status &&
150
159
  ((id.type && !id.type.to_s.strip.empty? && id.type != "P") ||
151
160
  id.is_a?(Identifiers::ProjectDraftIdentifier)) &&
152
161
  !id.draft_status.to_s.match?(/unapproved/i)
@@ -112,3 +112,26 @@ Read this before you change `lib/pubid/nist/` or `spec/pubid/nist/`. The root
112
112
  `from_hash` now derives it by the Builder's own condition and rules
113
113
  (`derive_subseries_code`, sharing SP_SUBSERIES). Locked by
114
114
  `spec/pubid/nist/from_hash_shapes_spec.rb`.
115
+
116
+ - **The bare compound series is the SERIES REFERENCE (owner ruling on the
117
+ relaton fuzz gate's `NBS CS` mutant).** The compound-series branch of the
118
+ grammar demanded a separator after the series token, so the bare
119
+ abbreviation of a named series failed to parse; the fuzz gate drops the
120
+ edition from a published `NBS CS e15`-style row and kept recording the
121
+ mutant. The ruling: the bare form is a legitimate identity — the v1
122
+ pubid-nist `series.yaml` names CS as Commercial Standards, CSM as
123
+ Commercial Standards Monthly, CIS as Consumer Information Series, CS-E
124
+ as the emergency form — the same partial/bare-reference pattern as
125
+ BIPM's `SI Brochure`. The grammar (pubid-grammar, fleet compiler
126
+ 1.3.121) makes the compound branch's separator+tail one optional group,
127
+ so every previously accepted form parses byte-identically and exactly
128
+ the bare compound-series forms join the accept set; simple-series bare
129
+ forms (`CS` alone, no publisher) are deliberately NOT covered. The bare
130
+ forms route through the normal series classes (`NBS CS` →
131
+ CommercialStandard …, `NBS CIS`/`NIST PS`/`NIST DCI` → the base
132
+ `Identifier`), render the short face as the abbreviation and the MR
133
+ face per the v1 series table (`NBS.CS`), and match their series members
134
+ as wildcard queries (`#matches?`). Locked by
135
+ `spec/pubid/nist/bare_series_reference_spec.rb`; the corpus round froze
136
+ the six rows (`NBS CS`, `NBS CSM`, `NBS CIS`, `NBS CS-E`, `NIST PS`,
137
+ `NIST DCI`) into the testsuite with the gem twin in lockstep.
data/lib/pubid/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pubid
4
- VERSION = "2.0.13"
4
+ VERSION = "2.0.15"
5
5
  end
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.13
4
+ version: 2.0.15
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-07 00:00:00.000000000 Z
11
+ date: 2026-11-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model