@pubid/pubid 0.1.37 → 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
@@ -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)
@@ -43,7 +43,28 @@ function buildRules() {
43
43
  rule("part_edition_rule", () => space.then(digits.then(dot, digits, dot, digits).as("part_edition")));
44
44
  rule("part_year_rule", () => space.then(str("(").then(year4.as("part_year"), str(")"))));
45
45
  rule("part_dated", () => rules["part_edition_rule"].then(rules["part_year_rule"].maybe()));
46
- rule("part", () => space.then(str("Part")).then(space, rules["part_value"], rules["part_dated"].maybe()));
46
+ // --- S-4 loose-leaf sections and specifications (issue #691) ---
47
+ // The spec number's vocabulary: the S-4 sections run 100-600 (Part C
48
+ // 100-500), so the leaf number is 3 digits with hundreds digit 1-6 —
49
+ // the same shape the bare citation branch leans on to keep the B
50
+ // Bibliographic series (B-1..B-13 style) distinct from B-4xx specs.
51
+ const specShape = match("[1-6]").then(match("[0-9]").repeat(2, 2));
52
+ // The citation letter is the spec's part (A/B/C); the builder binds
53
+ // it into `part` and rejects a conflicting Part-rule capture.
54
+ rule("spec_citation_body", () => str("A").or(str("B"), str("C")).as("spec_part")
55
+ .then(dash, specShape.as("spec"))
56
+ .then(dot.then(digits.as("spec_sub")).maybe())
57
+ .then(str("(").then(match("[A-Z]").repeat(1, Infinity).as("spec_variant"), str(")")).maybe()));
58
+ // The compact anchor form: the citation follows the code after one
59
+ // space ("IHO S-4 B-401").
60
+ rule("spec_citation", () => space.then(rules["spec_citation_body"]));
61
+ // The spelled section long form: "Part B, Section 400" — the spelled
62
+ // number becomes the spec (the section-level citation), and a
63
+ // trailing citation (", Section 400, B-401") makes it a cross-check
64
+ // only.
65
+ rule("part_section_tail", () => str(",").then(space, str("Section"), space, specShape.as("spec_section"))
66
+ .then(str(",").then(space, rules["spec_citation_body"]).maybe()));
67
+ rule("part", () => space.then(str("Part")).then(space, rules["part_value"], rules["part_dated"].maybe(), rules["part_section_tail"].maybe()));
47
68
  rule("supplement_edition_rule", () => space.then(digits.then(dot, digits, dot, digits).as("supplement_edition")));
48
69
  rule("supplement_year_rule", () => space.then(str("(").then(year4.as("supplement_year"), str(")"))));
49
70
  rule("supplement_dated", () => rules["supplement_edition_rule"].then(rules["supplement_year_rule"].maybe()));
@@ -79,8 +100,16 @@ function buildRules() {
79
100
  rule("identifier", () => str("IHO")
80
101
  .then(space)
81
102
  .maybe()
82
- .then(rules["series"], dash, rules["number"], rules["compact_part"].maybe(), rules["glued"].maybe(), rules["volume_short"].maybe(), rules["dated"].maybe(), rules["supplement"].maybe(), rules["part"].maybe(), rules["annex"].maybe(), rules["appendix"].maybe(), rules["serial"].maybe(), rules["year_qualifier"].maybe()));
83
- rule("root", () => rules["identifier"]);
103
+ .then(rules["series"], dash, rules["number"], rules["compact_part"].maybe(), rules["glued"].maybe(), rules["volume_short"].maybe(), rules["dated"].maybe(), rules["supplement"].maybe(), rules["part"].maybe(), rules["spec_citation"].maybe(), rules["annex"].maybe(), rules["appendix"].maybe(), rules["serial"].maybe(), rules["year_qualifier"].maybe()));
104
+ // The bare citation branch (the entry's FIRST alternative): "B-401" /
105
+ // "IHO B-401" — implicit S-4 anchor (the builder synthesizes series S
106
+ // number 4). B only: bare C-x stays the Circular Letter series, and
107
+ // the 3-digit [1-6]xx shape keeps the B Bibliographic series (B-12)
108
+ // on the main branch.
109
+ rule("bare_spec_citation", () => str("IHO").then(space).maybe()
110
+ .then(str("B").as("spec_part"), dash, specShape.as("spec"), dot.then(digits.as("spec_sub")).maybe(), str("(").then(match("[A-Z]").repeat(1, Infinity).as("spec_variant"), str(")")).maybe()));
111
+ rule("identifier_entry", () => rules["bare_spec_citation"].or(rules["identifier"]));
112
+ rule("root", () => rules["identifier_entry"]);
84
113
  return rules;
85
114
  }
