@pubid/pubid 0.1.36 → 0.1.38

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.
Binary file
@@ -130,15 +130,30 @@ function buildRules() {
130
130
  .or(str("SI Brochure"))
131
131
  .or(str("Brochure sur le SI"))
132
132
  .then(space, str("sur le SI").then(space).maybe(), rules["edition"],
133
- // The version and the language code are optional (the guide's
134
- // language-independent form, "SI Brochure 9e (2019, E)").
135
- space.then(rules["version"]).maybe(), space, str("("), rules["years"], str(", ").then(lang()).maybe(), str(")"))
133
+ // The version and the trailing parenthetical are optional (the
134
+ // guide's language-independent form "SI Brochure 9e (2019, E)";
135
+ // the year-less editions "BIPM SI Brochure 9e" and "… (E)" must
136
+ // re-parse as canonically rendered, pubid#694 ruling R1).
137
+ space.then(rules["version"]).maybe(), space
138
+ .then(str("(")
139
+ .then(rules["years"], str(", ").then(lang()).maybe(), str(")"))
140
+ .or(str("(").then(lang(), str(")"))))
141
+ .maybe())
136
142
  .as("si_brochure"));
137
143
  // The corpus-cited ordinal-edition spelling ("SI Brochure 9th edition
138
144
  // (2019)") normalizes the English ordinal onto the canonical "9e".
139
145
  rule("si_brochure_ordinal_edition", () => str("SI Brochure")
140
146
  .then(space, digits.then(ordinal()).as("ordinal_edition"), space, str("edition"), space.then(rules["year_paren"]).maybe())
141
147
  .as("si_brochure_ordinal_edition"));
148
+ // The unmerged si-brochure-id proposal's bare-number edition spellings
149
+ // ("SI Brochure 9", "SI Brochure 9 (E)", "SI Brochure 9 (2024) (F)"):
150
+ // the edition carries no trailing "e", and the year and the language
151
+ // may each sit in their own parentheses (pubid#694 ruling R1). A
152
+ // parse-in alias of the canonical "9e" edition — the builder appends
153
+ // the "e" the proposal drops.
154
+ rule("si_brochure_bare_edition", () => str("SI Brochure")
155
+ .then(space, digits.as("bare_edition"), space.then(str("("), rules["years"], str(")")).maybe(), space.then(str("("), lang(), str(")")).maybe())
156
+ .as("si_brochure_bare_edition"));
142
157
  rule("brochure_variant", () => str("Appendix")
143
158
  .then(space, digits)
144
159
  .or(str("Concise"), str("FAQ"))
@@ -167,7 +182,7 @@ function buildRules() {
167
182
  .or(guideBody())
168
183
  .as("guide"));
169
184
  rule("group_leading", () => rules["meeting_en"].or(rules["meeting_fr"], rules["committee_short"], rules["committee_long_en"], rules["committee_group_fr"], rules["committee_bare"]));
170
- rule("identifier", () => rules["metrologia"].or(rules["si_brochure"], rules["si_brochure_variant"], rules["si_brochure_section"], rules["si_brochure_ordinal_edition"], rules["mep"], rules["guide"], rules["committee_long_fr"], rules["committee_prefixed"], rules["group_leading"]));
185
+ rule("identifier", () => rules["metrologia"].or(rules["si_brochure"], rules["si_brochure_variant"], rules["si_brochure_section"], rules["si_brochure_ordinal_edition"], rules["si_brochure_bare_edition"], rules["mep"], rules["guide"], rules["committee_long_fr"], rules["committee_prefixed"], rules["group_leading"]));
171
186
  rule("root", () => rules["identifier"]);
172
187
  return rules;
173
188
  }
