@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.
- package/README.md +22 -6
- package/artifact/corpus.json.gz +0 -0
- package/dist/conformance/implementation.d.ts +3 -0
- package/dist/flavors/bipm/index.js +39 -0
- package/dist/flavors/iec/index.js +80 -0
- package/dist/flavors/ieee/index.js +39 -3
- package/dist/flavors/ieee/model.js +3 -4
- package/dist/flavors/index.js +27 -0
- package/dist/flavors/itu/grammar.js +71 -9
- package/dist/flavors/itu/index.js +23 -3
- package/dist/flavors/itu/model.d.ts +8 -0
- package/dist/flavors/itu/model.js +45 -7
- package/dist/flavors/jcgm/index.js +61 -0
- package/dist/flavors/nist/grammar.js +9 -2
- package/dist/flavors/nist/index.js +8 -4
- package/dist/flavors/nist/model.js +10 -3
- package/dist/flavors/oiml/implementation.js +16 -0
- package/dist/pg/runtime.d.ts +85 -0
- package/dist/pg/runtime.js +170 -0
- package/dist/pg/suite.d.ts +20 -0
- package/dist/pg/suite.js +90 -0
- package/dist/urn/parsers.d.ts +5 -0
- package/dist/urn/parsers.js +566 -0
- package/dist/urn/types.d.ts +12 -0
- package/dist/urn/types.js +8 -0
- package/package.json +1 -1
|
@@ -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(
|
|
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")
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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;
|
package/dist/pg/suite.js
ADDED
|
@@ -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;
|