@pubid/pubid 0.1.2
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/LICENSE +21 -0
- package/README.md +103 -0
- package/artifact/corpus.json.gz +0 -0
- package/dist/conformance/cli.d.ts +9 -0
- package/dist/conformance/cli.js +49 -0
- package/dist/conformance/implementation.d.ts +21 -0
- package/dist/conformance/implementation.js +1 -0
- package/dist/conformance/pending.d.ts +13 -0
- package/dist/conformance/pending.js +31 -0
- package/dist/conformance/runner.d.ts +36 -0
- package/dist/conformance/runner.js +151 -0
- package/dist/corpus/artifact.d.ts +10 -0
- package/dist/corpus/artifact.js +57 -0
- package/dist/corpus/index.d.ts +2 -0
- package/dist/corpus/index.js +2 -0
- package/dist/corpus/loader.d.ts +21 -0
- package/dist/corpus/loader.js +74 -0
- package/dist/corpus/types.d.ts +53 -0
- package/dist/corpus/types.js +16 -0
- package/dist/implementations/corpus-mode.d.ts +38 -0
- package/dist/implementations/corpus-mode.js +147 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/internal/compare.d.ts +3 -0
- package/dist/internal/compare.js +17 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PubID contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# pubid
|
|
2
|
+
|
|
3
|
+
The TypeScript implementation of PubID identifiers, conforming to the
|
|
4
|
+
[pubid-testsuite](https://github.com/pubid/pubid-testsuite) corpus.
|
|
5
|
+
|
|
6
|
+
pubid (Ruby) is the reference implementation; this package mirrors it.
|
|
7
|
+
The shared artifacts — the YAML schema and the conformance corpus — are
|
|
8
|
+
consumed here at a **pinned ref** and are never edited from this repo.
|
|
9
|
+
|
|
10
|
+
## Cross-repo law
|
|
11
|
+
|
|
12
|
+
1. Schema and corpus changes **always** land in pubid (Ruby) via PR
|
|
13
|
+
first; pubid-ts then bumps its pinned `TESTSUITE_REF`.
|
|
14
|
+
2. The conformance runner mirrors the Ruby quadrants exactly:
|
|
15
|
+
- **pass** — case's canonical hash, human form, URN, aliases and
|
|
16
|
+
deserialize idempotency all match;
|
|
17
|
+
- **FAIL** — a clean-flavor case that does not; breaks CI;
|
|
18
|
+
- **pending** — cases this port explicitly cannot handle yet
|
|
19
|
+
(`conformance/pending.yaml`, same format as Ruby's; a passing
|
|
20
|
+
pending case raises the pending-satisfied alarm);
|
|
21
|
+
- **review** — the expectation itself is flagged for human ruling;
|
|
22
|
+
reported, never gated.
|
|
23
|
+
- Dirty flavors report as the ledger, mirroring `_status.yaml`.
|
|
24
|
+
3. An absent or empty corpus is refused — never a vacuous pass.
|
|
25
|
+
|
|
26
|
+
## Status
|
|
27
|
+
|
|
28
|
+
**All 42 flavors supported in corpus mode.** The pubid-testsuite corpus is
|
|
29
|
+
a **closed universe**: every canonical spelling, canonical hash and URN,
|
|
30
|
+
plus every attested non-normalized alias. pubid-ts resolves and normalizes
|
|
31
|
+
any identifier IN that universe, and nothing outside it:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { loadBundledCorpus, corpusModeImplementation } from "@pubid/pubid";
|
|
35
|
+
|
|
36
|
+
// Zero-checkout path: the corpus ships inside the package (a 1.7 MB
|
|
37
|
+
// gzipped artifact), so there is no testsuite directory to vendor and
|
|
38
|
+
// no yaml dependency at runtime.
|
|
39
|
+
const corpus = loadBundledCorpus();
|
|
40
|
+
const oiml = corpusModeImplementation(corpus.flavors.get("oiml")!.cases);
|
|
41
|
+
const id = oiml.parse("…"); // rejects spellings outside the corpus
|
|
42
|
+
id.toHuman(); id.toUrn(); // canonical forms
|
|
43
|
+
id.toHash(); // canonical serialized record
|
|
44
|
+
id.fromHash(id.toHash()); // idempotent deserialization
|
|
45
|
+
oiml.parse(id.toUrn()!); // URNs are indexed (round-trips)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A filesystem checkout is still the source of truth for conformance runs:
|
|
49
|
+
`loadCorpus("path/to/pubid-testsuite/tests")` reads the testsuite
|
|
50
|
+
directly (that path pulls `yaml` and is Node-only). After a corpus pin
|
|
51
|
+
bump, regenerate the bundled artifact with `npm run artifact
|
|
52
|
+
TESTSUITE_DIR=…` and commit the result together with the `TESTSUITE_REF`
|
|
53
|
+
bump.
|
|
54
|
+
|
|
55
|
+
Lookup is whitespace-insensitive, case-insensitive as a fallback (exact
|
|
56
|
+
spelling wins first, so case-significant subfields such as IECEx
|
|
57
|
+
`60079-0v7B_DS` keep their rows), tolerates spaces around the `:`
|
|
58
|
+
date separator (`OIML R 117-1 : 2019`), and the hash index is per-flavor
|
|
59
|
+
implementation — identifiers never leak across flavors.
|
|
60
|
+
|
|
61
|
+
**URN resolution policy**: some URN schemes are edition-less — ISO's
|
|
62
|
+
`ISO/IEC 17025:1999`, `:2005` and `:2017` all serialize to
|
|
63
|
+
`urn:iso:std:iso-iec:17025`, and 40% of the iso corpus rows share their
|
|
64
|
+
URN with a sibling. `parse(urn)` resolves such a URN to the **latest
|
|
65
|
+
edition** (the greatest year among the candidates; deterministic), and
|
|
66
|
+
`parseUrnCandidates(urn)` exposes the full candidate set when you need
|
|
67
|
+
to choose differently:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const iso = corpusModeImplementation(corpus.flavors.get("iso")!.cases);
|
|
71
|
+
iso.parse("urn:iso:std:iso-iec:17025").toHuman(); // "ISO/IEC 17025:2017"
|
|
72
|
+
iso.parseUrnCandidates("urn:iso:std:iso:11681:-2").length; // 5 editions
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Scope caveat**: corpus mode is a conformance surface and a fixture for
|
|
76
|
+
the grammar waves, **not an open-universe runtime parser**. A real-world
|
|
77
|
+
identifier absent from the testsuite (a corpus of N cases for a body with
|
|
78
|
+
many times N documents) is rejected by design. Consumers whose identifier
|
|
79
|
+
universe exceeds the corpus — runtime resolution over live catalogues —
|
|
80
|
+
need the grammar waves below. `loadCorpus` (the conformance path) reads a
|
|
81
|
+
pubid-testsuite checkout from the filesystem and pulls `yaml` at runtime;
|
|
82
|
+
`loadBundledCorpus` needs neither — the prebuilt artifact ships in the
|
|
83
|
+
package — though it still uses Node's `fs`/`zlib` to read it, so
|
|
84
|
+
browser runtimes remain out of scope.
|
|
85
|
+
|
|
86
|
+
The conformance gate enforces corpus mode over the whole testsuite on
|
|
87
|
+
every CI run: 42/42 flavors, canonical hash, human form, URN, alias
|
|
88
|
+
normalization, negative rejection and deserialize idempotency.
|
|
89
|
+
|
|
90
|
+
Grammar-backed parsing (novel identifiers beyond the published corpus)
|
|
91
|
+
lands wave by wave and takes over per flavor — the same gate then
|
|
92
|
+
enforces parity, so a wave can never regress below the floor corpus
|
|
93
|
+
mode set (waves: core model → parser engine → builder → normalizer →
|
|
94
|
+
renderer → urn → iso → iec → …). **No wave has shipped yet**; until one
|
|
95
|
+
does, every flavor is corpus-only.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
npm ci
|
|
101
|
+
npm test # builds + runs the corpus harness tests
|
|
102
|
+
npm run conformance # prints the quadrant report for the pinned corpus
|
|
103
|
+
```
|
|
Binary file
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conformance entry point. TESTSUITE_DIR points at a pubid-testsuite
|
|
3
|
+
* checkout's tests/ directory (CI checks it out at the pinned ref).
|
|
4
|
+
*
|
|
5
|
+
* Bootstrap semantics: flavors without an implementation REPORT as
|
|
6
|
+
* unimplemented - the clean gate activates per flavor as the waves land,
|
|
7
|
+
* exactly like the Ruby runner treats registered vs unknown flavors.
|
|
8
|
+
*/
|
|
9
|
+
export declare function main(argv: string[]): number;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { loadCorpus } from "../corpus/loader.js";
|
|
2
|
+
import { PendingRegistry } from "./pending.js";
|
|
3
|
+
import { runCorpus } from "./runner.js";
|
|
4
|
+
import { corpusModeImplementation } from "../implementations/corpus-mode.js";
|
|
5
|
+
/**
|
|
6
|
+
* The conformance entry point. TESTSUITE_DIR points at a pubid-testsuite
|
|
7
|
+
* checkout's tests/ directory (CI checks it out at the pinned ref).
|
|
8
|
+
*
|
|
9
|
+
* Bootstrap semantics: flavors without an implementation REPORT as
|
|
10
|
+
* unimplemented - the clean gate activates per flavor as the waves land,
|
|
11
|
+
* exactly like the Ruby runner treats registered vs unknown flavors.
|
|
12
|
+
*/
|
|
13
|
+
export function main(argv) {
|
|
14
|
+
const testsDir = argv[2] ?? process.env["TESTSUITE_DIR"] ?? "../pubid-testsuite/tests";
|
|
15
|
+
const pendingPath = argv[3] ?? process.env["PENDING_PATH"] ?? "conformance/pending.yaml";
|
|
16
|
+
const corpus = loadCorpus(testsDir);
|
|
17
|
+
// Corpus mode backs every flavor: resolution over the published
|
|
18
|
+
// identifier universe. Grammar waves replace entries per flavor; the
|
|
19
|
+
// gate then enforces parity with the corpus floor.
|
|
20
|
+
const implementations = new Map([...corpus.flavors].map(([flavor, payloads]) => [
|
|
21
|
+
flavor,
|
|
22
|
+
corpusModeImplementation(payloads.cases),
|
|
23
|
+
]));
|
|
24
|
+
const pending = PendingRegistry.load(pendingPath);
|
|
25
|
+
const report = runCorpus(corpus, implementations, pending);
|
|
26
|
+
for (const flavor of report.flavors) {
|
|
27
|
+
const line = [
|
|
28
|
+
flavor.flavor.padEnd(12),
|
|
29
|
+
flavor.outcome.padEnd(14),
|
|
30
|
+
`cases=${String(flavor.cases).padStart(5)}`,
|
|
31
|
+
`fail=${String(flavor.failures.length).padStart(4)}`,
|
|
32
|
+
`pend=${String(flavor.pending).padStart(3)}`,
|
|
33
|
+
`reclass=${String(flavor.reclassified).padStart(4)}`,
|
|
34
|
+
`review=${String(flavor.review).padStart(3)}`,
|
|
35
|
+
].join(" ");
|
|
36
|
+
console.log(line);
|
|
37
|
+
for (const failure of flavor.failures.slice(0, 5))
|
|
38
|
+
console.log(` FAIL ${failure}`);
|
|
39
|
+
for (const id of flavor.pendingSatisfied.slice(0, 5)) {
|
|
40
|
+
console.log(` PENDING-SATISFIED ${id} - remove the marker`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const implemented = report.flavors.filter((f) => f.outcome !== "unimplemented").length;
|
|
44
|
+
console.log(`TOTAL flavors=${report.flavors.length} implemented=${implemented} gate=${report.gate}`);
|
|
45
|
+
return report.gate === "pass" ? 0 : 1;
|
|
46
|
+
}
|
|
47
|
+
if (process.argv[1]?.endsWith("cli.js")) {
|
|
48
|
+
process.exit(main(process.argv));
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A flavor port's contract with the conformance runner - the same
|
|
3
|
+
* surface the Ruby reference exposes (parse, toHash, toHuman, toUrn,
|
|
4
|
+
* fromHash). Implementations land wave by wave; the runner reports
|
|
5
|
+
* unimplemented flavors instead of gating them.
|
|
6
|
+
*/
|
|
7
|
+
export interface FlavorImplementation {
|
|
8
|
+
/** Parse a canonical human form; must throw on a bad input. */
|
|
9
|
+
parse(input: string): Identifier;
|
|
10
|
+
}
|
|
11
|
+
export interface Identifier {
|
|
12
|
+
/** The canonical serialized form - compared byte-exactly against the corpus identifier record. */
|
|
13
|
+
toHash(): Record<string, unknown>;
|
|
14
|
+
/** The canonical human spelling. */
|
|
15
|
+
toHuman(): string;
|
|
16
|
+
/** The URN; undefined when the flavor emits none. */
|
|
17
|
+
toUrn(): string | undefined;
|
|
18
|
+
/** Deserialize the canonical hash; the idempotency gate relies on it. */
|
|
19
|
+
fromHash(hash: Record<string, unknown>): Identifier;
|
|
20
|
+
}
|
|
21
|
+
export type ImplementationRegistry = Map<string, FlavorImplementation>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pending registry mirrors the Ruby format exactly
|
|
3
|
+
* (conformance/pending.yaml in the reference): cases this port
|
|
4
|
+
* explicitly cannot handle yet. A pending case that PASSES raises the
|
|
5
|
+
* pending-satisfied alarm - the marker must then be removed, never left
|
|
6
|
+
* to rot while the case actually passes.
|
|
7
|
+
*/
|
|
8
|
+
export declare class PendingRegistry {
|
|
9
|
+
private readonly entries;
|
|
10
|
+
static load(path: string): PendingRegistry;
|
|
11
|
+
has(id: string): boolean;
|
|
12
|
+
get size(): number;
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
/**
|
|
4
|
+
* The pending registry mirrors the Ruby format exactly
|
|
5
|
+
* (conformance/pending.yaml in the reference): cases this port
|
|
6
|
+
* explicitly cannot handle yet. A pending case that PASSES raises the
|
|
7
|
+
* pending-satisfied alarm - the marker must then be removed, never left
|
|
8
|
+
* to rot while the case actually passes.
|
|
9
|
+
*/
|
|
10
|
+
export class PendingRegistry {
|
|
11
|
+
entries = new Map();
|
|
12
|
+
static load(path) {
|
|
13
|
+
const registry = new PendingRegistry();
|
|
14
|
+
if (!existsSync(path))
|
|
15
|
+
return registry;
|
|
16
|
+
const doc = parseYaml(readFileSync(path, "utf8"));
|
|
17
|
+
for (const [id, entry] of Object.entries(doc ?? {})) {
|
|
18
|
+
if (!entry?.reason || !entry?.ref) {
|
|
19
|
+
throw new Error(`pending entry ${id} needs reason + ref (same format as the Ruby reference)`);
|
|
20
|
+
}
|
|
21
|
+
registry.entries.set(id, { reason: entry.reason, ref: entry.ref });
|
|
22
|
+
}
|
|
23
|
+
return registry;
|
|
24
|
+
}
|
|
25
|
+
has(id) {
|
|
26
|
+
return this.entries.has(id);
|
|
27
|
+
}
|
|
28
|
+
get size() {
|
|
29
|
+
return this.entries.size;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CorpusCase } from "../corpus/types.js";
|
|
2
|
+
import type { FlavorPayloads } from "../corpus/loader.js";
|
|
3
|
+
import type { ImplementationRegistry, FlavorImplementation } from "./implementation.js";
|
|
4
|
+
import type { PendingRegistry } from "./pending.js";
|
|
5
|
+
export interface FlavorReport {
|
|
6
|
+
flavor: string;
|
|
7
|
+
outcome: "pass" | "fail" | "unimplemented" | "ledger";
|
|
8
|
+
cases: number;
|
|
9
|
+
errors: number;
|
|
10
|
+
quarantined: number;
|
|
11
|
+
failures: string[];
|
|
12
|
+
pending: number;
|
|
13
|
+
pendingSatisfied: string[];
|
|
14
|
+
reclassified: number;
|
|
15
|
+
review: number;
|
|
16
|
+
}
|
|
17
|
+
export interface RunReport {
|
|
18
|
+
flavors: FlavorReport[];
|
|
19
|
+
/** FAIL on any clean flavor; unimplemented flavors report, never gate. */
|
|
20
|
+
gate: "pass" | "fail";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One corpus case -> its mismatch list (empty == passing). List-shaped
|
|
24
|
+
* for the same reason as the Ruby core: control flow must not depend on
|
|
25
|
+
* exception mechanics to know whether a case passed.
|
|
26
|
+
*/
|
|
27
|
+
export declare function checkCase(testCase: CorpusCase, impl: FlavorImplementation): string[];
|
|
28
|
+
/** Negatives: expect-error rows must be rejected; the rest are the exporter's reclassify alarms. */
|
|
29
|
+
export declare function checkNegatives(payloads: FlavorPayloads, impl: FlavorImplementation): {
|
|
30
|
+
failures: string[];
|
|
31
|
+
reclassified: number;
|
|
32
|
+
};
|
|
33
|
+
export declare function runFlavor(flavor: string, payloads: FlavorPayloads, impl: FlavorImplementation | undefined, pending: PendingRegistry): FlavorReport;
|
|
34
|
+
export declare function runCorpus(corpus: {
|
|
35
|
+
flavors: Map<string, FlavorPayloads>;
|
|
36
|
+
}, implementations: ImplementationRegistry, pending: PendingRegistry): RunReport;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isErrorCase, isQuarantined, isReview } from "../corpus/types.js";
|
|
2
|
+
import { deepEqual } from "../internal/compare.js";
|
|
3
|
+
/**
|
|
4
|
+
* One corpus case -> its mismatch list (empty == passing). List-shaped
|
|
5
|
+
* for the same reason as the Ruby core: control flow must not depend on
|
|
6
|
+
* exception mechanics to know whether a case passed.
|
|
7
|
+
*/
|
|
8
|
+
export function checkCase(testCase, impl) {
|
|
9
|
+
const label = testCase.id;
|
|
10
|
+
let identifier;
|
|
11
|
+
try {
|
|
12
|
+
identifier = impl.parse(testCase.representations.human);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
return [`${label} raised ${error.name}`];
|
|
16
|
+
}
|
|
17
|
+
const mismatches = [];
|
|
18
|
+
if (!deepEqual(identifier.toHash(), testCase.identifier)) {
|
|
19
|
+
mismatches.push(`${label} canonical hash`);
|
|
20
|
+
}
|
|
21
|
+
if (identifier.toHuman() !== testCase.representations.human) {
|
|
22
|
+
mismatches.push(`${label} human`);
|
|
23
|
+
}
|
|
24
|
+
if (testCase.representations.urn !== undefined) {
|
|
25
|
+
const urn = identifier.toUrn();
|
|
26
|
+
if (urn !== testCase.representations.urn)
|
|
27
|
+
mismatches.push(`${label} urn`);
|
|
28
|
+
}
|
|
29
|
+
for (const alias of testCase.nonNormalizedAliases ?? []) {
|
|
30
|
+
try {
|
|
31
|
+
const aliased = impl.parse(alias.spelling);
|
|
32
|
+
if (aliased.toHuman() !== testCase.representations.human) {
|
|
33
|
+
mismatches.push(`${label} alias ${alias.spelling}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
mismatches.push(`${label} alias ${alias.spelling} raised`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const hash = identifier.toHash();
|
|
42
|
+
if (!deepEqual(identifier.fromHash(hash).toHash(), hash)) {
|
|
43
|
+
mismatches.push(`${label} deserialize`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
mismatches.push(`${label} deserialize raised`);
|
|
48
|
+
}
|
|
49
|
+
return mismatches;
|
|
50
|
+
}
|
|
51
|
+
/** Negatives: expect-error rows must be rejected; the rest are the exporter's reclassify alarms. */
|
|
52
|
+
export function checkNegatives(payloads, impl) {
|
|
53
|
+
const failures = [];
|
|
54
|
+
let reclassified = 0;
|
|
55
|
+
for (const row of payloads.negatives) {
|
|
56
|
+
if (!isErrorCase(row)) {
|
|
57
|
+
reclassified += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (row.input === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
try {
|
|
63
|
+
impl.parse(row.input);
|
|
64
|
+
failures.push(`${row.id} unexpectedly parsed`);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// rejected as expected
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { failures, reclassified };
|
|
71
|
+
}
|
|
72
|
+
export function runFlavor(flavor, payloads, impl, pending) {
|
|
73
|
+
const report = {
|
|
74
|
+
flavor,
|
|
75
|
+
outcome: "pass",
|
|
76
|
+
cases: 0,
|
|
77
|
+
errors: 0,
|
|
78
|
+
quarantined: 0,
|
|
79
|
+
failures: [],
|
|
80
|
+
pending: 0,
|
|
81
|
+
pendingSatisfied: [],
|
|
82
|
+
reclassified: 0,
|
|
83
|
+
review: 0,
|
|
84
|
+
};
|
|
85
|
+
if (impl === undefined) {
|
|
86
|
+
// Unimplemented flavors still report their full corpus footprint so
|
|
87
|
+
// the report proves corpus-wide operability of the harness itself;
|
|
88
|
+
// they never gate (waves activate the gate per flavor).
|
|
89
|
+
report.cases = payloads.cases.filter((c) => c.representations !== undefined).length;
|
|
90
|
+
report.errors = payloads.negatives.filter(isErrorCase).length;
|
|
91
|
+
report.reclassified = payloads.negatives.length - report.errors;
|
|
92
|
+
report.review = payloads.cases.filter(isReview).length;
|
|
93
|
+
report.outcome = "unimplemented";
|
|
94
|
+
return report;
|
|
95
|
+
}
|
|
96
|
+
for (const testCase of payloads.cases) {
|
|
97
|
+
if (isReview(testCase)) {
|
|
98
|
+
report.review += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (isErrorCase(testCase)) {
|
|
102
|
+
report.errors += 1;
|
|
103
|
+
if (testCase.input !== undefined) {
|
|
104
|
+
try {
|
|
105
|
+
impl.parse(testCase.input);
|
|
106
|
+
report.failures.push(`${testCase.id} unexpectedly parsed`);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// rejected as expected
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (isQuarantined(testCase)) {
|
|
115
|
+
report.quarantined += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const mismatches = checkCase(testCase, impl);
|
|
119
|
+
if (pending.has(testCase.id)) {
|
|
120
|
+
report.pending += 1;
|
|
121
|
+
if (mismatches.length === 0)
|
|
122
|
+
report.pendingSatisfied.push(testCase.id);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
report.cases += 1;
|
|
126
|
+
report.failures.push(...mismatches);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const negativeRun = checkNegatives(payloads, impl);
|
|
130
|
+
report.failures.push(...negativeRun.failures);
|
|
131
|
+
report.reclassified = negativeRun.reclassified;
|
|
132
|
+
// Dirty flavors report as the ledger; only their pass/fail shape is
|
|
133
|
+
// recorded - the clean gate below never consults them.
|
|
134
|
+
report.outcome = payloads.status.clean
|
|
135
|
+
? report.failures.length === 0
|
|
136
|
+
? "pass"
|
|
137
|
+
: "fail"
|
|
138
|
+
: "ledger";
|
|
139
|
+
return report;
|
|
140
|
+
}
|
|
141
|
+
export function runCorpus(corpus, implementations, pending) {
|
|
142
|
+
const flavors = [];
|
|
143
|
+
let gate = "pass";
|
|
144
|
+
for (const [flavor, payloads] of corpus.flavors) {
|
|
145
|
+
const report = runFlavor(flavor, payloads, implementations.get(flavor), pending);
|
|
146
|
+
if (report.outcome === "fail")
|
|
147
|
+
gate = "fail";
|
|
148
|
+
flavors.push(report);
|
|
149
|
+
}
|
|
150
|
+
return { flavors, gate };
|
|
151
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Corpus } from "./loader.js";
|
|
2
|
+
/** Serialize a loaded corpus to the compact artifact JSON string. */
|
|
3
|
+
export declare function buildArtifact(corpus: Corpus): string;
|
|
4
|
+
/** Rebuild the Corpus shape from artifact JSON produced by {@link buildArtifact}. */
|
|
5
|
+
export declare function parseArtifact(json: string): Corpus;
|
|
6
|
+
/**
|
|
7
|
+
* Load the corpus shipped inside this package (artifact/corpus.json.gz).
|
|
8
|
+
* No testsuite checkout, no yaml dependency at runtime.
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadBundledCorpus(): Corpus;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { gunzipSync } from "node:zlib";
|
|
3
|
+
/** Serialize a loaded corpus to the compact artifact JSON string. */
|
|
4
|
+
export function buildArtifact(corpus) {
|
|
5
|
+
const data = {};
|
|
6
|
+
for (const [name, payloads] of corpus.flavors) {
|
|
7
|
+
data[name] = {
|
|
8
|
+
status: payloads.status,
|
|
9
|
+
cases: payloads.cases,
|
|
10
|
+
negatives: payloads.negatives,
|
|
11
|
+
debt: payloads.debt,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return JSON.stringify(data);
|
|
15
|
+
}
|
|
16
|
+
/** Rebuild the Corpus shape from artifact JSON produced by {@link buildArtifact}. */
|
|
17
|
+
export function parseArtifact(json) {
|
|
18
|
+
const data = JSON.parse(json);
|
|
19
|
+
const flavors = new Map();
|
|
20
|
+
for (const [name, flavor] of Object.entries(data)) {
|
|
21
|
+
if (typeof flavor?.status?.clean !== "boolean") {
|
|
22
|
+
throw new Error(`malformed artifact: flavor ${name} has no boolean status.clean`);
|
|
23
|
+
}
|
|
24
|
+
flavors.set(name, {
|
|
25
|
+
status: flavor.status,
|
|
26
|
+
cases: flavor.cases ?? [],
|
|
27
|
+
negatives: flavor.negatives ?? [],
|
|
28
|
+
debt: flavor.debt ?? [],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (flavors.size === 0) {
|
|
32
|
+
throw new Error("malformed artifact: no flavors - refusing vacuous success");
|
|
33
|
+
}
|
|
34
|
+
return { flavors };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Walk up from this module to the directory holding package.json, so the
|
|
38
|
+
* artifact resolves identically from dist/, dist-test/ and src/ builds.
|
|
39
|
+
*/
|
|
40
|
+
function packageRoot() {
|
|
41
|
+
let dir = new URL(".", import.meta.url);
|
|
42
|
+
for (let i = 0; i < 6; i++) {
|
|
43
|
+
if (existsSync(new URL("package.json", dir)))
|
|
44
|
+
return dir;
|
|
45
|
+
dir = new URL("../", dir);
|
|
46
|
+
}
|
|
47
|
+
throw new Error("package root not found - cannot locate the bundled corpus artifact");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Load the corpus shipped inside this package (artifact/corpus.json.gz).
|
|
51
|
+
* No testsuite checkout, no yaml dependency at runtime.
|
|
52
|
+
*/
|
|
53
|
+
export function loadBundledCorpus() {
|
|
54
|
+
const artifactUrl = new URL("artifact/corpus.json.gz", packageRoot());
|
|
55
|
+
const json = gunzipSync(readFileSync(artifactUrl)).toString("utf8");
|
|
56
|
+
return parseArtifact(json);
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CorpusCase, FlavorStatus } from "./types.js";
|
|
2
|
+
export interface FlavorPayloads {
|
|
3
|
+
/** Non-underscore payload files: canonical cases + aliases. */
|
|
4
|
+
cases: CorpusCase[];
|
|
5
|
+
/** tests/{flavor}/_negative.yaml - fail fixtures decoded by the exporter. */
|
|
6
|
+
negatives: CorpusCase[];
|
|
7
|
+
/** tests/{flavor}/_debt.yaml - pass lines the reference cannot parse. */
|
|
8
|
+
debt: CorpusCase[];
|
|
9
|
+
status: FlavorStatus;
|
|
10
|
+
}
|
|
11
|
+
export interface Corpus {
|
|
12
|
+
flavors: Map<string, FlavorPayloads>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Load the pubid-testsuite corpus. The corpus is the single source of
|
|
16
|
+
* truth for identifier behavior; this loader never edits it (the
|
|
17
|
+
* cross-repo law: schema/corpus changes land in pubid Ruby PRs first).
|
|
18
|
+
*
|
|
19
|
+
* @param testsDir path to a pubid-testsuite checkout's tests/ directory
|
|
20
|
+
*/
|
|
21
|
+
export declare function loadCorpus(testsDir: string): Corpus;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
/**
|
|
5
|
+
* The wire schema uses snake_case for exactly one field
|
|
6
|
+
* (non_normalized_aliases); the loader is the boundary that normalises
|
|
7
|
+
* it, so a missed mapping can never silently disable a check.
|
|
8
|
+
*/
|
|
9
|
+
function loadPayload(path) {
|
|
10
|
+
const doc = parseYaml(readFileSync(path, "utf8"));
|
|
11
|
+
if (!Array.isArray(doc))
|
|
12
|
+
return [];
|
|
13
|
+
return doc.map((row) => {
|
|
14
|
+
const raw = row;
|
|
15
|
+
if (raw.non_normalized_aliases === undefined)
|
|
16
|
+
return row;
|
|
17
|
+
const { non_normalized_aliases, ...rest } = raw;
|
|
18
|
+
return {
|
|
19
|
+
...rest,
|
|
20
|
+
nonNormalizedAliases: non_normalized_aliases,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function loadStatus(dir) {
|
|
25
|
+
const path = join(dir, "_status.yaml");
|
|
26
|
+
if (!existsSync(path)) {
|
|
27
|
+
throw new Error(`missing _status.yaml in ${dir} - every flavor has one`);
|
|
28
|
+
}
|
|
29
|
+
const doc = parseYaml(readFileSync(path, "utf8"));
|
|
30
|
+
if (typeof doc.clean !== "boolean") {
|
|
31
|
+
throw new Error(`malformed _status.yaml in ${dir}: clean must be boolean`);
|
|
32
|
+
}
|
|
33
|
+
return doc;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Load the pubid-testsuite corpus. The corpus is the single source of
|
|
37
|
+
* truth for identifier behavior; this loader never edits it (the
|
|
38
|
+
* cross-repo law: schema/corpus changes land in pubid Ruby PRs first).
|
|
39
|
+
*
|
|
40
|
+
* @param testsDir path to a pubid-testsuite checkout's tests/ directory
|
|
41
|
+
*/
|
|
42
|
+
export function loadCorpus(testsDir) {
|
|
43
|
+
if (!existsSync(testsDir)) {
|
|
44
|
+
throw new Error(`corpus not found at ${testsDir} - refusing to report vacuous success`);
|
|
45
|
+
}
|
|
46
|
+
const flavors = new Map();
|
|
47
|
+
for (const entry of readdirSync(testsDir).sort()) {
|
|
48
|
+
const dir = join(testsDir, entry);
|
|
49
|
+
if (!statSync(dir).isDirectory())
|
|
50
|
+
continue;
|
|
51
|
+
const payloads = {
|
|
52
|
+
cases: [],
|
|
53
|
+
negatives: [],
|
|
54
|
+
debt: [],
|
|
55
|
+
status: loadStatus(dir),
|
|
56
|
+
};
|
|
57
|
+
for (const file of readdirSync(dir).sort()) {
|
|
58
|
+
if (!file.endsWith(".yaml"))
|
|
59
|
+
continue;
|
|
60
|
+
const rows = loadPayload(join(dir, file));
|
|
61
|
+
if (file === "_negative.yaml")
|
|
62
|
+
payloads.negatives = rows;
|
|
63
|
+
else if (file === "_debt.yaml")
|
|
64
|
+
payloads.debt = rows;
|
|
65
|
+
else if (!file.startsWith("_"))
|
|
66
|
+
payloads.cases.push(...rows);
|
|
67
|
+
}
|
|
68
|
+
flavors.set(entry, payloads);
|
|
69
|
+
}
|
|
70
|
+
if (flavors.size === 0) {
|
|
71
|
+
throw new Error(`no flavor payloads under ${testsDir} - refusing to report vacuous success`);
|
|
72
|
+
}
|
|
73
|
+
return { flavors };
|
|
74
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pubid-testsuite wire contract (schema/test.schema.yaml is the
|
|
3
|
+
* authority; these types mirror it exactly). Every field is optional at
|
|
4
|
+
* the schema level except id - the loader normalises to explicit
|
|
5
|
+
* optionals rather than lying about shapes.
|
|
6
|
+
*/
|
|
7
|
+
export interface Representations {
|
|
8
|
+
/** The canonical spelling AND the canonical parse input. */
|
|
9
|
+
human: string;
|
|
10
|
+
/** Emitted URN, absent when the reference emits none. */
|
|
11
|
+
urn?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Spelling {
|
|
14
|
+
spelling: string;
|
|
15
|
+
style?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ErrorExpectation {
|
|
18
|
+
/** Neutral rejection code - never an implementation class. */
|
|
19
|
+
code: string;
|
|
20
|
+
}
|
|
21
|
+
export interface Expectation {
|
|
22
|
+
error?: ErrorExpectation;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* One corpus case. `identifier` is the reference implementation's own
|
|
26
|
+
* canonical toHash carried as DATA to compare against - an opaque record,
|
|
27
|
+
* never interpreted structurally by the port.
|
|
28
|
+
*/
|
|
29
|
+
export interface CorpusCase {
|
|
30
|
+
id: string;
|
|
31
|
+
style?: string;
|
|
32
|
+
identifier?: Record<string, unknown>;
|
|
33
|
+
representations: Representations;
|
|
34
|
+
nonNormalizedAliases?: Spelling[];
|
|
35
|
+
/** Present only on exception (a documented round-trip failure). */
|
|
36
|
+
roundtrip?: boolean;
|
|
37
|
+
expect?: Expectation;
|
|
38
|
+
/** Debt/negative rows carry the raw input instead of representations. */
|
|
39
|
+
input?: string;
|
|
40
|
+
notes?: string;
|
|
41
|
+
/** Contract-linked expectation flag (tests/_contracts.yaml); never gated. */
|
|
42
|
+
review?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface FlavorStatus {
|
|
45
|
+
clean: boolean;
|
|
46
|
+
knownMismatches: number;
|
|
47
|
+
knownSchemaErrors: number;
|
|
48
|
+
notes?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function isErrorCase(testCase: CorpusCase): boolean;
|
|
51
|
+
/** Reference-bug debt: nothing to gate on. */
|
|
52
|
+
export declare function isQuarantined(testCase: CorpusCase): boolean;
|
|
53
|
+
export declare function isReview(testCase: CorpusCase): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pubid-testsuite wire contract (schema/test.schema.yaml is the
|
|
3
|
+
* authority; these types mirror it exactly). Every field is optional at
|
|
4
|
+
* the schema level except id - the loader normalises to explicit
|
|
5
|
+
* optionals rather than lying about shapes.
|
|
6
|
+
*/
|
|
7
|
+
export function isErrorCase(testCase) {
|
|
8
|
+
return testCase.expect?.error !== undefined;
|
|
9
|
+
}
|
|
10
|
+
/** Reference-bug debt: nothing to gate on. */
|
|
11
|
+
export function isQuarantined(testCase) {
|
|
12
|
+
return testCase.identifier === undefined && !isErrorCase(testCase);
|
|
13
|
+
}
|
|
14
|
+
export function isReview(testCase) {
|
|
15
|
+
return (testCase.review ?? "") !== "";
|
|
16
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CorpusCase } from "../corpus/types.js";
|
|
2
|
+
import type { FlavorImplementation, Identifier } from "../conformance/implementation.js";
|
|
3
|
+
interface CorpusRow {
|
|
4
|
+
hash: Record<string, unknown>;
|
|
5
|
+
human: string;
|
|
6
|
+
urn?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Corpus-backed identifier resolution over the pubid-testsuite corpus:
|
|
10
|
+
* every canonical spelling, its URN, its aliases and its canonical
|
|
11
|
+
* serialized form. Corpus mode resolves any identifier IN that universe
|
|
12
|
+
* and nothing outside it — it is a conformance surface and a fixture for
|
|
13
|
+
* the grammar waves (which take over a flavor open-endedly as they land),
|
|
14
|
+
* NOT an open-universe runtime parser: a real-world identifier missing
|
|
15
|
+
* from the corpus is rejected by design.
|
|
16
|
+
*/
|
|
17
|
+
export declare class CorpusBackedIdentifier implements Identifier {
|
|
18
|
+
private readonly row;
|
|
19
|
+
private readonly resolveHash;
|
|
20
|
+
constructor(row: CorpusRow, resolveHash: (hash: Record<string, unknown>) => CorpusRow);
|
|
21
|
+
toHash(): Record<string, unknown>;
|
|
22
|
+
toHuman(): string;
|
|
23
|
+
toUrn(): string | undefined;
|
|
24
|
+
fromHash(hash: Record<string, unknown>): Identifier;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build a corpus-mode implementation for one flavor from its payload
|
|
28
|
+
* cases. Indexes are per-implementation: a module-global hash index
|
|
29
|
+
* accumulated rows from every flavor, so an identifier from one flavor
|
|
30
|
+
* could deserialize another flavor's row — exactly the multi-SDO consumer
|
|
31
|
+
* this library exists for. Humans, URNs and non-normalized aliases all
|
|
32
|
+
* resolve (case- and whitespace-insensitively) to the same row; unknown
|
|
33
|
+
* spellings are rejected (parse throws).
|
|
34
|
+
*/
|
|
35
|
+
export declare function corpusModeImplementation(cases: CorpusCase[]): FlavorImplementation & {
|
|
36
|
+
parseUrnCandidates(input: string): Identifier[];
|
|
37
|
+
};
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { canonicalKey } from "../internal/compare.js";
|
|
2
|
+
/**
|
|
3
|
+
* The lookup tiers. Whitespace runs collapse in the EXACT tier already —
|
|
4
|
+
* no identifier semantics live in whitespace. Case is different: some
|
|
5
|
+
* corpora carry identifiers whose subfields are case-significant (IECEx
|
|
6
|
+
* "60079-0v7B_DS" vs "60079-0v7b_DS" are distinct documents), so casefold
|
|
7
|
+
* is a FALLBACK tier, consulted only when no exact row matches. The
|
|
8
|
+
* fallback is first-wins in corpus order: deterministic, and it never
|
|
9
|
+
* overrides an exact hit. Spaces adjacent to a COLON collapse in the
|
|
10
|
+
* fallback tier too ("R 117-1 : 2019" -> "R 117-1:2019") — the colon is
|
|
11
|
+
* always a date separator in these identifiers, so the squeeze cannot
|
|
12
|
+
* merge two distinct rows, while "…: 2004" spellings are established
|
|
13
|
+
* real-world variance (the testsuite's own _normalization.yaml carries
|
|
14
|
+
* them). Only the colon: spaces around dashes or dots DO distinguish
|
|
15
|
+
* rows in some corpora, and are left alone.
|
|
16
|
+
*/
|
|
17
|
+
function collapseWs(spelling) {
|
|
18
|
+
return spelling.trim().replace(/\s+/g, " ");
|
|
19
|
+
}
|
|
20
|
+
function foldKey(spelling) {
|
|
21
|
+
return collapseWs(spelling).replace(/\s*:\s*/g, ":").toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Some URN schemes are edition-less (ISO: "ISO/IEC 17025:1999", ":2005"
|
|
25
|
+
* and ":2017" all serialize to urn:iso:std:iso-iec:17025), so a URN can
|
|
26
|
+
* key several corpus rows — 40% of iso's. Resolve such a URN to the
|
|
27
|
+
* LATEST edition (the greatest `year` in the row's hash; rows without a
|
|
28
|
+
* year rank lowest), which keeps parse(toUrn()) deterministic AND
|
|
29
|
+
* sensible, and expose the whole candidate set through
|
|
30
|
+
* parseUrnCandidates for consumers that need it.
|
|
31
|
+
*/
|
|
32
|
+
function latestEditionRow(rows) {
|
|
33
|
+
return [...rows].sort((a, b) => rowYear(a) - rowYear(b)).at(-1);
|
|
34
|
+
}
|
|
35
|
+
function rowYear(row) {
|
|
36
|
+
const year = row.hash["year"];
|
|
37
|
+
const parsed = typeof year === "string" || typeof year === "number"
|
|
38
|
+
? Number(year)
|
|
39
|
+
: Number.NaN;
|
|
40
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Corpus-backed identifier resolution over the pubid-testsuite corpus:
|
|
44
|
+
* every canonical spelling, its URN, its aliases and its canonical
|
|
45
|
+
* serialized form. Corpus mode resolves any identifier IN that universe
|
|
46
|
+
* and nothing outside it — it is a conformance surface and a fixture for
|
|
47
|
+
* the grammar waves (which take over a flavor open-endedly as they land),
|
|
48
|
+
* NOT an open-universe runtime parser: a real-world identifier missing
|
|
49
|
+
* from the corpus is rejected by design.
|
|
50
|
+
*/
|
|
51
|
+
export class CorpusBackedIdentifier {
|
|
52
|
+
row;
|
|
53
|
+
resolveHash;
|
|
54
|
+
constructor(row, resolveHash) {
|
|
55
|
+
this.row = row;
|
|
56
|
+
this.resolveHash = resolveHash;
|
|
57
|
+
}
|
|
58
|
+
toHash() {
|
|
59
|
+
return structuredClone(this.row.hash);
|
|
60
|
+
}
|
|
61
|
+
toHuman() {
|
|
62
|
+
return this.row.human;
|
|
63
|
+
}
|
|
64
|
+
toUrn() {
|
|
65
|
+
return this.row.urn;
|
|
66
|
+
}
|
|
67
|
+
fromHash(hash) {
|
|
68
|
+
return new CorpusBackedIdentifier(this.resolveHash(hash), this.resolveHash);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build a corpus-mode implementation for one flavor from its payload
|
|
73
|
+
* cases. Indexes are per-implementation: a module-global hash index
|
|
74
|
+
* accumulated rows from every flavor, so an identifier from one flavor
|
|
75
|
+
* could deserialize another flavor's row — exactly the multi-SDO consumer
|
|
76
|
+
* this library exists for. Humans, URNs and non-normalized aliases all
|
|
77
|
+
* resolve (case- and whitespace-insensitively) to the same row; unknown
|
|
78
|
+
* spellings are rejected (parse throws).
|
|
79
|
+
*/
|
|
80
|
+
export function corpusModeImplementation(cases) {
|
|
81
|
+
const bySpelling = new Map();
|
|
82
|
+
const byFoldedSpelling = new Map();
|
|
83
|
+
// A URN keys EVERY row it was serialized from — ISO-style URN schemes
|
|
84
|
+
// are edition-less, so one URN can carry up to five editions. Singular
|
|
85
|
+
// storage silently resolved the ambiguity (a different edition than the
|
|
86
|
+
// one serialized, with no signal) — pubid-ts issue #4.
|
|
87
|
+
const byUrn = new Map();
|
|
88
|
+
const hashIndex = new Map();
|
|
89
|
+
const resolveHash = (hash) => {
|
|
90
|
+
const found = hashIndex.get(canonicalKey(hash));
|
|
91
|
+
if (found === undefined) {
|
|
92
|
+
throw new Error("unknown canonical hash for corpus-mode deserialization");
|
|
93
|
+
}
|
|
94
|
+
return found;
|
|
95
|
+
};
|
|
96
|
+
const indexSpelling = (spelling, row, keepFirst) => {
|
|
97
|
+
const exact = collapseWs(spelling);
|
|
98
|
+
if (!keepFirst || !bySpelling.has(exact))
|
|
99
|
+
bySpelling.set(exact, row);
|
|
100
|
+
const folded = foldKey(spelling);
|
|
101
|
+
if (!byFoldedSpelling.has(folded))
|
|
102
|
+
byFoldedSpelling.set(folded, row);
|
|
103
|
+
};
|
|
104
|
+
for (const testCase of cases) {
|
|
105
|
+
if (testCase.identifier === undefined)
|
|
106
|
+
continue; // quarantined row
|
|
107
|
+
const row = {
|
|
108
|
+
hash: testCase.identifier,
|
|
109
|
+
human: testCase.representations.human,
|
|
110
|
+
urn: testCase.representations.urn,
|
|
111
|
+
};
|
|
112
|
+
hashIndex.set(canonicalKey(row.hash), row);
|
|
113
|
+
indexSpelling(row.human, row, false);
|
|
114
|
+
if (row.urn !== undefined) {
|
|
115
|
+
const key = foldKey(row.urn);
|
|
116
|
+
const bucket = byUrn.get(key);
|
|
117
|
+
if (bucket)
|
|
118
|
+
bucket.push(row);
|
|
119
|
+
else
|
|
120
|
+
byUrn.set(key, [row]);
|
|
121
|
+
}
|
|
122
|
+
for (const alias of testCase.nonNormalizedAliases ?? []) {
|
|
123
|
+
indexSpelling(alias.spelling, row, true);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const urnRows = (input) => /^urn:/i.test(input.trim()) ? byUrn.get(foldKey(input)) : undefined;
|
|
127
|
+
const urnCandidates = (input) => urnRows(input) ?? [];
|
|
128
|
+
const parseInput = (input) => {
|
|
129
|
+
const rows = urnRows(input);
|
|
130
|
+
if (rows)
|
|
131
|
+
return latestEditionRow(rows);
|
|
132
|
+
return bySpelling.get(collapseWs(input)) ??
|
|
133
|
+
byFoldedSpelling.get(foldKey(input));
|
|
134
|
+
};
|
|
135
|
+
const implementation = {
|
|
136
|
+
parse(input) {
|
|
137
|
+
const row = parseInput(input);
|
|
138
|
+
if (row === undefined) {
|
|
139
|
+
throw new Error(`no such identifier in the published corpus: ${input}`);
|
|
140
|
+
}
|
|
141
|
+
return new CorpusBackedIdentifier(row, resolveHash);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
return Object.assign(implementation, {
|
|
145
|
+
parseUrnCandidates: (input) => urnCandidates(input).map((row) => new CorpusBackedIdentifier(row, resolveHash)),
|
|
146
|
+
});
|
|
147
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./corpus/types.js";
|
|
2
|
+
export { loadCorpus } from "./corpus/loader.js";
|
|
3
|
+
export type { FlavorPayloads, Corpus } from "./corpus/loader.js";
|
|
4
|
+
export { buildArtifact, loadBundledCorpus, parseArtifact, } from "./corpus/artifact.js";
|
|
5
|
+
export type { FlavorImplementation, Identifier, ImplementationRegistry } from "./conformance/implementation.js";
|
|
6
|
+
export { PendingRegistry } from "./conformance/pending.js";
|
|
7
|
+
export { checkCase, runFlavor, runCorpus } from "./conformance/runner.js";
|
|
8
|
+
export { corpusModeImplementation, CorpusBackedIdentifier } from "./implementations/corpus-mode.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from "./corpus/types.js";
|
|
2
|
+
export { loadCorpus } from "./corpus/loader.js";
|
|
3
|
+
export { buildArtifact, loadBundledCorpus, parseArtifact, } from "./corpus/artifact.js";
|
|
4
|
+
export { PendingRegistry } from "./conformance/pending.js";
|
|
5
|
+
export { checkCase, runFlavor, runCorpus } from "./conformance/runner.js";
|
|
6
|
+
export { corpusModeImplementation, CorpusBackedIdentifier } from "./implementations/corpus-mode.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Canonical JSON key order: the comparison basis for hash records. */
|
|
2
|
+
export function canonicalKey(value) {
|
|
3
|
+
return JSON.stringify(sortKeys(value));
|
|
4
|
+
}
|
|
5
|
+
export function deepEqual(a, b) {
|
|
6
|
+
return canonicalKey(a) === canonicalKey(b);
|
|
7
|
+
}
|
|
8
|
+
function sortKeys(value) {
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return value.map(sortKeys);
|
|
11
|
+
if (value !== null && typeof value === "object") {
|
|
12
|
+
return Object.fromEntries(Object.entries(value)
|
|
13
|
+
.sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0))
|
|
14
|
+
.map(([k, v]) => [k, sortKeys(v)]));
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pubid/pubid",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "TypeScript PubID identifiers: resolution, normalization and canonical hashes for every identifier in the pubid-testsuite corpus (41 standards bodies), with a conformance runner mirroring the Ruby reference",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -p tsconfig.json",
|
|
14
|
+
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js",
|
|
15
|
+
"conformance": "tsc -p tsconfig.json && node dist/conformance/cli.js",
|
|
16
|
+
"prepublishOnly": "tsc -p tsconfig.json",
|
|
17
|
+
"artifact": "tsc -p tsconfig.json && node scripts/build-artifact.mjs"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"yaml": "^2.5.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.6.0",
|
|
24
|
+
"@types/node": "^20.14.0"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"artifact"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"pubid",
|
|
32
|
+
"identifier",
|
|
33
|
+
"standards",
|
|
34
|
+
"iso",
|
|
35
|
+
"iec",
|
|
36
|
+
"ieee",
|
|
37
|
+
"nist",
|
|
38
|
+
"conformance"
|
|
39
|
+
],
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/pubid/pubid-ts.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/pubid/pubid-ts#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/pubid/pubid-ts/issues"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"sideEffects": false
|
|
52
|
+
}
|