@nuggetslife/vc 0.0.30 → 0.2.0

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.
Files changed (66) hide show
  1. package/Cargo.toml +11 -0
  2. package/W3C_CONFORMANCE.md +6 -5
  3. package/bench/frame_compare.mjs +203 -0
  4. package/bench/v2_internals.mjs +115 -0
  5. package/bench/vc_ops.mjs +308 -0
  6. package/interop-allowlist.json +3 -0
  7. package/interop-smoke-allowlist.json +3 -0
  8. package/package.json +8 -7
  9. package/scripts/fetch-w3c-tests.sh +8 -0
  10. package/src/jsonld.rs +211 -140
  11. package/src/ld_signatures.rs +11 -2
  12. package/test-fixtures/interop/README.md +46 -0
  13. package/test-fixtures/interop/_contexts/README.md +51 -0
  14. package/test-fixtures/interop/_contexts/bbs-bound-v1.jsonld +92 -0
  15. package/test-fixtures/interop/_contexts/citizenship-v1.jsonld +58 -0
  16. package/test-fixtures/interop/_contexts/credentials-v1.jsonld +316 -0
  17. package/test-fixtures/interop/_contexts/elm-edc-ap.jsonld +809 -0
  18. package/test-fixtures/interop/_contexts/essif-schemas-vc-2020-v1.jsonld +47 -0
  19. package/test-fixtures/interop/_contexts/identity-v2.jsonld +195 -0
  20. package/test-fixtures/interop/_contexts/nuggets-identity-v1.jsonld +175 -0
  21. package/test-fixtures/interop/_contexts/nuggets-kyb-v1.jsonld +333 -0
  22. package/test-fixtures/interop/_contexts/openbadges-v3.jsonld +445 -0
  23. package/test-fixtures/interop/_contexts/security-bbs-v1.jsonld +93 -0
  24. package/test-fixtures/interop/ebsi/diploma-elm/README.md +29 -0
  25. package/test-fixtures/interop/ebsi/diploma-elm/expected.nq +70 -0
  26. package/test-fixtures/interop/ebsi/diploma-elm/frame.jsonld +24 -0
  27. package/test-fixtures/interop/ebsi/diploma-elm/input.jsonld +444 -0
  28. package/test-fixtures/interop/ebsi/diploma-simple/README.md +19 -0
  29. package/test-fixtures/interop/ebsi/diploma-simple/expected.nq +9 -0
  30. package/test-fixtures/interop/ebsi/diploma-simple/frame.jsonld +13 -0
  31. package/test-fixtures/interop/ebsi/diploma-simple/input.jsonld +24 -0
  32. package/test-fixtures/interop/idv2/full-disclosure/README.md +9 -0
  33. package/test-fixtures/interop/idv2/full-disclosure/expected.nq +59 -0
  34. package/test-fixtures/interop/idv2/full-disclosure/frame.jsonld +9 -0
  35. package/test-fixtures/interop/idv2/full-disclosure/input.jsonld +82 -0
  36. package/test-fixtures/interop/nuggets/identity-v1/README.md +17 -0
  37. package/test-fixtures/interop/nuggets/identity-v1/expected.nq +21 -0
  38. package/test-fixtures/interop/nuggets/identity-v1/frame.jsonld +17 -0
  39. package/test-fixtures/interop/nuggets/identity-v1/input.jsonld +31 -0
  40. package/test-fixtures/interop/nuggets/kyb-v1/README.md +15 -0
  41. package/test-fixtures/interop/nuggets/kyb-v1/expected.nq +18 -0
  42. package/test-fixtures/interop/nuggets/kyb-v1/frame.jsonld +24 -0
  43. package/test-fixtures/interop/nuggets/kyb-v1/input.jsonld +60 -0
  44. package/test-fixtures/interop/openbadges-v3/basic-achievement/README.md +17 -0
  45. package/test-fixtures/interop/openbadges-v3/basic-achievement/expected.nq +12 -0
  46. package/test-fixtures/interop/openbadges-v3/basic-achievement/frame.jsonld +17 -0
  47. package/test-fixtures/interop/openbadges-v3/basic-achievement/input.jsonld +25 -0
  48. package/test-fixtures/interop/openbadges-v3/with-allowed-values/README.md +11 -0
  49. package/test-fixtures/interop/openbadges-v3/with-allowed-values/expected.nq +25 -0
  50. package/test-fixtures/interop/openbadges-v3/with-allowed-values/frame.jsonld +22 -0
  51. package/test-fixtures/interop/openbadges-v3/with-allowed-values/input.jsonld +40 -0
  52. package/test-fixtures/interop/vp/single-vc-wrap/README.md +6 -0
  53. package/test-fixtures/interop/vp/single-vc-wrap/expected.nq +7 -0
  54. package/test-fixtures/interop/vp/single-vc-wrap/frame.jsonld +17 -0
  55. package/test-fixtures/interop/vp/single-vc-wrap/input.jsonld +27 -0
  56. package/test-fixtures/interop/w3c-vc-v1/permanent-resident-card/README.md +5 -0
  57. package/test-fixtures/interop/w3c-vc-v1/permanent-resident-card/expected.nq +13 -0
  58. package/test-fixtures/interop/w3c-vc-v1/permanent-resident-card/frame.jsonld +14 -0
  59. package/test-fixtures/interop/w3c-vc-v1/permanent-resident-card/input.jsonld +29 -0
  60. package/test_interop.mjs +184 -0
  61. package/test_interop_smoke.mjs +388 -0
  62. package/test_jsonld_crossverify.mjs +8 -2
  63. package/test_w3c_conformance.mjs +80 -7
  64. package/tools/regen_expected.mjs +108 -0
  65. package/w3c-baseline.json +881 -263
  66. package/w3c-denylist.json +6 -1
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env node
2
+ // Cross-stack BBS+ sign-verify smoke harness (Sprint 4 Phase D).
3
+ //
4
+ // Layered on top of the parity harness (test_interop.mjs): the parity gate
5
+ // ensures frame + canonize byte-equality between V2 native and jsonld.js;
6
+ // THIS gate ensures a full cryptographic round-trip across the two stacks.
7
+ //
8
+ // For each fixture × direction:
9
+ // 1. V2 native sign → @mattrglobal/jsonld-signatures-bbs verify
10
+ // 2. @mattrglobal/jsonld-signatures-bbs sign → V2 native verify
11
+ // Both directions must succeed (verified: true) for the fixture to "pass smoke".
12
+ //
13
+ // Usage:
14
+ // node test_interop_smoke.mjs --check (CI default; exits 1 on any
15
+ // non-allowlisted failure)
16
+ // node test_interop_smoke.mjs --report (local triage; prints diagnostic
17
+ // per failure, always exits 0)
18
+ //
19
+ // Fixture subset rationale:
20
+ // The corpus is 9 fixtures across 6 families. After clientffi#69 (Phase E
21
+ // canonize_dispatch fix — mirror of the Phase A frame_native hotfix), the
22
+ // smoke harness covers all 9: caller-registered contexts now thread
23
+ // through the V2 BBS+ sign / verify chain, so OB v3 / EBSI / VP fixtures
24
+ // that reference spec-sourced (non-bundled) contexts can now round-trip.
25
+ //
26
+ // Included (9):
27
+ // - w3c-vc-v1/permanent-resident-card — canonical W3C VC v1 + BBS+
28
+ // - idv2/full-disclosure — Nuggets identity-v2 (post-#56)
29
+ // - nuggets/identity-v1 — Nuggets identity-v1 (post-#63)
30
+ // - nuggets/kyb-v1 — Nuggets kyb-v1 (post-#65)
31
+ // - vp/single-vc-wrap — VP wrapping a VC (post-#66)
32
+ // - openbadges-v3/basic-achievement — OB v3 spec context
33
+ // - openbadges-v3/with-allowed-values — OB v3 spec context
34
+ // - ebsi/diploma-simple — EBSI essif spec context
35
+ // - ebsi/diploma-elm — EBSI ELM (V2-pinned, see #61)
36
+ //
37
+ // Hardcoded BBS+ keypair: `did:example:489398593#test`, the same keypair
38
+ // used by the existing test infrastructure (vc/js/test-data/keyPair.json,
39
+ // matching vc/rs/src/jsonld/signatures/bbs/data/sample_data.rs::KEY_PAIR).
40
+
41
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
42
+ import { resolve, dirname, join } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+ import { createRequire } from 'node:module';
45
+ import { ldSign, ldVerify } from './index.js';
46
+
47
+ const require = createRequire(import.meta.url);
48
+ const { Bls12381G2KeyPair } = require('@mattrglobal/bls12381-key-pair');
49
+ const { BbsBlsSignature2020 } = require('@mattrglobal/jsonld-signatures-bbs');
50
+ const jsigs = require('jsonld-signatures');
51
+ const { extendContextLoader, purposes } = jsigs;
52
+
53
+ const __dirname = dirname(fileURLToPath(import.meta.url));
54
+ const fixturesRoot = resolve(__dirname, 'test-fixtures', 'interop');
55
+
56
+ const args = process.argv.slice(2);
57
+ const checkMode = args.includes('--check');
58
+ const reportMode = args.includes('--report');
59
+ if (!checkMode && !reportMode) {
60
+ console.error('Usage: test_interop_smoke.mjs [--check | --report]');
61
+ process.exit(2);
62
+ }
63
+
64
+ const allowlistPath = resolve(__dirname, 'interop-smoke-allowlist.json');
65
+ const allowlist = existsSync(allowlistPath)
66
+ ? JSON.parse(readFileSync(allowlistPath, 'utf8'))
67
+ : { expect_fail: [] };
68
+ const expectFail = new Set((allowlist.expect_fail || []).map((e) => e.fixture));
69
+
70
+ // All corpus fixtures included in the smoke gate — see header rationale.
71
+ const SMOKE_FIXTURES = [
72
+ 'w3c-vc-v1/permanent-resident-card',
73
+ 'idv2/full-disclosure',
74
+ 'nuggets/identity-v1',
75
+ 'nuggets/kyb-v1',
76
+ 'vp/single-vc-wrap',
77
+ 'openbadges-v3/basic-achievement',
78
+ 'openbadges-v3/with-allowed-values',
79
+ 'ebsi/diploma-simple',
80
+ 'ebsi/diploma-elm',
81
+ ];
82
+
83
+ // BBS+ keypair — `did:example:489398593#test`. Single source of truth is
84
+ // vc/js/test-data/keyPair.json, which mirrors
85
+ // vc/rs/src/jsonld/signatures/bbs/data/sample_data.rs::KEY_PAIR. The on-disk
86
+ // file omits `type` (consumed by other tests that don't need it); we patch
87
+ // it in here so the BBS+ suites resolve the verification method correctly.
88
+ const KEYPAIR_PATH = resolve(__dirname, 'test-data', 'keyPair.json');
89
+ const KEYPAIR_JSON = {
90
+ ...JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8')),
91
+ type: 'Bls12381G2Key2020',
92
+ };
93
+
94
+ const CONTROLLER_DOC = {
95
+ '@context': 'https://w3id.org/security/v2',
96
+ id: KEYPAIR_JSON.controller,
97
+ assertionMethod: [KEYPAIR_JSON.id],
98
+ };
99
+
100
+ // Load offline interop contexts the same way test_interop.mjs does.
101
+ function loadOfflineContexts() {
102
+ const dir = join(fixturesRoot, '_contexts');
103
+ const out = {};
104
+ if (!existsSync(dir)) return out;
105
+ for (const f of readdirSync(dir)) {
106
+ if (!f.endsWith('.jsonld') && !f.endsWith('.json')) continue;
107
+ const doc = JSON.parse(readFileSync(join(dir, f), 'utf8'));
108
+ if (!doc.__url__) continue;
109
+ const url = doc.__url__;
110
+ const copy = JSON.parse(JSON.stringify(doc));
111
+ delete copy.__url__;
112
+ out[url] = copy;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ // Augment with the contexts the BBS+ suites resolve at sign/verify time:
118
+ // the keypair URL itself, the controller doc, and the JWS suite context the
119
+ // `@mattrglobal/jsonld-signatures-bbs` `getVerificationMethod` framing pulls
120
+ // in. `jsonld-signatures` already bundles `security/v1` + `security/v2`
121
+ // (via its `extendContextLoader`), so we don't need to re-add those.
122
+ function buildContexts(offline) {
123
+ const suiteContextPath = resolve(__dirname, 'test-data', 'suiteContext.json');
124
+ const suiteContext = JSON.parse(readFileSync(suiteContextPath, 'utf8'));
125
+ return {
126
+ ...offline,
127
+ [KEYPAIR_JSON.id]: KEYPAIR_JSON,
128
+ [KEYPAIR_JSON.controller]: CONTROLLER_DOC,
129
+ 'https://w3id.org/security/suites/jws-2020/v1': suiteContext,
130
+ };
131
+ }
132
+
133
+ function buildJsigsLoader(contextMap) {
134
+ const loader = (url) => {
135
+ const doc = contextMap[url];
136
+ if (doc) return { contextUrl: null, document: doc, documentUrl: url };
137
+ throw new Error(`Smoke harness: unbundled context requested: ${url}`);
138
+ };
139
+ return extendContextLoader(loader);
140
+ }
141
+
142
+ // Strip an existing proof if present (some fixtures may carry one). For the
143
+ // smoke harness we always sign fresh.
144
+ function stripProof(doc) {
145
+ const copy = JSON.parse(JSON.stringify(doc));
146
+ delete copy.proof;
147
+ return copy;
148
+ }
149
+
150
+ // All current smoke fixtures are VCs — no wrapper unwrapping needed; we
151
+ // just strip any incidental embedded proof before signing.
152
+ function unwrapForSigning(_fixtureId, doc) {
153
+ return stripProof(doc);
154
+ }
155
+
156
+ // Some inputs don't carry the BBS+ context — V2 ldSign auto-adds it; we
157
+ // ensure jsonld-signatures-bbs sees it the same way by injecting it before
158
+ // calling sign. (The suite would otherwise call its own `ensureSuiteContext`
159
+ // which mutates the input but produces an identical effect.)
160
+ function ensureBbsContext(doc) {
161
+ const BBS_URL = 'https://w3id.org/security/bbs/v1';
162
+ const ctx = doc['@context'];
163
+ if (!ctx) {
164
+ doc['@context'] = [BBS_URL];
165
+ return doc;
166
+ }
167
+ if (Array.isArray(ctx)) {
168
+ if (!ctx.includes(BBS_URL)) doc['@context'] = [...ctx, BBS_URL];
169
+ return doc;
170
+ }
171
+ // string or object
172
+ doc['@context'] = [ctx, BBS_URL];
173
+ return doc;
174
+ }
175
+
176
+ async function v2Sign(input, contexts) {
177
+ return await ldSign({
178
+ document: input,
179
+ keyPair: KEYPAIR_JSON,
180
+ contexts,
181
+ });
182
+ }
183
+
184
+ async function v2Verify(signedDoc, contexts) {
185
+ return await ldVerify({
186
+ document: signedDoc,
187
+ contexts,
188
+ });
189
+ }
190
+
191
+ async function mattrSign(input, contexts) {
192
+ const keyPair = await Bls12381G2KeyPair.from({
193
+ id: KEYPAIR_JSON.id,
194
+ controller: KEYPAIR_JSON.controller,
195
+ publicKeyBase58: KEYPAIR_JSON.publicKeyBase58,
196
+ privateKeyBase58: KEYPAIR_JSON.privateKeyBase58,
197
+ });
198
+ const suite = new BbsBlsSignature2020({ key: keyPair });
199
+ const documentLoader = buildJsigsLoader(contexts);
200
+ // Mutate-safe: ensureBbsContext is applied before suite.sign so that
201
+ // expansion has the same vocabulary V2 ldSign would have used.
202
+ const docToSign = ensureBbsContext(JSON.parse(JSON.stringify(input)));
203
+ return await jsigs.sign(docToSign, {
204
+ suite,
205
+ purpose: new purposes.AssertionProofPurpose(),
206
+ documentLoader,
207
+ });
208
+ }
209
+
210
+ async function mattrVerify(signedDoc, contexts) {
211
+ const documentLoader = buildJsigsLoader(contexts);
212
+ const result = await jsigs.verify(signedDoc, {
213
+ suite: new BbsBlsSignature2020(),
214
+ purpose: new purposes.AssertionProofPurpose(),
215
+ documentLoader,
216
+ });
217
+ return result;
218
+ }
219
+
220
+ function summariseError(err) {
221
+ if (!err) return 'unknown';
222
+ if (typeof err === 'string') return err;
223
+ if (err.message) return err.message;
224
+ if (err.errors && err.errors.length) {
225
+ return err.errors.map((e) => e.message || String(e)).join('; ');
226
+ }
227
+ return String(err);
228
+ }
229
+
230
+ async function runDirection(directionId, signFn, verifyFn, input, contexts) {
231
+ let signed;
232
+ try {
233
+ signed = await signFn(input, contexts);
234
+ } catch (err) {
235
+ return { direction: directionId, status: 'error', stage: 'sign', error: summariseError(err) };
236
+ }
237
+ let verifyResult;
238
+ try {
239
+ verifyResult = await verifyFn(signed, contexts);
240
+ } catch (err) {
241
+ return { direction: directionId, status: 'error', stage: 'verify', error: summariseError(err) };
242
+ }
243
+ if (verifyResult && verifyResult.verified === true) {
244
+ return { direction: directionId, status: 'pass' };
245
+ }
246
+ return {
247
+ direction: directionId,
248
+ status: 'fail',
249
+ stage: 'verify',
250
+ error: summariseError(verifyResult && verifyResult.error),
251
+ };
252
+ }
253
+
254
+ async function runFixture(fixture, contexts) {
255
+ const inputPath = join(fixture.dir, 'input.jsonld');
256
+ if (!existsSync(inputPath)) {
257
+ return { id: fixture.id, status: 'skip', reason: 'missing input.jsonld' };
258
+ }
259
+ const raw = JSON.parse(readFileSync(inputPath, 'utf8'));
260
+ const input = unwrapForSigning(fixture.id, raw);
261
+
262
+ const v2ToMattr = await runDirection(
263
+ 'v2->jsonld',
264
+ v2Sign,
265
+ mattrVerify,
266
+ input,
267
+ contexts,
268
+ );
269
+ const mattrToV2 = await runDirection(
270
+ 'jsonld->v2',
271
+ mattrSign,
272
+ v2Verify,
273
+ input,
274
+ contexts,
275
+ );
276
+
277
+ const overall =
278
+ v2ToMattr.status === 'pass' && mattrToV2.status === 'pass'
279
+ ? 'pass'
280
+ : 'fail';
281
+
282
+ return {
283
+ id: fixture.id,
284
+ status: overall,
285
+ directions: { v2ToMattr, mattrToV2 },
286
+ };
287
+ }
288
+
289
+ async function main() {
290
+ const offline = loadOfflineContexts();
291
+ const contexts = buildContexts(offline);
292
+
293
+ const fixtures = SMOKE_FIXTURES.map((id) => ({
294
+ id,
295
+ dir: join(fixturesRoot, ...id.split('/')),
296
+ }));
297
+
298
+ const results = [];
299
+ for (const fx of fixtures) {
300
+ process.stderr.write(` smoke ${fx.id}…\n`);
301
+ results.push(await runFixture(fx, contexts));
302
+ }
303
+
304
+ const pass = results.filter((r) => r.status === 'pass').length;
305
+ const fail = results.filter((r) => r.status === 'fail').length;
306
+ const skip = results.filter((r) => r.status === 'skip').length;
307
+
308
+ console.log(`\n========== INTEROP SMOKE ==========`);
309
+ console.log(`pass: ${pass}`);
310
+ console.log(`fail: ${fail}`);
311
+ console.log(`skip: ${skip}`);
312
+
313
+ // Per-fixture results table
314
+ console.log(`\nfixture | v2->jsonld | jsonld->v2 | overall`);
315
+ console.log(`-------------------------------------------+------------+------------+--------`);
316
+ for (const r of results) {
317
+ const v2j = r.directions ? r.directions.v2ToMattr.status : '-';
318
+ const jv2 = r.directions ? r.directions.mattrToV2.status : '-';
319
+ const id = r.id.padEnd(42);
320
+ console.log(`${id} | ${v2j.padEnd(10)} | ${jv2.padEnd(10)} | ${r.status}`);
321
+ }
322
+
323
+ const regressions = [];
324
+ const improvements = [];
325
+ const expectedFails = [];
326
+ const skips = [];
327
+ for (const r of results) {
328
+ if (r.status === 'pass') {
329
+ if (expectFail.has(r.id)) improvements.push(r);
330
+ } else if (r.status === 'fail') {
331
+ if (expectFail.has(r.id)) expectedFails.push(r);
332
+ else regressions.push(r);
333
+ } else if (r.status === 'skip') {
334
+ skips.push(r);
335
+ }
336
+ }
337
+
338
+ if (reportMode) {
339
+ for (const r of [...regressions, ...expectedFails]) {
340
+ console.log(`\n--- ${r.id} (${r.status}) ---`);
341
+ if (r.directions) {
342
+ for (const d of [r.directions.v2ToMattr, r.directions.mattrToV2]) {
343
+ if (d.status === 'pass') continue;
344
+ console.log(` [${d.direction}] ${d.status} @ ${d.stage}: ${d.error}`);
345
+ }
346
+ }
347
+ }
348
+ console.log(
349
+ `\n[--report] ${regressions.length} divergence(s); ${expectedFails.length} expected-fail; ${improvements.length} improvement(s); ${skips.length} skip(s).`,
350
+ );
351
+ process.exit(0);
352
+ }
353
+
354
+ // --check mode
355
+ if (improvements.length) {
356
+ console.log(`\n${improvements.length} fixture(s) newly passing — please ratchet interop-smoke-allowlist.json:`);
357
+ for (const r of improvements) console.log(` + ${r.id}`);
358
+ process.exit(1);
359
+ }
360
+ if (regressions.length) {
361
+ console.log(`\n${regressions.length} REGRESSION(S):`);
362
+ for (const r of regressions) {
363
+ console.log(` - ${r.id}`);
364
+ if (r.directions) {
365
+ for (const d of [r.directions.v2ToMattr, r.directions.mattrToV2]) {
366
+ if (d.status === 'pass') continue;
367
+ console.log(` [${d.direction}] ${d.status} @ ${d.stage}: ${d.error}`);
368
+ }
369
+ }
370
+ }
371
+ process.exit(1);
372
+ }
373
+ if (skips.length) {
374
+ console.log(`\n${skips.length} skipped fixture(s):`);
375
+ for (const r of skips) console.log(` ! ${r.id}: ${r.reason}`);
376
+ process.exit(1);
377
+ }
378
+ console.log(
379
+ `\nAll smoke fixtures round-trip cleanly.${
380
+ expectedFails.length ? ' (' + expectedFails.length + ' tracked expect-fail.)' : ''
381
+ }`,
382
+ );
383
+ }
384
+
385
+ main().catch((e) => {
386
+ console.error(e);
387
+ process.exit(1);
388
+ });
@@ -135,7 +135,10 @@ test('cross-verify: flatten(inputDocument, null) matches JS reference', async ()
135
135
  const napiResult = await proc.flatten(inputDocument, null);
136
136
  const jsResult = await jsonld.flatten(inputDocument, null, { documentLoader });
137
137
 
138
- assert.deepStrictEqual(napiResult, jsResult);
138
+ // Flatten without compaction returns an unordered set of node objects
139
+ // (each keyed by @id). Sort by @id before comparing.
140
+ const sortById = arr => [...arr].sort((a, b) => String(a['@id'] || '').localeCompare(String(b['@id'] || '')));
141
+ assert.deepStrictEqual(sortById(napiResult), sortById(jsResult));
139
142
  });
140
143
 
141
144
 
@@ -171,7 +174,10 @@ test('cross-verify: toRDF(inputDocument, n-quads) matches JS reference', async (
171
174
  const napiResult = await proc.toRDF(inputDocument, { format: 'application/n-quads' });
172
175
  const jsResult = await jsonld.toRDF(inputDocument, { documentLoader, format: 'application/n-quads' });
173
176
 
174
- assert.strictEqual(napiResult, jsResult);
177
+ // toRDF triple order is implementation-defined (RDFC-1.0 normalises it later).
178
+ // Compare as a set: split into lines, drop empties, sort.
179
+ const norm = s => s.split('\n').filter(l => l.length).sort().join('\n');
180
+ assert.strictEqual(norm(napiResult), norm(jsResult));
175
181
  });
176
182
 
177
183
 
@@ -32,6 +32,7 @@ const updateBaseline = flag('--update');
32
32
 
33
33
  const TESTS_ROOT = resolve(__dirname, 'tmp-w3c-tests');
34
34
  const JLD_TESTS = join(TESTS_ROOT, 'json-ld-api', 'tests');
35
+ const JLD_FRAMING_TESTS = join(TESTS_ROOT, 'json-ld-framing', 'tests');
35
36
  const RDFC_TESTS = join(TESTS_ROOT, 'rdf-canon', 'tests');
36
37
  const JLD_BASE = 'https://w3c.github.io/json-ld-api/tests/';
37
38
  const BASELINE_PATH = join(__dirname, 'w3c-baseline.json');
@@ -70,6 +71,7 @@ function preloadJldContexts() {
70
71
  }
71
72
  }
