@nuggetslife/vc 0.0.29 → 0.1.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.
@@ -0,0 +1,419 @@
1
+ // W3C JSON-LD 1.1 API + RDF Dataset Canonicalization (RDFC-1.0) conformance
2
+ // runner for the clientffi NAPI JsonLd binding.
3
+ //
4
+ // Two modes:
5
+ // node test_w3c_conformance.mjs --check (CI default)
6
+ // run all suites, compare against w3c-baseline.json, exit non-zero
7
+ // if any test that previously passed now fails (a regression).
8
+ //
9
+ // node test_w3c_conformance.mjs --update
10
+ // run all suites, overwrite w3c-baseline.json with the current
11
+ // pass set. Use when intentionally widening conformance.
12
+ //
13
+ // Without flags, runs and prints a summary table without baseline comparison.
14
+ //
15
+ // Other flags:
16
+ // --only <algo> limit to one of: expand|compact|flatten|toRdf|fromRdf|canonize
17
+ // --verbose print PASS/FAIL/ERR per test
18
+
19
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
20
+ import { resolve, dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { JsonLd } from './index.js';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const args = process.argv.slice(2);
26
+ const flag = (n) => args.includes(n);
27
+ const valOf = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
28
+ const verbose = flag('--verbose');
29
+ const onlyArg = valOf('--only');
30
+ const checkBaseline = flag('--check');
31
+ const updateBaseline = flag('--update');
32
+
33
+ const TESTS_ROOT = resolve(__dirname, 'tmp-w3c-tests');
34
+ const JLD_TESTS = join(TESTS_ROOT, 'json-ld-api', 'tests');
35
+ const JLD_FRAMING_TESTS = join(TESTS_ROOT, 'json-ld-framing', 'tests');
36
+ const RDFC_TESTS = join(TESTS_ROOT, 'rdf-canon', 'tests');
37
+ const JLD_BASE = 'https://w3c.github.io/json-ld-api/tests/';
38
+ const BASELINE_PATH = join(__dirname, 'w3c-baseline.json');
39
+
40
+ // Per-test wall-clock cap. clientffi has known hangs in a few hot paths;
41
+ // without this they freeze the runner.
42
+ const TIMEOUT_MS = 30000;
43
+ const PROGRESS_EVERY = 25; // emit a heartbeat every N tests so CI's
44
+ // no_output_timeout doesn't kill the runner
45
+
46
+ function withTimeout(promiseOrValue, label) {
47
+ // proc.fromRDF returns synchronously; everything else is async.
48
+ const p = Promise.resolve(promiseOrValue);
49
+ let t;
50
+ const timeoutPromise = new Promise((_, reject) => {
51
+ t = setTimeout(() => reject(new Error('timeout: ' + label)), TIMEOUT_MS);
52
+ });
53
+ // If the main op settles first, the timeout-rejection still fires later;
54
+ // consume that specific rejection rather than suppressing process-wide.
55
+ timeoutPromise.catch(() => {});
56
+ return Promise.race([p.finally(() => clearTimeout(t)), timeoutPromise]);
57
+ }
58
+
59
+ const loadJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
60
+ const loadText = (p) => readFileSync(p, 'utf8');
61
+
62
+ function preloadJldContexts() {
63
+ const ctxs = {};
64
+ function walk(dir, prefix) {
65
+ for (const name of readdirSync(dir)) {
66
+ const full = join(dir, name);
67
+ if (statSync(full).isDirectory()) walk(full, prefix + name + '/');
68
+ else if (name.endsWith('.jsonld') || name.endsWith('.json')) {
69
+ try { ctxs[JLD_BASE + prefix + name] = loadJson(full); } catch {}
70
+ }
71
+ }
72
+ }
73
+ walk(JLD_TESTS, '');
74
+ if (existsSync(JLD_FRAMING_TESTS)) walk(JLD_FRAMING_TESTS, '');
75
+ return ctxs;
76
+ }
77
+
78
+ function canonicalStringify(v) {
79
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
80
+ if (Array.isArray(v)) return '[' + v.map(canonicalStringify).join(',') + ']';
81
+ const keys = Object.keys(v).sort();
82
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k])).join(',') + '}';
83
+ }
84
+ const jsonEqual = (a, b) => canonicalStringify(a) === canonicalStringify(b);
85
+ const normNQuads = (s) => typeof s === 'string'
86
+ ? s.split('\n').map(l => l.trim()).filter(Boolean).sort().join('\n')
87
+ : s;
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
+
108
+ const DENYLIST_PATH = join(__dirname, 'w3c-denylist.json');
109
+ let DENYLIST = {};
110
+ if (existsSync(DENYLIST_PATH)) {
111
+ DENYLIST = loadJson(DENYLIST_PATH);
112
+ }
113
+ const isDenied = (algo, id) => (DENYLIST[algo] || []).includes(id);
114
+
115
+ // In --check mode, restrict the run to baseline-passing tests only — the
116
+ // gate's purpose is regression detection, not full re-evaluation. Cuts
117
+ // CI runtime ~70% (skips ~450 baseline-failing tests).
118
+ let CHECK_ALLOW = null;
119
+ if (checkBaseline && existsSync(BASELINE_PATH)) {
120
+ const b = loadJson(BASELINE_PATH);
121
+ CHECK_ALLOW = {};
122
+ for (const [algo, r] of Object.entries(b)) {
123
+ CHECK_ALLOW[algo] = new Set(r.passing || []);
124
+ }
125
+ }
126
+ const isInCheckScope = (algo, id) =>
127
+ !CHECK_ALLOW || (CHECK_ALLOW[algo] && CHECK_ALLOW[algo].has(id));
128
+
129
+ const RESULTS = {}; // { algo: { passing: Set, failing: Set, erroring: Set } }
130
+ function record(algo, id, status) {
131
+ const r = RESULTS[algo] ||= { passing: new Set(), failing: new Set(), erroring: new Set() };
132
+ if (status === 'pass') r.passing.add(id);
133
+ else if (status === 'fail') r.failing.add(id);
134
+ else r.erroring.add(id);
135
+ if (verbose) {
136
+ const tag = { pass: 'PASS', fail: 'FAIL', err: 'ERR ' }[status];
137
+ process.stderr.write(`[${tag}] ${algo} ${id}\n`);
138
+ }
139
+ }
140
+
141
+ async function runJld(algo, methodName) {
142
+ const manifestPath = join(JLD_TESTS, `${algo}-manifest.jsonld`);
143
+ if (!existsSync(manifestPath)) return;
144
+ const manifest = loadJson(manifestPath);
145
+ const contexts = preloadJldContexts();
146
+ const proc = new JsonLd({ contexts });
147
+ let seen = 0;
148
+
149
+ for (const entry of manifest.sequence) {
150
+ const types = Array.isArray(entry['@type']) ? entry['@type'] : [entry['@type']];
151
+ const isPositive = types.includes('jld:PositiveEvaluationTest');
152
+ const isNegative = types.includes('jld:NegativeEvaluationTest');
153
+ const id = entry['@id'];
154
+ if (!isPositive && !isNegative) continue;
155
+ if (isDenied(algo, id)) continue;
156
+ if (!isInCheckScope(algo, id)) continue;
157
+ seen += 1;
158
+ if (seen % PROGRESS_EVERY === 0) {
159
+ process.stderr.write(` ${algo}: ${seen} tests processed\n`);
160
+ }
161
+ const inputUrl = JLD_BASE + entry.input;
162
+ const inputPath = join(JLD_TESTS, entry.input);
163
+ if (!existsSync(inputPath)) { record(algo, id, 'err'); continue; }
164
+
165
+ const opt = entry.option || {};
166
+ const callOpts = { base: opt.base || inputUrl };
167
+ if (opt.processingMode) callOpts.processingMode = opt.processingMode;
168
+ else if (opt.specVersion) callOpts.processingMode = opt.specVersion;
169
+ if (opt.expandContext) {
170
+ const ctxUrl = JLD_BASE + opt.expandContext;
171
+ callOpts.expandContext = contexts[ctxUrl] || ctxUrl;
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;
180
+
181
+ try {
182
+ const input = methodName === 'fromRDF' ? loadText(inputPath) : loadJson(inputPath);
183
+ let napiResult;
184
+ if (methodName === 'expand') napiResult = await withTimeout(proc.expand(input, callOpts), id);
185
+ else if (methodName === 'compact') {
186
+ const ctxUrl = JLD_BASE + entry.context;
187
+ const ctx = contexts[ctxUrl] || ctxUrl;
188
+ napiResult = await withTimeout(proc.compact(input, ctx, callOpts), id);
189
+ } else if (methodName === 'flatten') {
190
+ const ctxUrl = entry.context ? (JLD_BASE + entry.context) : null;
191
+ const ctx = ctxUrl ? (contexts[ctxUrl] || ctxUrl) : null;
192
+ napiResult = await withTimeout(proc.flatten(input, ctx, callOpts), id);
193
+ } else if (methodName === 'toRDF') {
194
+ napiResult = await withTimeout(proc.toRDF(input, { ...callOpts, format: 'application/n-quads' }), id);
195
+ } else if (methodName === 'fromRDF') napiResult = await withTimeout(proc.fromRDF(input, callOpts), id);
196
+
197
+ if (isNegative) { record(algo, id, 'fail'); continue; }
198
+
199
+ const expectPath = join(JLD_TESTS, entry.expect);
200
+ if (!existsSync(expectPath)) { record(algo, id, 'err'); continue; }
201
+ const expected = methodName === 'toRDF' ? loadText(expectPath) : loadJson(expectPath);
202
+
203
+ const ok = methodName === 'toRDF'
204
+ ? await nquadsEqual(napiResult, expected)
205
+ : jsonEqual(napiResult, expected);
206
+ record(algo, id, ok ? 'pass' : 'fail');
207
+ } catch (e) {
208
+ // Negative tests expect a processor error; only count as pass if it
209
+ // genuinely came from the processor, not from a timeout or harness bug.
210
+ const isTimeout = e && /^timeout:/.test(e.message || '');
211
+ record(algo, id, isNegative && !isTimeout ? 'pass' : 'err');
212
+ }
213
+ }
214
+ }
215
+
216
+ async function runRdfc() {
217
+ const manifestPath = join(RDFC_TESTS, 'manifest.jsonld');
218
+ if (!existsSync(manifestPath)) return;
219
+ const manifest = loadJson(manifestPath);
220
+ const algo = 'canonize';
221
+ const proc = new JsonLd();
222
+ let seen = 0;
223
+
224
+ for (const entry of manifest.entries) {
225
+ if (entry.type !== 'rdfc:RDFC10EvalTest') continue;
226
+ const id = entry.id;
227
+ if (isDenied('canonize', id)) continue;
228
+ if (!isInCheckScope('canonize', id)) continue;
229
+ seen += 1;
230
+ if (seen % PROGRESS_EVERY === 0) {
231
+ process.stderr.write(` canonize: ${seen} tests processed\n`);
232
+ }
233
+ const inputPath = join(RDFC_TESTS, entry.action);
234
+ const expectedPath = join(RDFC_TESTS, entry.result);
235
+ if (!existsSync(inputPath) || !existsSync(expectedPath)) { record(algo, id, 'err'); continue; }
236
+ try {
237
+ const input = loadText(inputPath);
238
+ const expected = loadText(expectedPath);
239
+ const opts = {
240
+ inputFormat: 'application/n-quads',
241
+ format: 'application/n-quads',
242
+ algorithm: 'RDFC-1.0',
243
+ };
244
+ if (entry.hashAlgorithm) opts.hashAlgorithm = entry.hashAlgorithm;
245
+ const got = await withTimeout(proc.canonize(input, opts), id);
246
+ record(algo, id, got === expected ? 'pass' : 'fail');
247
+ } catch (e) {
248
+ record(algo, id, 'err');
249
+ }
250
+ }
251
+ }
252
+
253
+ async function runOnce(algo) {
254
+ process.stderr.write(`Running ${algo}...\n`);
255
+ if (algo === 'canonize') await runRdfc();
256
+ else if (algo === 'expand') await runJld('expand', 'expand');
257
+ else if (algo === 'compact') await runJld('compact', 'compact');
258
+ else if (algo === 'flatten') await runJld('flatten', 'flatten');
259
+ else if (algo === 'toRdf') await runJld('toRdf', 'toRDF');
260
+ else if (algo === 'fromRdf') await runJld('fromRdf', 'fromRDF');
261
+ else if (algo === 'frame') await runFraming();
262
+ }
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
+
306
+ async function main() {
307
+ if (!existsSync(JLD_TESTS) || !existsSync(RDFC_TESTS)) {
308
+ console.error('W3C test suites not found at ' + TESTS_ROOT);
309
+ console.error('Run: vc/js/scripts/fetch-w3c-tests.sh');
310
+ process.exit(2);
311
+ }
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'];
319
+ if (onlyArg && !allAlgos.includes(onlyArg)) {
320
+ console.error(`Invalid --only value: ${onlyArg}. Expected one of: ${allAlgos.join(', ')}`);
321
+ process.exit(2);
322
+ }
323
+ const filtered = onlyArg ? [onlyArg] : algos;
324
+
325
+ // --runs N: run each algo N times and intersect the passing sets so
326
+ // flaky tests don't end up in the baseline. Default 1.
327
+ // Note: slow algos (expand, compact) hang on N>1 because clientffi's
328
+ // NAPI worker pool fills with hung threads from earlier passes. Use
329
+ // --runs 3 only for fast algos like canonize/fromRdf.
330
+ const runsArg = valOf('--runs');
331
+ const repeats = runsArg == null ? 1 : Number.parseInt(runsArg, 10);
332
+ if (!Number.isInteger(repeats) || repeats <= 0 || String(repeats) !== runsArg) {
333
+ if (runsArg != null) {
334
+ console.error(`Invalid value for --runs: ${runsArg}. Expected a positive integer.`);
335
+ process.exit(2);
336
+ }
337
+ }
338
+ const passingPerRun = []; // array of { algo: Set<id> }, length = repeats
339
+
340
+ for (let r = 0; r < repeats; r++) {
341
+ if (repeats > 1) process.stderr.write(`-- pass ${r + 1}/${repeats} --\n`);
342
+ // Fresh RESULTS for this pass
343
+ for (const k of Object.keys(RESULTS)) delete RESULTS[k];
344
+ for (const algo of filtered) await runOnce(algo);
345
+ const snapshot = {};
346
+ for (const [algo, r2] of Object.entries(RESULTS)) snapshot[algo] = new Set(r2.passing);
347
+ passingPerRun.push(snapshot);
348
+ }
349
+
350
+ // Build current state. For --update with repeats=3, "passing" is the
351
+ // intersection across all runs. For --check (single run), use the last run.
352
+ const current = {};
353
+ for (const [algo, r] of Object.entries(RESULTS)) {
354
+ let passing;
355
+ if (repeats > 1) {
356
+ passing = [...passingPerRun[0][algo] || []].filter(id =>
357
+ passingPerRun.every(snap => snap[algo]?.has(id))
358
+ );
359
+ } else {
360
+ passing = [...r.passing];
361
+ }
362
+ current[algo] = {
363
+ passing: passing.sort(),
364
+ failing: [...r.failing].sort(),
365
+ erroring: [...r.erroring].sort(),
366
+ };
367
+ }
368
+
369
+ // Print summary
370
+ console.log('\n========== W3C CONFORMANCE ==========');
371
+ console.log(['Algo'.padEnd(10), 'Total'.padEnd(8), 'Pass'.padEnd(8), 'Fail'.padEnd(8), 'Error'.padEnd(8), 'Pass%'].join(''));
372
+ for (const [algo, r] of Object.entries(current)) {
373
+ const total = r.passing.length + r.failing.length + r.erroring.length;
374
+ const pct = total ? ((r.passing.length / total) * 100).toFixed(1) + '%' : '-';
375
+ console.log([algo.padEnd(10), String(total).padEnd(8), String(r.passing.length).padEnd(8), String(r.failing.length).padEnd(8), String(r.erroring.length).padEnd(8), pct].join(''));
376
+ }
377
+
378
+ if (updateBaseline) {
379
+ // Merge with existing baseline so single-algo runs accumulate.
380
+ const merged = existsSync(BASELINE_PATH) ? loadJson(BASELINE_PATH) : {};
381
+ for (const [algo, r] of Object.entries(current)) merged[algo] = r;
382
+ writeFileSync(BASELINE_PATH, JSON.stringify(merged, null, 2) + '\n');
383
+ console.log(`\nBaseline ${onlyArg ? 'updated for ' + onlyArg : 'written'} at ${BASELINE_PATH}`);
384
+ return;
385
+ }
386
+
387
+ if (checkBaseline) {
388
+ if (!existsSync(BASELINE_PATH)) {
389
+ console.error(`\nNo baseline at ${BASELINE_PATH}. Run with --update to create one.`);
390
+ process.exit(2);
391
+ }
392
+ const baseline = loadJson(BASELINE_PATH);
393
+ const regressions = [];
394
+ const improvements = [];
395
+ const algosToCheck = onlyArg ? [onlyArg] : Object.keys(baseline);
396
+ for (const algo of algosToCheck) {
397
+ if (!baseline[algo]) continue;
398
+ const wasPassing = new Set(baseline[algo].passing);
399
+ const nowPassing = new Set(current[algo]?.passing || []);
400
+ for (const id of wasPassing) if (!nowPassing.has(id)) regressions.push({ algo, id });
401
+ for (const id of nowPassing) if (!wasPassing.has(id)) improvements.push({ algo, id });
402
+ }
403
+
404
+ if (improvements.length) {
405
+ console.log(`\n${improvements.length} test(s) newly passing — please run --update to ratchet the baseline:`);
406
+ for (const { algo, id } of improvements.slice(0, 20)) console.log(` + ${algo} ${id}`);
407
+ if (improvements.length > 20) console.log(` ... ${improvements.length - 20} more`);
408
+ }
409
+
410
+ if (regressions.length) {
411
+ console.log(`\n${regressions.length} REGRESSION(S) — these tests passed in baseline but now fail:`);
412
+ for (const { algo, id } of regressions) console.log(` - ${algo} ${id}`);
413
+ process.exit(1);
414
+ }
415
+ console.log('\nNo regressions vs baseline.');
416
+ }
417
+ }
418
+
419
+ main().catch(e => { console.error(e); process.exit(1); });