@@ -246,22 +261,29 @@ class BipmUrnGenerator extends BaseUrnGenerator {
246
261
  generate() {
247
262
  const id = this.identifier;
248
263
  if (id instanceof BipmCommitteeDocument) {
264
+ // The guide-of-record faces (pubid#694, ruling R2): the v1 version
265
+ // segment leads every family and the type segment spells the English
266
+ // type name lowercased ("decision"), never the REC/RES/DECN code.
267
+ // type_code is nil for the bare MRA form -> empty type segment.
268
+ const typeSegment = id.type_code === undefined
269
+ ? ""
270
+ : (TYPE_NAME_EN[id.type_code] ?? id.type_code).toLowerCase();
249
271
  const parts = [
250
- "urn", "bipm", id.group?.toLowerCase() ?? "",
251
- id.type_code?.toLowerCase() ?? "", committeeNumberSegment(id),
272
+ "urn", "bipm", "v1", id.group?.toLowerCase() ?? "",
273
+ typeSegment, committeeNumberSegment(id),
252
274
  ];
253
275
  if (id.year !== undefined)
254
276
  parts.push(String(id.year));
255
277
  return parts.join(":");
256
278
  }
257
279
  if (id instanceof BipmMeeting) {
258
- const parts = ["urn", "bipm", id.group?.toLowerCase() ?? "", "meeting", id.number ?? ""];
280
+ const parts = ["urn", "bipm", "v1", id.group?.toLowerCase() ?? "", "meeting", id.number ?? ""];
259
281
  if (id.year !== undefined)
260
282
  parts.push(String(id.year));
261
283
  return parts.join(":");
262
284
  }
263
285
  if (id instanceof BipmMetrologiaArticle) {
264
- const parts = ["urn", "bipm", "metrologia", String(id.volume())];
286
+ const parts = ["urn", "bipm", "v1", "metrologia", String(id.volume())];
265
287
  if (id.issue !== undefined)
266
288
  parts.push(id.issue);
267
289
  if (id.article !== undefined)
@@ -269,9 +291,14 @@ class BipmUrnGenerator extends BaseUrnGenerator {
269
291
  return parts.join(":");
270
292
  }
271
293
  if (id instanceof BipmSiBrochure) {
294
+ // The guide's edition-number carries no "e" ("9e" emits "9"); the
295
+ // version drops its "v"; the year is the latest of the years range;
296
+ // the language segment is dropped entirely.
272
297
  return [
273
- "urn", "bipm", "si-brochure", id.language?.toLowerCase() ?? "",
274
- id.edition ?? "", id.version ?? "", id.years ?? "",
298
+ "urn", "bipm", "v1", "bipm", "si-brochure",
299
+ (id.edition ?? "").replace(/e$/i, ""),
300
+ (id.version ?? "").replace(/^v/i, ""),
301
+ (id.years ?? "").split("/").pop() ?? "",
275
302
  ].join(":");
276
303
  }
277
304
  throw new ParseFailed(`Cannot build URN for BIPM identifier: ${id.constructor.polymorphicName}`, 0);
@@ -284,6 +311,14 @@ class BipmCommitteeDocument extends BipmIdentifier {
284
311
  if (this.form === "long") {
285
312
  if (this.language === "F") {
286
313
  const name = TYPE_NAME_FR[this.type_code ?? ""] ?? "";
314
+ // The guide's body-prefixed French pattern ("Décision CIPM/111-10
315
+ // (2022)") is the canonical long face (pubid#694, ruling R3): the
316
+ // group qualifies the resource-specific id with a slash. A
317
+ // number-less document keeps the connective spelling the guide's
318
+ // general French pattern uses ("Statement de la CGPM (1889)").
319
+ if (this.number !== undefined) {
320
+ return `${name} ${this.group}/${this.number}${this.yearSegment()}`;
321
+ }
287
322
  return `${name}${this.numberSegment()} ${frenchConnective(this.group ?? "")} ${this.group}${this.yearSegment()}`;
288
323
  }
289
324
  const name = TYPE_NAME_EN[this.type_code ?? ""] ?? "";
@@ -343,10 +378,18 @@ class BipmSiBrochure extends BipmIdentifier {
343
378
  const part = this.part === undefined ? "" : ` Part ${this.part}`;
344
379
  return `BIPM SI Brochure${part}`;
345
380
  }
381
+ // The parens carry the year, the language or both, and stay off
382
+ // entirely when the reference names neither ("SI Brochure 9e", the
383
+ // bare edition without a year — pubid#694's renderer fix).
346
384
  const phrase = this.language === "F" ? "sur le SI " : "";
347
385
  const lang = this.language === undefined ? "" : `, ${this.language}`;
348
386
  const version = this.version === undefined ? "" : ` ${this.version}`;
349
- return `BIPM SI Brochure ${phrase}${this.edition}${version} (${this.years}${lang})`;
387
+ const tail = this.years !== undefined ? `${this.years}${lang}` : (this.language ?? "");
388
+ let result = `BIPM SI Brochure ${phrase}${this.edition}${version}`;
389
+ if (this.years !== undefined || this.language !== undefined) {
390
+ result += ` (${tail})`;
391
+ }
392
+ return result;
350
393
  }
351
394
  }
352
395
  registerType(BipmSiBrochure);
@@ -446,6 +489,23 @@ class BipmBuilder {
446
489
  language: normalizeLanguage(s(n["language"])),
447
490
  });
448
491
  }
492
+ if (tree["si_brochure_bare_edition"] !== undefined) {
493
+ // The proposal drops the trailing "e"; the builder appends it back
494
+ // so the bare-number spellings are parse-in aliases of the canonical
495
+ // "9e" edition (pubid#694 ruling R1). The year and the language sit
496
+ // in separate parentheses; an absent year stays undefined (a partial
497
+ // reference, like "SI Brochure 9th edition" without a year).
498
+ const n = node("si_brochure_bare_edition");
499
+ const edition = `${s(n["bare_edition"])}e`;
500
+ const year = s(n["year"]);
501
+ const language = normalizeLanguage(s(n["language"]));
502
+ return new BipmSiBrochure({
503
+ number: edition,
504
+ edition,
505
+ ...(year !== undefined ? { years: year } : {}),
506
+ ...(language !== undefined ? { language } : {}),
507
+ });
508
+ }
449
509
  if (tree["mep"] !== undefined) {
450
510
  const n = node("mep");
451
511
  const mepCode = s(n["mep_code"]);
@@ -500,40 +560,84 @@ export function bipmGrammarImplementation() {
500
560
  // Inverse of BipmUrnGenerator (lib/pubid/bipm/urn_parser.rb): rebuild
501
561
  // the identifier DIRECTLY from the URN fields — the printed forms carry
502
562
  // surface detail (meeting ordinals, French wording) the URN omits.
563
+ //
564
+ // Two URN generations parse (pubid#694): the canonical face leads with
565
+ // the guide's `v1` version segment; the pre-adoption face this flavor
566
+ // emitted for years stays parseable as a documented parse-in alias of
567
+ // the same identity. The EMITTER is the new shape; an alias never
568
+ // renders back as itself, it rebuilds the identifier whose toUrn is
569
+ // the v1 face.
503
570
  parseUrn(urn) {
504
571
  const body = urn.replace(/^urn:bipm:/, "");
505
572
  // Keep trailing empty segments (gem splits with -1).
506
573
  const parts = body.split(":");
507
574
  const presence = (v) => v === undefined || v === "" ? undefined : v;
508
- if (parts[0] === "metrologia") {
575
+ const canonical = parts[0] === "v1";
576
+ const p = canonical ? parts.slice(1) : parts;
577
+ if (p[0] === "metrologia") {
509
578
  return new BipmMetrologiaArticle({
510
- number: presence(parts[1]),
511
- issue: parts[2],
512
- article: parts[3],
579
+ number: presence(p[1]),
580
+ issue: p[2],
581
+ article: p[3],
582
+ });
583
+ }
584
+ if (canonical && p[0] === "bipm") {
585
+ // `bipm:si-brochure:<edition-number>:<version-number>:<year>` —
586
+ // the guide strips the "e" off the edition and the "v" off the
587
+ // version and drops the language segment, so they are restored
588
+ // here; the year stores back verbatim (idempotent emission).
589
+ const edition = presence(p[2]);
590
+ const version = presence(p[3]);
591
+ return new BipmSiBrochure({
592
+ number: edition ? `${edition}e` : undefined,
593
+ edition: edition ? `${edition}e` : undefined,
594
+ version: version ? `v${version}` : undefined,
595
+ years: presence(p[4]),
513
596
  });
514
597
  }
515
- if (parts[0] === "si-brochure") {
598
+ if (p[0] === "si-brochure") {
599
+ // Legacy face: `si-brochure:<lang>:<edition>:<version>:<years>`.
516
600
  return new BipmSiBrochure({
517
- number: presence(parts[2]),
518
- language: (parts[1] ?? "").toUpperCase(),
519
- edition: parts[2],
520
- version: parts[3],
521
- years: parts[4],
601
+ number: presence(p[2]),
602
+ language: (p[1] ?? "").toUpperCase(),
603
+ edition: p[2],
604
+ version: p[3],
605
+ years: p[4],
522
606
  });
523
607
  }
524
- const group = (parts[0] ?? "").toUpperCase();
525
- if (parts[1] === "meeting") {
608
+ const group = (p[0] ?? "").toUpperCase();
609
+ if (p[1] === "meeting") {
526
610
  return new BipmMeeting({
527
611
  group,
528
- number: parts[2] ?? "",
529
- year: parts[3] ? Number(parts[3]) : undefined,
612
+ number: p[2] ?? "",
613
+ year: p[3] ? Number(p[3]) : undefined,
530
614
  });
531
615
  }
616
+ // The legacy face carried the lowercase type abbreviation ("res");
617
+ // the v1 face carries the lowercase English type name ("resolution").
618
+ // Both resolve through TYPE_WORD_TO_CODE, so an empty segment (the
619
+ // bare MRA form) stays a nil type_code on either face.
620
+ const segment = presence(p[1]);
621
+ const word = segment === undefined
622
+ ? undefined
623
+ : canonical
624
+ ? segment.charAt(0).toUpperCase() + segment.slice(1)
625
+ : segment.toUpperCase();
626
+ // Inverse of committeeNumberSegment: split the folded
627
+ // "-a{appendix}[.{annex}]" tail back out of the identifier segment.
628
+ // A bare number is digits and dashes only, so the letter-prefixed
629
+ // tail is unambiguous.
630
+ const raw = p[2] ?? "";
631
+ const folded = /^(.+?)-a(\d+)(?:\.(\d+))?$/.exec(raw);
632
+ const number = folded ? folded[1] : raw;
532
633
  return new BipmCommitteeDocument({
533
634
  group,
534
- type_code: presence(parts[1])?.toUpperCase(),
535
- number: presence(parts[2]),
536
- year: parts[3] ? Number(parts[3]) : undefined,
635
+ type_code: word === undefined ? undefined : TYPE_WORD_TO_CODE[word],
636
+ number: presence(number),
637
+ year: p[3] ? Number(p[3]) : undefined,
638
+ ...(folded
639
+ ? { appendix: folded[2], ...(folded[3] !== undefined ? { annex: folded[3] } : {}) }
640
+ : {}),
537
641
  });
538
642
  },
539
643
  parse(input) {
@@ -106,11 +106,45 @@ class EcmaBuilder extends BaseBuilder {
106
106
  return value;
107
107
  }
108
108
  }
109
+ /**
110
+ * The human edition spellings that alias the glued " ed<N>" token
111
+ * (pubid#698; ruby Ecma::Parser.normalize_input), in rewrite order.
112
+ * Each rule rewrites INTO the exact canonical face, so the parse (and
113
+ * therefore the render, the URN and the index key) is the one " ed<N>"
114
+ * already produces, and a canonical face passes through
115
+ * byte-identically.
116
+ *
117
+ * ECMA-262 Edition 5.1 the document cover's own form
118
+ * ECMA-262 ed. 5.1 dotted, spaced
119
+ * ECMA-262 ed 5.1 spaced, undotted
120
+ * ECMA-262 ed.5.1 dotted, unspaced
121
+ * ECMA 262 5.1 bare trailing edition
122
+ *
123
+ * " ed5.1" (no separator) is the grammar's own face and is left alone;
124
+ * a " vol<N>" number is never rewritten — the bare-trailing rule fires
125
+ * only behind a document number's own digit ("ECMA 262 5.1"), so the
126
+ * space-separator form itself ("ECMA 6") keeps its number and the
127
+ * grammar's ed-then-vol order is untouched.
128
+ */
129
+ const EDITION_ALIASES = [
130
+ // "Edition <N>" starts mid-word, so the replacement carries no space
131
+ // of its own — the space before it is kept as-is.
132
+ [/\bedition[.\s]*(\d+(?:\.\d+)?)\b/gi, "ed$1"],
133
+ // "ed. <N>" / "ed <N>" / "ed.<N>": same mid-word shape. The glued
134
+ // "ed5.1" has no separator after "ed" and cannot match [.\s]+.
135
+ [/\bed[.\s]+(\d+(?:\.\d+)?)\b/g, "ed$1"],
136
+ // Bare trailing edition: fires only when a document number is already
137
+ // present (a digit directly before the space — "ECMA 262 5.1"). A
138
+ // glued "vol2" has no space, and a spaced "vol 2" has no digit before
139
+ // the space, so a volume number is never rewritten.
140
+ [/(\d) (\d+(?:\.\d+)?)$/, "$1 ed$2"],
141
+ ];
109
142
  export function ecmaGrammarImplementation() {
110
143
  const builder = new EcmaBuilder();
111
144
  return {
112
145
  parse(input) {
113
- const tree = parseGrammar(ecmaGrammar, input);
146
+ const normalized = EDITION_ALIASES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), input);
147
+ const tree = parseGrammar(ecmaGrammar, normalized);
114
148
  if (typeof tree !== "object" || tree === null || Array.isArray(tree)) {
115
149
  throw new ParseFailed("ECMA: unexpected parse tree", 0);
116
150
  }
@@ -86,8 +86,9 @@ function buildRules() {
86
86
  // --- supplements ---
87
87
  const SUPP_ABBRS = [
88
88
  "PRF Amd", "PWI Amd", "NP Amd", "ANW Amd", "WD Amd",
89
- "PRF Cor", "PWI Cor", "NP Cor", "ANW Cor", "WD Cor", "CDCor", "WDCor",
90
- "CDAM", "DAM", "FDAM", "DCOR", "FDCOR", "AMD", "Amd", "COR", "Cor", "ISH", "Ish",
89
+ "PRF Cor", "PWI Cor", "NP Cor", "ANW Cor", "WD Cor", "CDCor", "CDCOR", "WDCor",
90
+ "CDAM", "DAM", "FDAM", "PDAM", "FPDAM", "DCOR", "FDCOR", "AMD", "Amd", "COR", "Cor",
91
+ "CDISH", "DISH", "ISH", "Ish",
91
92
  ];
92
93
  rule("supplement_type_with_stage", () => SUPP_ABBRS.reduce((acc, a) => acc.or(str(a)), str(SUPP_ABBRS[0]))
93
94
  .or(str("FRAG"))
@@ -155,6 +156,15 @@ function buildRules() {
155
156
  .then(str("/"), rules["supplement_type_with_stage"])
156
157
  .then(spaceQ, rules["second_part"], rules["third_part"]));
157
158
  rule("supplement_identifier_no_third", () => rules["identifier_copublishers_no_third"].as("base")
159
+ // The [language] slot between the base designation and the
160
+ // supplement token: the renderer prints the BASE document's
161
+ // languages right after its date
162
+ // ("ISO/IEC 19772:2009(en)/DAM1:2019" — the rendered twin of the
163
+ // stored "ISO/IEC 19772:2009/DAM 1:2019(E)" input), and without
164
+ // the slot the canonical face could not re-parse. The capture
165
+ // lands on the base (the builder's supplement branch routes it
166
+ // there, like the supplement's own third_part language).
167
+ .then(rules["language"].maybe())
158
168
  .then(str("/"), rules["supplement_type_with_stage"])
159
169
  .then(spaceQ, rules["second_part"]));
160
170
  rule("supplement_identifier", () => rules["supplement_identifier_no_third"].then(rules["third_part"]));
@@ -164,9 +174,36 @@ function buildRules() {
164
174
  rule("standalone_supplement", () => rules["prefix_with_copublishers"]
165
175
  .then(str("/"), rules["supplement_type_with_stage"])
166
176
  .then(space, rules["second_part"], rules["third_part"]));
177
+ // --- the legacy DAM-family prefix face (the JTC1 ballot spelling;
178
+ // decode-to-modern, owner ruling 2026-11-12) ---
179
+ // The stage token precedes the base designation — "IEC/FDAM 60038-1",
180
+ // "ISO/IEC PDAM 12207", "ISO/IEC PDAM 2 10646:2012",
181
+ // "ISO/IEC FPDAM 9075/1", "ISO/IEC FDAM 1 14496-12:2004". A number
182
+ // between the token and the designation is the SUPPLEMENT number; the
183
+ // designation is the base's and is kept whole (unnumbered
184
+ // DAM/PDAM/FDAM/FPDAM means Amendment 1, and the old slash-part
185
+ // spelling "9075/1" carries the base's part). The date rides with the
186
+ // designation — old notation prints the base's year, and the draft
187
+ // supplement carries none of its own. Longest tokens first: FPDAM
188
+ // before PDAM, FDAM before DAM. The slash-part alternative precedes
189
+ // the dash-part one: the designation body also matches a bare number,
190
+ // so a slash-part input must be claimed first or the "/1" would
191
+ // dangle unclaimed.
192
+ rule("legacy_dam_token", () => str("FPDAM").or(str("PDAM"), str("FDAM"), str("DAM")).as("legacy_stage"));
193
+ // The copublisher captures as its own top-level scalar (the wasm
194
+ // binder scatters array bindings over the sibling list when a bare
195
+ // copublisher node rides a flat shape — the parg uses the same
196
+ // legacy_copublisher_item rule for that).
197
+ rule("legacy_copublisher_item", () => str("/").then(spaceQ, ORGS.reduce((acc, o) => acc.or(str(o)), str(ORGS[0])).as("legacy_copublisher")));
198
+ rule("legacy_base_designation", () => match("[0-9]").repeat(1, 6).as("legacy_base_number")
199
+ .then(str("/"), match("[0-9]").repeat(1, Infinity).as("legacy_base_part"))
200
+ .or(rules["number_with_part"].as("legacy_base_number_with_part")));
201
+ rule("legacy_supplement", () => rules["prefix_sole_publisher"]
202
+ .then(spaceQ, rules["legacy_copublisher_item"].maybe(), spaceQ, str("/").maybe(), rules["legacy_dam_token"], space, match("[0-9]").repeat(1, Infinity).as("legacy_supplement_number").then(space).maybe(), rules["legacy_base_designation"], (spaceQ.then(rules["date"])).maybe(), rules["third_part"]));
167
203
  rule("identifier", () => rules["working_programme"]
168
204
  .or(rules["technical_group"])
169
205
  .or(rules["working_document"])
206
+ .or(rules["legacy_supplement"])
170
207
  .or(rules["sheet_supplement_identifier"])
171
208
  .or(rules["sheet_identifier"])
172
209
  .or(rules["supplement_supplement_identifier"])
@@ -62,6 +62,20 @@ class IecBuilder {
62
62
  delete tree["vap_suffix"];
63
63
  tree["base"]["vap_suffix"] = vapSuffix;
64
64
  }
65
+ // The third_part language rides the BASE document on every
66
+ // attached-supplement layer: the ruby grammar binds the layers'
67
+ // language unprefixed, the supplement field set keeps none
68
+ // (SUPP_KEYS), and the wire carries base.languages — the ruby
69
+ // render prints it inside the base portion
70
+ // ("ISO/IEC 19772:2009(en)/DAM1:2019"). Push it down before
71
+ // dispatching; the recursion re-applies it until it reaches the
72
+ // base. A standalone supplement (no base tree) keeps its own
73
+ // languages, like ruby's standalone slice.
74
+ const languages = tree["languages"];
75
+ if (languages !== undefined && isObj(tree["base"])) {
76
+ delete tree["languages"];
77
+ tree["base"]["languages"] = languages;
78
+ }
65
79
  // --- working programme / working document ---
66
80
  if (tree["wp_stage"] !== undefined)
67
81
  return this.buildWorkingProgramme(tree);
@@ -71,6 +85,10 @@ class IecBuilder {
71
85
  if (tree["technical_committee"] !== undefined) {
72
86
  throw new ParseFailed("IEC: technical groups are not corpus-covered", 0);
73
87
  }
88
+ // --- the legacy DAM-family prefix face (decode-to-modern) ---
89
+ if (tree["legacy_stage"] !== undefined) {
90
+ return this.buildLegacySupplement(tree);
91
+ }
74
92
  // --- fragment of an amendment/corrigendum (third_part notation) ---
75
93
  if (tree["fragment_type"] !== undefined && tree["base"] !== undefined) {
76
94
  return this.buildFragment(tree);
@@ -153,6 +171,41 @@ class IecBuilder {
153
171
  kind: kind,
154
172
  attrs,
155
173
  };
174
+ // The ensure_base decode (ruby SupplementIdentifier#ensure_base;
175
+ // the 2026-11-12 owner ruling reversing #693): a standalone draft
176
+ // face whose designation is designation-shaped ("IEC/DCOR 60038-1",
177
+ // "IEC CDISH 60050-191") moves the designation WHOLE onto a
178
+ // concrete InternationalStandard base — number, part, subpart and
179
+ // the date (old notation prints the base's year) — while the
180
+ // supplement keeps a number of its own, defaulting to 1 (the
181
+ // no-number rule: an unnumbered DCOR/DISH/CDISH means
182
+ // Corrigendum/Interpretation-Sheet 1). The concrete base is what
183
+ // makes the decode hash-match (and equal) the modern on-base parse
184
+ // of the same document. A supplement with no part (a true
185
+ // standalone like "IEC/AMD 1:2017") keeps its own number below.
186
+ if ((kind === "amd" || kind === "cor" || kind === "ish") &&
187
+ tree["base"] === undefined &&
188
+ attrs["part"] !== undefined) {
189
+ const baseAttrs = {};
190
+ for (const key of ["publisher", "copublishers", "number", "part", "subpart", "date"]) {
191
+ if (attrs[key] !== undefined)
192
+ baseAttrs[key] = attrs[key];
193
+ }
194
+ const base = new IEC_CLASSES.KIND_CLASSES["is"](baseAttrs);
195
+ const suppAttrs = { base, number: "1" };
196
+ // Supplements copy the delegated publisher/copublishers level
197
+ // (ruby reads them through the base; the stored wire copies the
198
+ // non-default level).
199
+ if (attrs["publisher"] !== undefined && attrs["publisher"] !== "IEC") {
200
+ suppAttrs["publisher"] = attrs["publisher"];
201
+ }
202
+ if (Array.isArray(attrs["copublishers"]) && attrs["copublishers"].length > 0) {
203
+ suppAttrs["copublishers"] = attrs["copublishers"];
204
+ }
205
+ const supplement = this.construct({ kind: kind, attrs: suppAttrs, typedStage }, suppAttrs);
206
+ supplement["base"] = base;
207
+ return supplement;
208
+ }
156
209
  // Consolidated supplements (+AMD…+AMD…)
157
210
  const consolidated = tree["consolidated_supplements"];
158
211
  let identifier = this.construct(parsed, attrs);
@@ -219,6 +272,63 @@ class IecBuilder {
219
272
  ...(edition !== undefined ? { edition } : {}),
220
273
  });
221
274
  }
275
+ /**
276
+ * The legacy DAM-family prefix face (IEC/FDAM 60038-1, ISO/IEC PDAM
277
+ * 2 10646:2012, ISO/IEC FPDAM 9075/1): the JTC1 ballot spelling puts
278
+ * the stage token before the base designation. Decode onto the
279
+ * modern identity (owner ruling 2026-11-12): the designation is the
280
+ * BASE's, kept whole — the old slash-part spelling "9075/1" is base
281
+ * 9075 part 1 — and the supplement's number is the captured N,
282
+ * defaulting to 1 (the no-number rule). The date rides with the
283
+ * designation (old notation prints the base's year); a draft
284
+ * supplement carries none of its own. The base builds through the
285
+ * ordinary single-document path, so it is the same
286
+ * InternationalStandard the modern on-base parse holds and
287
+ * parse(legacy) == parse(modern).
288
+ */
289
+ buildLegacySupplement(tree) {
290
+ const baseTree = {};
291
+ if (tree["publisher"] !== undefined)
292
+ baseTree["publisher"] = tree["publisher"];
293
+ if (tree["legacy_copublisher"] !== undefined) {
294
+ baseTree["copublishers"] = [s(tree["legacy_copublisher"])];
295
+ }
296
+ if (tree["legacy_base_number"] !== undefined) {
297
+ // The slash-part designation ("9075/1") captured number and part
298
+ // separately; they go on straight as the plain strings the
299
+ // retyped attributes are.
300
+ baseTree["number_with_part"] = {
301
+ number: s(tree["legacy_base_number"]),
302
+ ...(s(tree["legacy_base_part"]) !== undefined ? { part: s(tree["legacy_base_part"]) } : {}),
303
+ };
304
+ }
305
+ else if (tree["legacy_base_number_with_part"] !== undefined) {
306
+ const designation = tree["legacy_base_number_with_part"];
307
+ baseTree["number_with_part"] = isObj(designation)
308
+ ? flatten(designation)["number_with_part"]
309
+ : designation;
310
+ }
311
+ if (tree["date"] !== undefined)
312
+ baseTree["date"] = tree["date"];
313
+ if (tree["undated_marker"] !== undefined)
314
+ baseTree["undated_marker"] = tree["undated_marker"];
315
+ if (tree["edition"] !== undefined)
316
+ baseTree["edition"] = tree["edition"];
317
+ if (tree["languages"] !== undefined)
318
+ baseTree["languages"] = tree["languages"];
319
+ // The wrap: one amendment/corrigendum layer over the whole base;
320
+ // third_part's fragment notation rides the outermost layer.
321
+ const suppTree = {
322
+ base: baseTree,
323
+ type_with_stage: s(tree["legacy_stage"]),
324
+ number_with_part: { number: s(tree["legacy_supplement_number"]) ?? "1" },
325
+ };
326
+ if (tree["fragment_type"] !== undefined) {
327
+ suppTree["fragment_type"] = tree["fragment_type"];
328
+ suppTree["fragment_number"] = tree["fragment_number"];
329
+ }
330
+ return this.buildInner(suppTree);
331
+ }
222
332
  buildSupplement(tree) {
223
333
  const base = this.buildInner(tree["base"]);
224
334
  const stageAbbr = s(tree["type_with_stage"]) ?? "";
@@ -44,8 +44,20 @@ export const TYPED_STAGES = [
44
44
  TS("ndts", "ndts", "ts", "NDTS"),
45
45
  TS("dpas", "dpas", "pas", "DPAS"),
46
46
  TS("cdpas", "cdpas", "pas", "CDPAS"),
47
- TS("dam", "dam", "amd", "DAM"),
48
- TS("fdam", "fdam", "amd", "FDAM"),
47
+ // The DAM family's ballot stages carry the CIRCULATION slots as the
48
+ // lead harmonized code (30.20/50.20), not ISO's 30.00/50.00 — CDAM/
49
+ // FDAM keep those (type, leading code) URN keys (gem Amendment
50
+ // registry; the codes are the per-typed-stage keys the wire
51
+ // serializes: pdam, damd, fdamd, fpdam).
52
+ TS("pdam", "cd", "amd", "PDAM", "30.20"),
53
+ TS("damd", "damd", "amd", "DAM", "40.00"),
54
+ TS("fdamd", "fdamd", "amd", "FDAM", "50.00", ["FDAM", "PRF Amd"]),
55
+ TS("fpdam", "fdamd", "amd", "FPDAM", "50.20"),
56
+ // "CDCOR" is the all-caps spelling the attached-supplement renderer
57
+ // emits (abbr.first upcase) when a legacy prefix face decodes onto a
58
+ // CDCor draft; appended as a token so the canonical render re-parses
59
+ // (spelling-only: abbr stays "CDCor").
60
+ TS("cdcor", "cd", "cor", "CDCor", "30.00", ["CDCor", "CDCOR"]),
49
61
  TS("dcor", "dcor", "cor", "DCOR"),
50
62
  TS("fdcor", "fdcor", "cor", "FDCOR"),
51
63
  TS("cdish", "cdish", "ish", "CDISH"),
@@ -311,6 +323,10 @@ function iecSupplementClass(kind) {
311
323
  static attributes = extendAttributes(BaseIdentifier, {
312
324
  base: { type: IecIdentifier },
313
325
  number: { type: "string" },
326
+ // The draft stage rides the wire as the typed stage's code
327
+ // ("stage: fdamd") — the single classes declare the same attr
328
+ // (SINGLE_ATTRS.stage); construct sets it on the parse path.
329
+ stage: { type: "string" },
314
330
  synthetic_base: { type: "boolean", default: false },
315
331
  });
316
332
  static mappings = keyValue({ wire: "number", to: "number" }, { wire: "year", to: "date", toWire: (m) => m["date"]?.year, fromWire: yearFromWire }, { wire: "month", to: "date", toWire: (m) => m["date"]?.month, fromWire: () => undefined }, { wire: "day", to: "date", toWire: (m) => m["date"]?.day, fromWire: () => undefined }, { wire: "edition", to: "edition" }, {
@@ -326,7 +342,11 @@ function iecSupplementClass(kind) {
326
342
  return list !== undefined && list.length > 0 ? list : undefined;
327
343
  },
328
344
  fromWire: (h) => (Array.isArray(h["copublishers"]) ? h["copublishers"].map(String) : undefined),
329
- }, { wire: "base", to: "base" });
345
+ },
346
+ // The draft stage rides the wire as the typed stage's code
347
+ // ("stage: fdamd"); the single classes map the same key (ruby
348
+ // serializes typed_stage.code and restores it in from_hash).
349
+ { wire: "stage", to: "stage" }, { wire: "base", to: "base" });
330
350
  render() {
331
351
  const base = this.base;
332
352
  if (base === undefined || this.synthetic_base === true) {
@@ -340,7 +360,12 @@ function iecSupplementClass(kind) {
340
360
  return result;
341
361
  }
342
362
  let result = base.render();
343
- result += `/${abbr}${this.number ?? ""}`;
363
+ // The attached-supplement renderer prints the typed stage's own
364
+ // abbreviation, upcased (ruby renderer.rb: abbr.first.upcase) —
365
+ // a published Amendment renders "/AMD1", a draft stage its own
366
+ // token ("/FDAM1", "/DCOR1", "/CDCOR1" from the "CDCor" entry's
367
+ // spelling-only upcase).
368
+ result += `/${(this.typedStage?.abbr ?? abbr).toUpperCase()}${this.number ?? ""}`;
344
369
  if (this.date !== undefined)
345
370
  result += `:${this.date.render()}`;
346
371
  if (this.edition !== undefined)