72
73
  walk(JLD_TESTS, '');
74
+ if (existsSync(JLD_FRAMING_TESTS)) walk(JLD_FRAMING_TESTS, '');
73
75
  return ctxs;
74
76
  }
75
77
 
@@ -84,6 +86,25 @@ const normNQuads = (s) => typeof s === 'string'
84
86
  ? s.split('\n').map(l => l.trim()).filter(Boolean).sort().join('\n')
85
87
  : s;
86
88
 
89
+ // W3C toRdf expected fixtures contain post-URDNA2015 blank-node labels
90
+ // (e.g. `_:c14n0`), but the spec itself defines toRdf as producing
91
+ // pre-canonical labels (e.g. `_:b0`). jsonld.js exhibits the same
92
+ // pre-canonical output. To compare semantically we run URDNA2015 on both
93
+ // sides before string-matching. Falls back to normNQuads if either side
94
+ // can't be canonicalized (so a bug in the processor still surfaces).
95
+ const canonProc = new JsonLd();
96
+ async function nquadsEqual(got, expected) {
97
+ if (typeof got !== 'string' || typeof expected !== 'string') return false;
98
+ try {
99
+ const opts = { algorithm: 'URDNA2015', inputFormat: 'application/n-quads', format: 'application/n-quads' };
100
+ const a = await canonProc.canonize(got, opts);
101
+ const b = await canonProc.canonize(expected, opts);
102
+ return a === b;
103
+ } catch {
104
+ return normNQuads(got) === normNQuads(expected);
105
+ }
106
+ }
107
+
87
108
  const DENYLIST_PATH = join(__dirname, 'w3c-denylist.json');