86
115
  export const ihoGrammar = { rules: buildRules(), root: "root" };
@@ -115,8 +144,15 @@ function ihoClass(kind) {
115
144
  volume_edition: { type: "string" },
116
145
  volume_year: { type: "string" },
117
146
  issue: { type: "string" },
147
+ // The S-4 loose-leaf spec citation (issue #691): spec is the leaf
148
+ // number, the dotted sub and the parenthesized variant its
149
+ // children. The citation letter rides `part`; the spelled section
150
+ // is resolve-only (never an atom).
151
+ spec: { type: "string" },
152
+ spec_sub: { type: "string" },
153
+ spec_variant: { type: "string" },
118
154
  });
119
- static mappings = keyValue({ wire: "number", to: "number" }, { wire: "part", to: "part" }, { wire: "part_edition", to: "part_edition" }, { wire: "part_year", to: "part_year" }, { wire: "annex", to: "annex" }, { wire: "annex_edition", to: "annex_edition" }, { wire: "annex_year", to: "annex_year" }, { wire: "appendix", to: "appendix" }, { wire: "appendix_subnumber", to: "appendix_subnumber" }, { wire: "appendix_edition", to: "appendix_edition" }, { wire: "appendix_year", to: "appendix_year" }, { wire: "supplement", to: "supplement" }, { wire: "supplement_edition", to: "supplement_edition" }, { wire: "supplement_year", to: "supplement_year" }, { wire: "edition", to: "edition" }, { wire: "year", to: "year" }, { wire: "volume", to: "volume" }, { wire: "volume_edition", to: "volume_edition" }, { wire: "volume_year", to: "volume_year" }, { wire: "issue", to: "issue" });
155
+ static mappings = keyValue({ wire: "number", to: "number" }, { wire: "part", to: "part" }, { wire: "part_edition", to: "part_edition" }, { wire: "part_year", to: "part_year" }, { wire: "annex", to: "annex" }, { wire: "annex_edition", to: "annex_edition" }, { wire: "annex_year", to: "annex_year" }, { wire: "appendix", to: "appendix" }, { wire: "appendix_subnumber", to: "appendix_subnumber" }, { wire: "appendix_edition", to: "appendix_edition" }, { wire: "appendix_year", to: "appendix_year" }, { wire: "supplement", to: "supplement" }, { wire: "supplement_edition", to: "supplement_edition" }, { wire: "supplement_year", to: "supplement_year" }, { wire: "edition", to: "edition" }, { wire: "year", to: "year" }, { wire: "volume", to: "volume" }, { wire: "volume_edition", to: "volume_edition" }, { wire: "volume_year", to: "volume_year" }, { wire: "issue", to: "issue" }, { wire: "spec", to: "spec" }, { wire: "spec_sub", to: "spec_sub" }, { wire: "spec_variant", to: "spec_variant" });
120
156
  render() {
121
157
  let result = `IHO ${letter}-${this.number}`;
122
158
  if (this.edition !== undefined)
@@ -128,7 +164,17 @@ function ihoClass(kind) {
128
164
  if (this.supplement_year !== undefined)
129
165
  result += ` (${this.supplement_year})`;
130
166
  }
131
- if (this.part !== undefined) {
167
+ if (this.spec !== undefined) {
168
+ // The S-4 citation face replaces the Part face (same
169
+ // containment slot: the citation letter IS the part):
170
+ // "B-401", "B-122.1", "B-521(I)".
171
+ result += ` ${this.part}-${this.spec}`;
172
+ if (this.spec_sub !== undefined)
173
+ result += `.${this.spec_sub}`;
174
+ if (this.spec_variant !== undefined)
175
+ result += `(${this.spec_variant})`;
176
+ }
177
+ else if (this.part !== undefined) {
132
178
  result += ` Part ${this.part}`;
133
179
  if (this.part_edition !== undefined)
134
180
  result += ` ${this.part_edition}`;
@@ -192,6 +238,17 @@ function ihoClass(kind) {
192
238
  if (id.part_year !== undefined)
193
239
  parts.push(`year-${id.part_year}`);
194
240
  }
241
+ // The S-4 spec citation: a child of its part (the citation letter
242
+ // rides the part- atom), the dotted sub and the parenthesized
243
+ // variant its own children. The spelled SECTION is NOT emitted —
244
+ // derivable from the spec's hundreds digit.
245
+ if (id.spec !== undefined) {
246
+ parts.push(`spec-${id.spec}`);
247
+ if (id.spec_sub !== undefined)
248
+ parts.push(`specsub-${id.spec_sub}`);
249
+ if (id.spec_variant !== undefined)
250
+ parts.push(`specvar-${id.spec_variant}`);
251
+ }
195
252
  if (id.annex !== undefined) {
196
253
  parts.push(`annex-${id.annex}`);
197
254
  if (id.annex_edition !== undefined)
@@ -235,6 +292,18 @@ const KIND_CLASSES = {
235
292
  };
236
293
  const LETTER_TO_CLASS = new Map(Object.keys(KIND_LETTERS).map((kind) => [KIND_LETTERS[kind], KIND_CLASSES[kind]]));
237
294
  class IhoBuilder extends BaseBuilder {
295
+ // The S-4 anchor: the loose-leaf publication every spec citation
296
+ // resolves through (bare "B-401" carries it implicitly).
297
+ static S4_TYPE = "S";
298
+ static S4_NUMBER = "4";
299
+ // A citation letter is one of S-4's lettered Parts; the section
300
+ // vocabulary (100-600, Part C 100-500) is theirs.
301
+ static SPEC_LETTERS = ["A", "B", "C"];
302
+ // Components an S-4 spec citation cannot carry: the citation is a
303
+ // leaf of the Part, not a wrappable publication.
304
+ static SPEC_FOREIGN_COMPONENTS = [
305
+ "supplement", "annex", "appendix", "appendix_subnumber", "volume", "issue",
306
+ ];
238
307
  selectClass(data) {
239
308
  const kind = LETTER_TO_CLASS.get(String(data["type"]));
240
309
  if (kind === undefined) {
@@ -264,6 +333,58 @@ class IhoBuilder extends BaseBuilder {
264
333
  delete flat[yearKey];
265
334
  }
266
335
  }
336
+ // The S-4 spec citation resolution (ruby resolve_spec_citation!):
337
+ // the citation letter folds into `part` (a Part-rule capture must
338
+ // agree), and the long form's spelled section becomes the spec
339
+ // alone — beside a citation it only cross-checks the citation's
340
+ // derived section ("Part B, Section 400, B-401" stores spec 401;
341
+ // "Part B, Section 500, B-401" is rejected).
342
+ const letter = flat["spec_part"];
343
+ const spelled = flat["spec_section"];
344
+ delete flat["spec_part"];
345
+ delete flat["spec_section"];
346
+ if (letter !== undefined) {
347
+ if (flat["part"] === undefined || flat["part"] === letter) {
348
+ flat["part"] = letter;
349
+ }
350
+ else {
351
+ throw new ParseFailed(`IHO: the citation letter ${String(letter)} conflicts with part ${String(flat["part"])}`, 0);
352
+ }
353
+ }
354
+ if (spelled !== undefined) {
355
+ if (flat["spec"] === undefined) {
356
+ flat["spec"] = spelled;
357
+ }
358
+ else {
359
+ const section = `${String(flat["spec"])[0]}00`;
360
+ if (spelled !== section) {
361
+ throw new ParseFailed(`IHO: the spelled section ${String(spelled)} disagrees with the citation ${String(flat["part"])}-${String(flat["spec"])}`, 0);
362
+ }
363
+ }
364
+ }
365
+ if (flat["spec"] !== undefined) {
366
+ if (!IhoBuilder.SPEC_LETTERS.includes(String(flat["part"]))) {
367
+ throw new ParseFailed(`IHO: an S-4 spec citation needs a lettered Part (A/B/C), not ${JSON.stringify(flat["part"])}`, 0);
368
+ }
369
+ if (flat["part_edition"] !== undefined || flat["part_year"] !== undefined) {
370
+ throw new ParseFailed("IHO: a spec citation rides the S-4 edition, not a part edition", 0);
371
+ }
372
+ const foreign = IhoBuilder.SPEC_FOREIGN_COMPONENTS.filter((k) => flat[k] !== undefined);
373
+ if (foreign.length > 0) {
374
+ throw new ParseFailed(`IHO: an S-4 spec citation is a leaf of its Part and cannot carry ${foreign.join(", ")}`, 0);
375
+ }
376
+ // The series letter: a bare citation branch parse has no series
377
+ // capture, and its anchor is implicitly the S-4 publication (the
378
+ // number binds with it). An explicit series must BE the anchor.
379
+ if (flat["type"] === undefined) {
380
+ flat["type"] = IhoBuilder.S4_TYPE;
381
+ flat["number"] = IhoBuilder.S4_NUMBER;
382
+ }
383
+ else if (flat["type"] !== IhoBuilder.S4_TYPE ||
384
+ flat["number"] !== IhoBuilder.S4_NUMBER) {
385
+ throw new ParseFailed(`IHO: an S-4 spec citation requires the S-4 anchor: ${String(flat["type"])}-${String(flat["number"])} cannot carry spec ${String(flat["spec"])}`, 0);
386
+ }
387
+ }
267
388
  return super.build(flat);
268
389
  }
269
390
  cast(key, value) {
@@ -1,2 +1,7 @@
1
1
  import { Grammar } from "../../grammar/engine.js";
2
+ /** The DAM-family ballot stage codes (grammar table
3
+ * typed_stages_ballots: PDAM, DAM, DAmd, FDAM, FDAmd, FPDAM — exactly
4
+ * these entries' abbrs); the TYPE-first ballot branch stays scoped to
5
+ * the ballot family. */
6
+ export declare const BALLOT_STAGE_CODES: string[];
2
7
  export declare const isoGrammar: Grammar;
@@ -1,5 +1,6 @@
1
1
  import { match, ref, str } from "../../grammar/engine.js";
2
2
  import { stageTokens } from "./model.js";
3
+ import { ISO_TYPED_STAGES } from "./stages.generated.js";
3
4
  /**
4
5
  * Port of lib/pubid/iso/parser.rb. The identifier alternation order is
5
6
  * load-bearing (PEG ordered choice): nsb_iso | tc_document |
@@ -9,6 +10,11 @@ import { stageTokens } from "./model.js";
9
10
  * supplement tables exactly like the Ruby TYPED_STAGES constants.
10
11
  */
11
12
  const SUPPLEMENT_TYPE_KEYS = ["amd", "cor", "suppl", "ext", "add"];
13
+ /** The DAM-family ballot stage codes (grammar table
14
+ * typed_stages_ballots: PDAM, DAM, DAmd, FDAM, FDAmd, FPDAM — exactly
15
+ * these entries' abbrs); the TYPE-first ballot branch stays scoped to
16
+ * the ballot family. */
17
+ export const BALLOT_STAGE_CODES = ["pdam", "damd", "fdamd", "fpdam"];
12
18
  const BASE_STAGE_TOKENS = stageTokens().filter((t) => {
13
19
  // Supplement tokens are excluded from the base table by type key, but
14
20
  // stageTokens() is type-agnostic here; the split is done via the
@@ -208,7 +214,68 @@ function buildRules() {
208
214
  .as("base")
209
215
  .then(space.maybe(), str("|"), space.maybe(), rules["idf_identifier"])
210
216
  .as("joint_identifier"));
211
- rule("identifier", () => rules["bare_tc_document"].or(rules["nsb_iso_identifier"], rules["tc_document"], rules["directives_identifiers"], rules["jcgm_identifier"], rules["iso_r_supplement_identifier"], rules["iso_r_identifier"], rules["supplement_supplement_supplement_identifier"], rules["supplement_supplement_identifier"], rules["supplement_identifier"], rules["joint_identifier"], rules["identifier_copublishers"]));
217
+ // Historic TYPE-first ballot faces (decode-to-modern, owner ruling
218
+ // 2026-11-12): the DAM-family token in TYPE position — "ISO/IEC FDAM 1
219
+ // 14496-12:2004", "ISO/PDAM 19973-2" — and the space-attached ballot
220
+ // face "ISO 15877-5:2009 FDAM 1". Both branches sit LAST in the
221
+ // identifier alternation, so they are purely additive: every face an
222
+ // earlier branch parses keeps its exact parse. The ballot token
223
+ // captures as ballot_type_with_stage; a number on the TYPE token is
224
+ // space-bounded (ballot_number) so "PDAM 19973-2" keeps 19973 in the
225
+ // base; an on-base stamp's number/date capture as ballot_stamp_* (the
226
+ // stamp token itself matches uncaptured — the ballot's stage comes
227
+ // from the TYPE token and the decode keeps no spelling). The builder
228
+ // folds the captures into ONE amendment layer over the whole base, the
229
+ // number taken from the TYPE token, the stamp, or the implied 1.
230
+ const ballotStageTokens = ISO_TYPED_STAGES
231
+ .filter((st) => BALLOT_STAGE_CODES.includes(st.code))
232
+ .flatMap((st) => st.abbr);
233
+ const ballotTypeAlt = literalAlternation(ballotStageTokens).as("ballot_type_with_stage");
234
+ rule("ballot_type_with_stage", () => ballotTypeAlt);
235
+ // An on-base stamp riding a ballot face ("/Amd 9:2001", "/Amd.1",
236
+ // "/Am1:2006"): it carries the amendment's number and ballot date
237
+ // while its own token letters are decode-away (matched uncaptured).
238
+ // The stamp's date captures under its own ballot_stamp_* names —
239
+ // reusing the base date capture would collide with second_part's at
240
+ // the sequence merge and the stamp's year would eat the base's.
241
+ rule("ballot_stamp", () => str("/")
242
+ .then(literalAlternation(supplementTypeTokens))
243
+ .then(space
244
+ .maybe()
245
+ .then(digit.repeat(1, 5).as("ballot_stamp_number"))
246
+ .then(str(":")
247
+ .or(dash)
248
+ .then(yearDigits
249
+ .then(dash.then(monthDigits).maybe(), dash.then(dayDigits).maybe())
250
+ .as("ballot_stamp_date"))
251
+ .or(str(":")
252
+ .or(dash)
253
+ .then(str("--").or(str("—")).as("ballot_stamp_undated_marker")))
254
+ .maybe())
255
+ .or(str(".").then(digits.as("ballot_stamp_dot_number")))));
256
+ // "ISO/IEC FDAM 1 14496-12:2004", "ISO/PDAM 19973-2": the ballot
257
+ // token first, then the base kept whole. The TYPE-token number is
258
+ // space-bounded on both sides — mandatory trailing space — so
259
+ // "PDAM 19973-2" cannot read 19973 as the ballot number: with no
260
+ // second space the group backtracks and 19973 parses in second_part.
261
+ rule("ballot_supplement_identifier", () => rules["prefix_with_copublishers"]
262
+ .then(space.maybe(), str("/").maybe(), ballotTypeAlt, space.then(digit.repeat(1, Infinity).as("ballot_number")).then(space).maybe(), space.maybe(), rules["type_with_stage"].maybe(), space.maybe(), rules["second_part"], rules["ballot_stamp"].maybe()));
263
+ // The space-attached ballot face: "ISO 15877-5:2009 FDAM 1" — the
264
+ // base designation first, then the ballot token attached by a space.
265
+ // The base front is inlined (not identifier_copublishers_no_third):
266
+ // that rule ends in third_part_edition, whose own optional space
267
+ // would consume the separator the ballot token needs. No base
268
+ // edition is served here — none of the stored or owner-cited ballot
269
+ // faces carries one.
270
+ rule("ballot_space_supplement_identifier", () => rules["guide_prefix"]
271
+ .as("type_with_stage_fr")
272
+ .then(space)
273
+ .maybe()
274
+ .then(rules["prefix_with_copublishers"].maybe(), space.maybe(), str("/").maybe(), rules["type_with_stage"].maybe(), space.maybe(), rules["second_part"], space, ballotTypeAlt, space.then(digit.repeat(1, Infinity).as("ballot_number")).maybe()));
275
+ rule("identifier", () => rules["bare_tc_document"].or(rules["nsb_iso_identifier"], rules["tc_document"], rules["directives_identifiers"], rules["jcgm_identifier"], rules["iso_r_supplement_identifier"], rules["iso_r_identifier"], rules["supplement_supplement_supplement_identifier"], rules["supplement_supplement_identifier"], rules["supplement_identifier"], rules["joint_identifier"], rules["identifier_copublishers"],
276
+ // The historic ballot faces go last — purely additive branches
277
+ // (an input an earlier member parses keeps that parse).
278
+ rules["ballot_supplement_identifier"], rules["ballot_space_supplement_identifier"]));
212
279
  rule("root", () => rules["identifier"]);
213
280
  return rules;
214
281
  }