@pubid/pubid 0.1.5 → 0.1.7

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.
@@ -288,6 +288,27 @@ class JcgmBuilder extends BaseBuilder {
288
288
  export function jcgmGrammarImplementation() {
289
289
  const builder = new JcgmBuilder();
290
290
  return {
291
+ // Inverse of JcgmUrnGenerator (lib/pubid/jcgm/urn_parser.rb): rebuild
292
+ // identifiers DIRECTLY from the URN segments — re-parsing through the
293
+ // grammar flattened segments and lost languages/supplements.
294
+ parseUrn(urn) {
295
+ const parts = urn.replace(/^urn:jcgm:/, "").split(":");
296
+ if (parts[0] === "meeting") {
297
+ const number = toMeetingNumber(parts[1]);
298
+ const date = urnDate(parts[2]);
299
+ return new (KIND_CLASSES.meeting)({ number, date });
300
+ }
301
+ const markerAt = parts.findIndex((seg) => seg === "corrigendum" || seg === "amendment");
302
+ if (markerAt === -1) {
303
+ return buildUrnDocument(parts);
304
+ }
305
+ const kind = (parts[markerAt] === "amendment" ? "amendment" : "corrigendum");
306
+ const base = buildUrnDocument(parts.slice(0, markerAt));
307
+ const tail = parts.slice(markerAt + 1);
308
+ const number = tail[0] === undefined || tail[0] === "" ? undefined : tail[0];
309
+ const date = urnDate(tail[1]);
310
+ return new (KIND_CLASSES[kind])({ base, number, date });
311
+ },
291
312
  parse(input) {
292
313
  const tree = parseGrammar(jcgmGrammar, input);
293
314
  if (typeof tree !== "object" || tree === null || Array.isArray(tree)) {
@@ -297,4 +318,44 @@ export function jcgmGrammarImplementation() {
297
318
  },
298
319
  };
299
320
  }
321
+ // lib/pubid/jcgm/urn_parser.rb build_document: Guide vs GumGuide by the
322
+ // "gum." number prefix; the year and language segments are matched by
323
+ // pattern, not position.
324
+ function buildUrnDocument(parts) {
325
+ const number = parts[0];
326
+ if (number === undefined || number === "") {
327
+ throw new Error("JCGM URN has no document number");
328
+ }
329
+ const gum = number.startsWith("gum.");
330
+ const rest = parts.slice(1);
331
+ const year = rest.find((seg) => /^(19|20)\d{2}$/.test(seg));
332
+ const langSegment = rest.find((seg) => /^[a-z]{2}(,[a-z]{2})*$/.test(seg));
333
+ const langLetters = {
334
+ ru: "R", fr: "F", en: "E", ar: "A", es: "S", de: "D",
335
+ };
336
+ const languages = langSegment
337
+ ? langSegment.split(",").map((code) => new Language({ code, originalCode: langLetters[code] ?? code }))
338
+ : [];
339
+ const cls = gum ? KIND_CLASSES["gum-guide"] : KIND_CLASSES.guide;
340
+ const cleanNumber = gum ? number.slice("gum.".length) : number;
341
+ return new cls({
342
+ number: cleanNumber,
343
+ date: year ? new PubidDate({ year }) : undefined,
344
+ languages,
345
+ });
346
+ }
347
+ // nil for an absent year; a component for a well-formed 19xx/20xx year;
348
+ // anything else rejects (no re-parse validates it any more).
349
+ function urnDate(segment) {
350
+ if (segment === undefined || segment === "")
351
+ return undefined;
352
+ if (!/^(19|20)\d{2}$/.test(segment)) {
353
+ throw new Error(`Invalid year in JCGM URN: ${JSON.stringify(segment)}`);
354
+ }
355
+ return new PubidDate({ year: segment });
356
+ }
357
+ // "011" reads back as "11" (Identifiers::Meeting.ordinal normalization).
358
+ function toMeetingNumber(segment) {
359
+ return String(Number(segment));
360
+ }
300
361
  export { isObj };
@@ -271,12 +271,19 @@ function buildRules() {
271
271
  .as("series")
272
272
  .then(space.or(dot))
273
273
  .as("circ_series")
274
- .then(str("supp")
274
+ .then(
275
+ // Bare supplement marker to the whole series, no base number
276
+ // ("NBS.CIRC.sup" — testsuite#5 C4).
277
+ ((str("supp").or(str("sup"))).then(monthAbbrev().then(digits).as("supplement_month_year").or(digits.as("supp_number").then(slash, digits.as("supp_year")).as("supplement_slash_year"), str("").as("supplement_empty")).maybe())).or(
278
+ // Date-range supplement (no base document).
279
+ str("supp")
275
280
  .then(monthAbbrev().as("supp_month_start"), digits.as("supp_year_start"), dash, monthAbbrev().as("supp_month_end"), digits.as("supp_year_end"))
276
281
  .as("supplement_date_range")
277
282
  .or((str("supp").or(str("sup")))
278
283
  .then(match("[0-9]").repeat(4, 4).as("supp_year_start"), dash, match("[0-9]").repeat(4, 4).as("supp_year_end"))
279
- .as("supplement_date_range"), (digits.as("base_number").then(str("e"), digits.as("edition_number")).or(digits
284
+ .as("supplement_date_range"))).or(
285
+ // Base identifier + supplement.
286
+ (digits.as("base_number").then(str("e"), digits.as("edition_number")).or(digits
280
287
  .as("base_number")
281
288
  .then(lowerLetter.as("revision_letter"), digits.as("revision_number")), digits.as("base_number").then(upperLetter.as("letter_suffix")), digits.as("simple_number")))
282
289
  .as("base_portion")
@@ -803,6 +803,7 @@ class NistBuilder {
803
803
  h["supplement_month_year"] !== undefined ||
804
804
  h["supplement_year"] !== undefined ||
805
805
  h["supplement"] !== undefined ||
806
+ h["supplement_empty"] !== undefined ||
806
807
  h["base_portion"] !== undefined) {
807
808
  return this.buildCircularSupplement(h);
808
809
  }
@@ -1094,9 +1095,12 @@ class NistBuilder {
1094
1095
  return this.buildCircularSupplementWrapper(h);
1095
1096
  }
1096
1097
  const seriesValue = isObj(h["circ_series"]) ? s(h["circ_series"]["series"]) : s(h["series"]);
1098
+ // The dotted series literal ("NBS.CIRC") names its publisher in the
1099
+ // first token; the builder default would render "NIST" instead.
1100
+ const seriesPublisher = seriesValue?.startsWith("NBS") ? "NBS" : undefined;
1097
1101
  if (isObj(h["supplement_date_range"])) {
1098
1102
  const range = h["supplement_date_range"];
1099
- const identifier = this.build({ series: seriesValue }, "short");
1103
+ const identifier = this.build({ series: seriesValue, publisher: seriesPublisher }, "short");
1100
1104
  const ms = s(range["supp_month_start"]);
1101
1105
  const ys = s(range["supp_year_start"]);
1102
1106
  const me = s(range["supp_month_end"]);
@@ -1109,7 +1113,7 @@ class NistBuilder {
1109
1113
  });
1110
1114
  return identifier;
1111
1115
  }
1112
- const base = this.buildCircularSupplementBase(h, seriesValue);
1116
+ const base = this.buildCircularSupplementBase(h, seriesValue, seriesPublisher);
1113
1117
  const self = base;
1114
1118
  const raw = h["supplement_month_year"] !== undefined
1115
1119
  ? s(h["supplement_month_year"])
@@ -1119,11 +1123,11 @@ class NistBuilder {
1119
1123
  self["supplement"] = NistSupplement.fromRaw(raw);
1120
1124
  return base;
1121
1125
  }
1122
- buildCircularSupplementBase(h, seriesValue) {
1126
+ buildCircularSupplementBase(h, seriesValue, seriesPublisher) {
1123
1127
  const basePortion = h["base_portion"];
1124
1128
  if (!isObj(basePortion)) {
1125
1129
  return this.build({
1126
- publisher: s(h["publisher"]),
1130
+ publisher: s(h["publisher"]) ?? seriesPublisher,
1127
1131
  series: seriesValue ?? s(h["series"]),
1128
1132
  first_number: s(h["first_number"]),
1129
1133
  }, s(h["parsed_format"]) ?? "short");
@@ -516,11 +516,18 @@ export class NistIdentifier extends BaseIdentifier {
516
516
  else if (this.volume !== undefined && this.issue_number !== undefined) {
517
517
  result += `${this.volume.render()}n${this.issue_number.number}`;
518
518
  }
519
- if (this.edition !== undefined)
520
- result += this.edition.render();
519
+ // With a number the edition glues per the NIST spec ("800-53r5");
520
+ // series-only editions take a dot separator ("NBS.CIRC.e2" — the
521
+ // attested raw spelling; testsuite#5 C4/C5).
522
+ if (this.edition !== undefined) {
523
+ result += this.number !== undefined ? this.edition.render() : `.${this.edition.render()}`;
524
+ }
521
525
  if (this.version_component !== undefined)
522
526
  result += this.version_component.render();
523
- result += this.supplementShort();
527
+ // A series-only supplement takes the dot itself ("NBS.CIRC.sup").
528
+ if (this.supplement !== undefined) {
529
+ result += this.number !== undefined ? this.supplementShort() : `.${this.supplementShort()}`;
530
+ }
524
531
  if (this.update_component !== undefined)
525
532
  result += this.update_component.render("mr");
526
533
  if (this.stage !== undefined) {
@@ -14,6 +14,22 @@ function flavorOf(input) {
14
14
  */
15
15
  export function oimlGrammarImplementation() {
16
16
  return {
17
+ // Inverse of OimlUrnGenerator (lib/pubid/oiml/urn_parser.rb): strip the
18
+ // "urn:oiml:" namespace, rebuild the human head "OIML <TYPE> <locator>",
19
+ // and re-parse. Mirrors the reference exactly — the year and language
20
+ // URN segments are not carried back (the reference parser reads only
21
+ // the type and the number-part locator).
22
+ parseUrn(urn) {
23
+ const body = urn.replace(/^urn:oiml:/i, "");
24
+ const parts = body.split(":");
25
+ const typeToken = parts[0] ?? "";
26
+ const number = parts[1];
27
+ const displayType = typeToken.toLowerCase() === "bulletin" ? "Bulletin" : typeToken.toUpperCase();
28
+ let text = `OIML ${displayType}`;
29
+ if (number !== undefined && number !== "")
30
+ text += ` ${number}`;
31
+ return this.parse(text);
32
+ },
17
33
  parse(input) {
18
34
  // The co-published form carries both identifiers joined by "|":
19
35
  // "ISO 4064-1:2024|OIML R 49-1:2024". The first side is another
@@ -0,0 +1,85 @@
1
+ type PgArtifactJs = any;
2
+ /** A field requirement from the binding-requirements schema. */
3
+ export interface FieldRequirement {
4
+ type: string;
5
+ card: string;
6
+ preprocess?: string;
7
+ }
8
+ /** The binding-requirements schema record for one entry. */
9
+ export interface EntrySchema {
10
+ root: string;
11
+ fields: Record<string, FieldRequirement>;
12
+ examples: {
13
+ input: string;
14
+ captures: Record<string, unknown>;
15
+ }[];
16
+ }
17
+ export type Schema = Record<string, EntrySchema>;
18
+ /**
19
+ * A loaded, checksum-verified PG artifact with its schema.
20
+ * Construction throws when the checksum does not verify.
21
+ */
22
+ export declare class PgRuntime {
23
+ readonly artifact: PgArtifactJs;
24
+ readonly schema: Schema;
25
+ readonly renderSpec: Record<string, RenderSegment[]>;
26
+ readonly entry: string;
27
+ readonly defaultEntry: string | null;
28
+ readonly envelope: Record<string, unknown>;
29
+ constructor(artifactJson: string, entry?: string);
30
+ /** Load an artifact JSON file and verify its checksum. */
31
+ static fromFile(path: string, entry?: string): PgRuntime;
32
+ /** The default artifacts directory (the pubid-grammar checkout). */
33
+ static artifactsDir(): string;
34
+ static load(grammar: string, entry?: string): PgRuntime;
35
+ /** Parse with the native wasm engine; returns the parsanol-shape tree. */
36
+ parseShape(input: string): unknown;
37
+ /** Apply the entry's bindings to a parsanol-shape tree. */
38
+ applyBindings(shape: unknown): Record<string, unknown>;
39
+ /** Parse and bind in one step. */
40
+ parseAndBind(input: string): Record<string, unknown> | null;
41
+ /**
42
+ * Schema-driven materialization: the schema (from the artifact, not
43
+ * hand-written model code) declares the fields a data model must
44
+ * implement; the bound captures populate a structurally-typed object.
45
+ * Card `1*`/`*` fields become arrays; `0..1` fields become nullable.
46
+ */
47
+ materialize(input: string): Materialized | null;
48
+ /** Render the identifier string from a bound map (F6). */
49
+ render(bound: Record<string, unknown>, variant?: string): string;
50
+ /** Parse, bind, and render in one step. */
51
+ renderString(input: string, variant?: string): string | null;
52
+ /** Evaluate a named derive spec against a bound map (F6). */
53
+ derive(name: string, bound: Record<string, unknown>): string;
54
+ /** Parse, bind, and evaluate a derive spec in one step. */
55
+ deriveString(input: string, name: string): string | null;
56
+ /** Run the artifact's embedded tests; empty list means green. */
57
+ runTests(): string[];
58
+ }
59
+ /** F6 v1 derive evaluator: {field} interpolates from the bound map. */
60
+ export declare function deriveTemplate(derive: Record<string, string>, name: string, bound: Record<string, unknown>): string;
61
+ export type RenderSegment = {
62
+ type: "field";
63
+ field: string;
64
+ } | {
65
+ type: "literal";
66
+ text: string;
67
+ } | {
68
+ type: "cond";
69
+ field: string;
70
+ then: RenderSegment[];
71
+ };
72
+ /** F6 v1 generic renderer: field / literal / cond-presence segments. */
73
+ export declare function renderSegments(segments: RenderSegment[] | null, bound: Record<string, unknown>): string;
74
+ /** A schema-materialized identifier: typed fields plus provenance. */
75
+ export interface Materialized {
76
+ entry: string;
77
+ attributes: Record<string, unknown>;
78
+ }
79
+ /**
80
+ * Schema-driven materialization independent of any wasm handle —
81
+ * the same function the model layer will build on (T2 codegen emits the
82
+ * static view of exactly these fields).
83
+ */
84
+ export declare function materializeFromSchema(schema: EntrySchema, bound: Record<string, unknown>): Materialized;
85
+ export {};
@@ -0,0 +1,170 @@
1
+ //! PG artifact runtime for TypeScript (TODO 12 / T1): checksum-verified
2
+ //! artifact load, native wasm parse, bindings application, and
3
+ //! schema-driven materialization. The engine is the vendored
4
+ //! parsanol wasm package (PgArtifactJs, C9) — no grammar code lives in
5
+ //! this repo; the artifact is the contract.
6
+ import { createRequire } from "node:module";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join } from "node:path";
10
+ /** Walk up from this module to the package root (which holds package.json). */
11
+ function packageRoot() {
12
+ let dir = dirname(fileURLToPath(import.meta.url));
13
+ for (; !existsSync(join(dir, "package.json")); dir = dirname(dir)) {
14
+ if (dir === dirname(dir))
15
+ throw new Error("package.json not found above pg runtime");
16
+ }
17
+ return dir;
18
+ }
19
+ // The vendored wasm glue is CommonJS and lives outside the compiled tree,
20
+ // so resolve it against the package root at runtime.
21
+ const require = createRequire(import.meta.url);
22
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
23
+ const wasm = require(join(packageRoot(), "vendor", "parsanol-wasm", "parsanol.js"));
24
+ /**
25
+ * A loaded, checksum-verified PG artifact with its schema.
26
+ * Construction throws when the checksum does not verify.
27
+ */
28
+ export class PgRuntime {
29
+ artifact;
30
+ schema;
31
+ renderSpec;
32
+ entry;
33
+ defaultEntry;
34
+ envelope;
35
+ constructor(artifactJson, entry) {
36
+ this.envelope = JSON.parse(artifactJson);
37
+ this.artifact = new wasm.PgArtifactJs(artifactJson);
38
+ this.schema = JSON.parse(this.artifact.schema());
39
+ this.renderSpec =
40
+ this.envelope["render"] ?? {};
41
+ // The compiler bakes default_entry: JSON key order is not preserved
42
+ // across engines, so the sorted entry list cannot rederive it.
43
+ this.defaultEntry =
44
+ JSON.parse(artifactJson)["default_entry"] ?? null;
45
+ this.entry = entry ?? this.defaultEntry ?? this.artifact.entryNames()[0];
46
+ }
47
+ /** Load an artifact JSON file and verify its checksum. */
48
+ static fromFile(path, entry) {
49
+ return new PgRuntime(readFileSync(path, "utf8"), entry);
50
+ }
51
+ /** The default artifacts directory (the pubid-grammar checkout). */
52
+ static artifactsDir() {
53
+ const env = process.env.PG_ARTIFACT_DIR;
54
+ if (env)
55
+ return env;
56
+ // pubid-grammar is a sibling checkout in the pubid workspace.
57
+ return join(packageRoot(), "..", "pubid-grammar", "artifacts");
58
+ }
59
+ static load(grammar, entry) {
60
+ return PgRuntime.fromFile(join(PgRuntime.artifactsDir(), `${grammar}.json`), entry);
61
+ }
62
+ /** Parse with the native wasm engine; returns the parsanol-shape tree. */
63
+ parseShape(input) {
64
+ return JSON.parse(this.artifact.parseShape(this.entry, input));
65
+ }
66
+ /** Apply the entry's bindings to a parsanol-shape tree. */
67
+ applyBindings(shape) {
68
+ return JSON.parse(this.artifact.applyBindings(this.entry, JSON.stringify(shape)));
69
+ }
70
+ /** Parse and bind in one step. */
71
+ parseAndBind(input) {
72
+ try {
73
+ return JSON.parse(this.artifact.parseAndBind(this.entry, input));
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Schema-driven materialization: the schema (from the artifact, not
81
+ * hand-written model code) declares the fields a data model must
82
+ * implement; the bound captures populate a structurally-typed object.
83
+ * Card `1*`/`*` fields become arrays; `0..1` fields become nullable.
84
+ */
85
+ materialize(input) {
86
+ const bound = this.parseAndBind(input);
87
+ if (bound === null)
88
+ return null;
89
+ const schema = this.schema[this.entry];
90
+ if (!schema)
91
+ throw new Error(`artifact has no schema for entry ${this.entry}`);
92
+ return materializeFromSchema(schema, bound);
93
+ }
94
+ /** Render the identifier string from a bound map (F6). */
95
+ render(bound, variant = "default") {
96
+ return renderSegments((this.renderSpec[variant] ?? null), bound);
97
+ }
98
+ /** Parse, bind, and render in one step. */
99
+ renderString(input, variant = "default") {
100
+ const bound = this.parseAndBind(input);
101
+ return bound === null ? null : this.render(bound, variant);
102
+ }
103
+ /** Evaluate a named derive spec against a bound map (F6). */
104
+ derive(name, bound) {
105
+ return deriveTemplate((this.envelope["derive"] ?? {}), name, bound);
106
+ }
107
+ /** Parse, bind, and evaluate a derive spec in one step. */
108
+ deriveString(input, name) {
109
+ const bound = this.parseAndBind(input);
110
+ return bound === null ? null : this.derive(name, bound);
111
+ }
112
+ /** Run the artifact's embedded tests; empty list means green. */
113
+ runTests() {
114
+ return JSON.parse(this.artifact.runTests());
115
+ }
116
+ }
117
+ /** F6 v1 derive evaluator: {field} interpolates from the bound map. */
118
+ export function deriveTemplate(derive, name, bound) {
119
+ const template = derive[name];
120
+ if (template === undefined)
121
+ throw new Error(`derive spec ${name} not declared`);
122
+ return template.replace(/\{(\w+)\}/g, (_, field) => {
123
+ const value = bound[field];
124
+ return value === undefined || value === null ? "" : String(value);
125
+ });
126
+ }
127
+ /** F6 v1 generic renderer: field / literal / cond-presence segments. */
128
+ export function renderSegments(segments, bound) {
129
+ let out = "";
130
+ for (const segment of segments ?? []) {
131
+ if (segment.type === "field") {
132
+ const value = bound[segment.field];
133
+ out += value === undefined || value === null ? "" : String(value);
134
+ }
135
+ else if (segment.type === "literal") {
136
+ out += segment.text;
137
+ }
138
+ else if (segment.type === "cond") {
139
+ const value = bound[segment.field];
140
+ if (value !== undefined && value !== null)
141
+ out += renderSegments(segment.then, bound);
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * Schema-driven materialization independent of any wasm handle —
148
+ * the same function the model layer will build on (T2 codegen emits the
149
+ * static view of exactly these fields).
150
+ */
151
+ export function materializeFromSchema(schema, bound) {
152
+ const attributes = {};
153
+ for (const [path, requirements] of Object.entries(schema.fields)) {
154
+ const leaf = path.includes("[]")
155
+ ? (path.split("].").pop() ?? path)
156
+ : path;
157
+ const value = bound[leaf];
158
+ if (requirements.card.endsWith("*")) {
159
+ const list = Array.isArray(value) ? value : value === undefined ? [] : [value];
160
+ attributes[leaf] = list;
161
+ }
162
+ else if (requirements.card === "0..1") {
163
+ attributes[leaf] = value === undefined ? null : value;
164
+ }
165
+ else {
166
+ attributes[leaf] = value;
167
+ }
168
+ }
169
+ return { entry: schema.root, attributes };
170
+ }
@@ -0,0 +1,20 @@
1
+ import { PgRuntime } from "./runtime.js";
2
+ export type TestKind = "accept" | "reject" | "example";
3
+ export interface SuiteTest {
4
+ kind: TestKind;
5
+ input: string;
6
+ expect?: Record<string, string>;
7
+ }
8
+ export interface Suite {
9
+ name: string;
10
+ entry?: string;
11
+ tests: SuiteTest[];
12
+ }
13
+ /** Parse one *.pgtest file (same grammar as Parsanol::PG::Suite#read_file). */
14
+ export declare function readSuite(text: string, name: string): Suite;
15
+ /** Load every *.pgtest file in a directory. */
16
+ export declare function loadSuites(dir: string): Suite[];
17
+ /** Run a suite against a runtime; returns failure descriptions. */
18
+ export declare function runSuite(runtime: PgRuntime, suite: Suite): string[];
19
+ /** The default suites directory (the pubid-grammar checkout). */
20
+ export declare function suitesDir(): string;
@@ -0,0 +1,90 @@
1
+ //! T3: external *.pgtest suites run through the wasm runtime. The suite
2
+ //! syntax matches the Ruby loader (Parsanol::PG::Suite): a
3
+ //! `suite <name> [for <entry>] { ... }` header with accept / reject /
4
+ //! example lines. Green means Ruby and TypeScript agree on the contract.
5
+ import { readFileSync, readdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { PgRuntime } from "./runtime.js";
8
+ const LINE = /^\s*(accept|reject|example)\s+"((?:[^"\\]|\\.)*)"(?:\s*\{([^}]*)\})?\s*$/;
9
+ const HEADER = /suite\s+(\w+)(?:\s+for\s+entry\s+(\w+))?\s*\{/;
10
+ /** Parse one *.pgtest file (same grammar as Parsanol::PG::Suite#read_file). */
11
+ export function readSuite(text, name) {
12
+ const body = text.replace(/^#[^\n]*\n/gm, "");
13
+ const header = HEADER.exec(body);
14
+ if (!header) {
15
+ throw new Error(`${name}: expected 'suite <name> [for <entry>] { ... }'`);
16
+ }
17
+ const tests = [];
18
+ for (const line of body.slice((header.index ?? 0) + header[0].length).split("\n")) {
19
+ if (line.trim() === "}")
20
+ break;
21
+ const match = LINE.exec(line);
22
+ if (!match)
23
+ continue;
24
+ const kind = match[1];
25
+ const rawInput = match[2] ?? "";
26
+ const input = JSON.parse(`"${rawInput}"`);
27
+ const expect = {};
28
+ if (match[3]) {
29
+ for (const pair of match[3].split(",")) {
30
+ const kv = /(\w+)\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(pair);
31
+ const key = kv?.[1];
32
+ const raw = kv?.[2];
33
+ if (key && raw !== undefined)
34
+ expect[key] = JSON.parse(`"${raw}"`);
35
+ }
36
+ }
37
+ tests.push(kind === "example" ? { kind, input, expect } : { kind, input });
38
+ }
39
+ const entry = header[2];
40
+ return entry === undefined
41
+ ? { name: header[1] ?? "suite", tests }
42
+ : { name: header[1] ?? "suite", entry, tests };
43
+ }
44
+ /** Load every *.pgtest file in a directory. */
45
+ export function loadSuites(dir) {
46
+ return readdirSync(dir)
47
+ .filter((file) => file.endsWith(".pgtest"))
48
+ .map((file) => readSuite(readFileSync(join(dir, file), "utf8"), file.replace(/\.pgtest$/, "")));
49
+ }
50
+ /** Run a suite against a runtime; returns failure descriptions. */
51
+ export function runSuite(runtime, suite) {
52
+ const failures = [];
53
+ const entry = suite.entry ?? runtime.entry;
54
+ for (const test of suite.tests) {
55
+ // The wasm runtime throws on parse failure; that is the reject signal.
56
+ let bound = null;
57
+ try {
58
+ bound = runtime.artifact.parseAndBind(entry, test.input);
59
+ }
60
+ catch {
61
+ bound = null;
62
+ }
63
+ if (test.kind === "reject") {
64
+ if (bound !== null) {
65
+ failures.push(`test ${JSON.stringify(test.input)}: expected the input to be rejected`);
66
+ }
67
+ continue;
68
+ }
69
+ if (bound === null) {
70
+ failures.push(`test ${JSON.stringify(test.input)}: expected the input to parse`);
71
+ continue;
72
+ }
73
+ if (test.kind === "example") {
74
+ const parsed = JSON.parse(bound);
75
+ for (const [key, value] of Object.entries(test.expect ?? {})) {
76
+ if (parsed[key] !== value) {
77
+ failures.push(`test ${JSON.stringify(test.input)}: expected captures ${key}: ${JSON.stringify(value)}, got ${JSON.stringify(parsed[key])}`);
78
+ }
79
+ }
80
+ }
81
+ }
82
+ return failures;
83
+ }
84
+ /** The default suites directory (the pubid-grammar checkout). */
85
+ export function suitesDir() {
86
+ const env = process.env.PG_SUITES_DIR;
87
+ if (env)
88
+ return env;
89
+ return join(PgRuntime.artifactsDir(), "..", "suites");
90
+ }
@@ -0,0 +1,5 @@
1
+ import type { Identifier } from "../conformance/implementation.js";
2
+ import { type UrnParserDef } from "./types.js";
3
+ export declare const URN_PARSERS: Record<string, UrnParserDef>;
4
+ export declare function lookupUrnParser(flavor: string): UrnParserDef | undefined;
5
+ export declare function parseUrnFor(def: UrnParserDef, urn: string, parse: (text: string) => Identifier): Identifier;