88
109
  let DENYLIST = {};
89
110
  if (existsSync(DENYLIST_PATH)) {
@@ -144,10 +165,18 @@ async function runJld(algo, methodName) {
144
165
  const opt = entry.option || {};
145
166
  const callOpts = { base: opt.base || inputUrl };
146
167
  if (opt.processingMode) callOpts.processingMode = opt.processingMode;
168
+ else if (opt.specVersion) callOpts.processingMode = opt.specVersion;
147
169
  if (opt.expandContext) {
148
170
  const ctxUrl = JLD_BASE + opt.expandContext;
149
171
  callOpts.expandContext = contexts[ctxUrl] || ctxUrl;
150
172
  }
173
+ // toRdf-specific options from W3C test entries
174
+ if (opt.rdfDirection) callOpts.rdfDirection = opt.rdfDirection;
175
+ if (opt.produceGeneralizedRdf != null) callOpts.produceGeneralizedRdf = opt.produceGeneralizedRdf;
176
+ if (opt.compactArrays != null) callOpts.compactArrays = opt.compactArrays;
177
+ // fromRDF-specific options
178
+ if (opt.useNativeTypes != null) callOpts.useNativeTypes = opt.useNativeTypes;
179
+ if (opt.useRdfType != null) callOpts.useRdfType = opt.useRdfType;
151
180
 
152
181
  try {
153
182
  const input = methodName === 'fromRDF' ? loadText(inputPath) : loadJson(inputPath);
@@ -172,7 +201,7 @@ async function runJld(algo, methodName) {
172
201
  const expected = methodName === 'toRDF' ? loadText(expectPath) : loadJson(expectPath);
173
202
 
174
203
  const ok = methodName === 'toRDF'
175
- ? normNQuads(napiResult) === normNQuads(expected)
204
+ ? await nquadsEqual(napiResult, expected)
176
205
  : jsonEqual(napiResult, expected);
177
206
  record(algo, id, ok ? 'pass' : 'fail');
178
207
  } catch (e) {
@@ -229,20 +258,64 @@ async function runOnce(algo) {
229
258
  else if (algo === 'flatten') await runJld('flatten', 'flatten');
230
259
  else if (algo === 'toRdf') await runJld('toRdf', 'toRDF');
231
260
  else if (algo === 'fromRdf') await runJld('fromRdf', 'fromRDF');
261
+ else if (algo === 'frame') await runFraming();
232
262
  }
233
263
 
264
+ async function runFraming() {
265
+ const manifestPath = join(JLD_FRAMING_TESTS, 'frame-manifest.jsonld');
266
+ if (!existsSync(manifestPath)) return;
267
+ const manifest = loadJson(manifestPath);
268
+ const contexts = preloadJldContexts();
269
+ const proc = new JsonLd({ contexts });
270
+ let seen = 0;
271
+ for (const entry of manifest.sequence) {
272
+ const types = Array.isArray(entry['@type']) ? entry['@type'] : [entry['@type']];
273
+ if (!types.includes('jld:PositiveEvaluationTest') && !types.includes('jld:NegativeEvaluationTest')) continue;
274
+ const id = entry['@id'];
275
+ if (isDenied('frame', id)) continue;
276
+ if (!isInCheckScope('frame', id)) continue;
277
+ seen += 1;
278
+ if (seen % PROGRESS_EVERY === 0) process.stderr.write(` frame: ${seen} tests processed\n`);
279
+ if (!entry.input || !entry.frame) { record('frame', id, 'err'); continue; }
280
+ const inputPath = join(JLD_FRAMING_TESTS, entry.input);
281
+ const framePath = join(JLD_FRAMING_TESTS, entry.frame);
282
+ const expectPath = entry.expect ? join(JLD_FRAMING_TESTS, entry.expect) : null;
283
+ if (!existsSync(inputPath) || !existsSync(framePath)) { record('frame', id, 'err'); continue; }
284
+ const opt = entry.option || {};
285
+ const inputUrl = 'https://w3c.github.io/json-ld-framing/tests/' + entry.input;
286
+ const callOpts = { base: opt.base || inputUrl };
287
+ if (opt.processingMode) callOpts.processingMode = opt.processingMode;
288
+ else if (opt.specVersion) callOpts.processingMode = opt.specVersion;
289
+ if (opt.omitGraph != null) callOpts.omitGraph = opt.omitGraph;
290
+ if (opt.requireAll != null) callOpts.requireAll = opt.requireAll;
291
+ try {
292
+ const input = loadJson(inputPath);
293
+ const frame = loadJson(framePath);
294
+ const napiResult = await withTimeout(proc.frame(input, frame, callOpts), id);
295
+ if (!expectPath || !existsSync(expectPath)) { record('frame', id, 'err'); continue; }
296
+ const expected = loadJson(expectPath);
297
+ record('frame', id, jsonEqual(napiResult, expected) ? 'pass' : 'fail');
298
+ } catch (e) {
299
+ const isTimeout = e && /^timeout:/.test(e.message || '');
300
+ record('frame', id, isTimeout ? 'err' : 'err');
301
+ }
302
+ }
303
+ }
304
+
305
+
234
306
  async function main() {
235
307
  if (!existsSync(JLD_TESTS) || !existsSync(RDFC_TESTS)) {
236
308
  console.error('W3C test suites not found at ' + TESTS_ROOT);
237
309
  console.error('Run: vc/js/scripts/fetch-w3c-tests.sh');
238
310
  process.exit(2);
239
311
  }
240
- // flatten and toRdf are not in the default run because clientffi has
241
- // Rust-level deadlocks in those algos. Pass --only flatten or --only toRdf
242
- // to opt in when investigating. They'll move into the default list once
243
- // their hangs are fixed.
244
- const algos = ['canonize', 'fromRdf', 'expand', 'compact'];
245
- const allAlgos = ['expand', 'compact', 'flatten', 'toRdf', 'fromRdf', 'canonize'];
312
+ // flatten and toRdf are gated by V2: the v1 path has Rust-level deadlocks
313
+ // (sync block_on bridging into tokio::sync locks in the document loader and
314
+ // node_map). The v2 path (jsonld_v2::flatten_v2 / to_nquads_v2) uses a
315
+ // pre-resolved HashMap loader with no tokio sync primitives and runs the
316
+ // suite without hangs, so it is included in the default list.
317
+ const algos = ['canonize', 'fromRdf', 'expand', 'compact', 'flatten', 'toRdf', 'frame'];
318
+ const allAlgos = ['expand', 'compact', 'flatten', 'toRdf', 'fromRdf', 'canonize', 'frame'];
246
319
  if (onlyArg && !allAlgos.includes(onlyArg)) {
247
320
  console.error(`Invalid --only value: ${onlyArg}. Expected one of: ${allAlgos.join(', ')}`);
248
321
  process.exit(2);
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ // Regenerate `expected.nq` for one or more interop fixtures using jsonld.js
3
+ // (the third-party reference). The harness in `test_interop.mjs` then asserts
4
+ // V2 native produces byte-equal output. Never hand-edit `expected.nq`.
5
+ //
6
+ // Usage:
7
+ // node tools/regen_expected.mjs <fixture-dir> [<fixture-dir>...]
8
+ // node tools/regen_expected.mjs --all
9
+ //
10
+ // Examples:
11
+ // node tools/regen_expected.mjs test-fixtures/interop/w3c-vc-v1/permanent-resident-card
12
+ // node tools/regen_expected.mjs --all
13
+
14
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
15
+ import { resolve, dirname, join, relative } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import jsonld from 'jsonld';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const repoRoot = resolve(__dirname, '..');
21
+ const fixturesRoot = resolve(repoRoot, 'test-fixtures', 'interop');
22
+
23
+ const args = process.argv.slice(2);
24
+ if (args.length === 0) {
25
+ console.error('Usage: regen_expected.mjs <fixture-dir>... | --all');
26
+ process.exit(2);
27
+ }
28
+
29
+ // Build the offline document loader from the fixtures' contexts. We don't
30
+ // fetch anything from the network — every context referenced by a fixture
31
+ // must resolve from the bundled `vc/js/test-fixtures/interop/_contexts/` dir
32
+ // or from inline contexts (i.e. embedded `@context` objects).
33
+ const contextDir = resolve(fixturesRoot, '_contexts');
34
+ const offlineContexts = {};
35
+ if (existsSync(contextDir)) {
36
+ for (const f of readdirSync(contextDir)) {
37
+ if (!f.endsWith('.jsonld') && !f.endsWith('.json')) continue;
38
+ const doc = JSON.parse(readFileSync(join(contextDir, f), 'utf8'));
39
+ const url = doc.__url__;
40
+ if (!url) {
41
+ console.error(`context ${f} missing __url__ marker`);
42
+ process.exit(2);
43
+ }
44
+ delete doc.__url__;
45
+ offlineContexts[url] = doc;
46
+ }
47
+ }
48
+
49
+ const documentLoader = async (url) => {
50
+ if (!offlineContexts[url]) {
51
+ throw new Error(`offline loader: unknown context ${url}`);
52
+ }
53
+ return { contextUrl: null, document: offlineContexts[url], documentUrl: url };
54
+ };
55
+
56
+ async function regen(fixtureDir) {
57
+ const inputPath = join(fixtureDir, 'input.jsonld');
58
+ const framePath = join(fixtureDir, 'frame.jsonld');
59
+ const expectedPath = join(fixtureDir, 'expected.nq');
60
+ if (!existsSync(inputPath) || !existsSync(framePath)) {
61
+ console.error(`skipping ${fixtureDir}: missing input.jsonld or frame.jsonld`);
62
+ return false;
63
+ }
64
+ const input = JSON.parse(readFileSync(inputPath, 'utf8'));
65
+ const frame = JSON.parse(readFileSync(framePath, 'utf8'));
66
+ const framed = await jsonld.frame(input, frame, { documentLoader });
67
+ const nquads = await jsonld.canonize(framed, {
68
+ algorithm: 'URDNA2015',
69
+ format: 'application/n-quads',
70
+ documentLoader,
71
+ });
72
+ // Normalize: LF, trailing newline, no BOM
73
+ const normalized = nquads.endsWith('\n') ? nquads : nquads + '\n';
74
+ writeFileSync(expectedPath, normalized, { encoding: 'utf8' });
75
+ console.log(`wrote ${relative(repoRoot, expectedPath)} (${normalized.length} bytes)`);
76
+ return true;
77
+ }
78
+
79
+ function discoverFixtureDirs(root) {
80
+ const out = [];
81
+ for (const family of readdirSync(root)) {
82
+ if (family.startsWith('_')) continue; // skip _contexts/
83
+ const familyDir = join(root, family);
84
+ if (!statSync(familyDir).isDirectory()) continue;
85
+ for (const name of readdirSync(familyDir)) {
86
+ const fixtureDir = join(familyDir, name);
87
+ if (!statSync(fixtureDir).isDirectory()) continue;
88
+ out.push(fixtureDir);
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ const targets = args[0] === '--all'
95
+ ? discoverFixtureDirs(fixturesRoot)
96
+ : args.map((a) => resolve(a));
97
+
98
+ let ok = 0, skipped = 0, failed = 0;
99
+ for (const dir of targets) {
100
+ try {
101
+ if (await regen(dir)) ok += 1; else skipped += 1;
102
+ } catch (err) {
103
+ failed += 1;
104
+ console.error(`failed ${dir}: ${err && err.message ? err.message : err}`);
105
+ }
106
+ }
107
+ console.log(`\nregenerated: ${ok}; skipped: ${skipped}; failed: ${failed}`);
108
+ process.exit(failed > 0 ? 1 : 0);