eyeleng 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.js CHANGED
@@ -24,7 +24,7 @@ const {
24
24
 
25
25
  class Parser {
26
26
  constructor(source, options = {}) {
27
- this.tokens = Array.isArray(source) ? source : tokenize(source, options.filename);
27
+ this.tokens = Array.isArray(source) ? source : tokenize(source, options);
28
28
  this.pos = 0;
29
29
  this.options = options;
30
30
  this.baseIRI = options.baseIRI || null;
@@ -87,6 +87,7 @@ class Parser {
87
87
  let name = nameToken.value;
88
88
  if (!name.endsWith(':')) throw this.error('Prefix name must end with :', nameToken);
89
89
  name = name.slice(0, -1);
90
+ if (this.strictGrammar() && !isValidPNPrefix(name)) throw this.error(`Invalid prefix name ${nameToken.value}`, nameToken);
90
91
  const iriToken = this.expectType('iri');
91
92
  this.prefixes[name] = this.resolveIRI(iriToken.value, iriToken);
92
93
  if (wasAtPrefix) this.consumeOptionalDot();
@@ -94,6 +95,10 @@ class Parser {
94
95
 
95
96
  parseVersion() {
96
97
  const token = this.expectType('string');
98
+ if (this.strictGrammar()) {
99
+ if (token.long) throw this.error('VERSION must use a short string literal', token);
100
+ if (token.value !== '1.2') throw this.error('VERSION must be the SHACL Rules version label \"1.2\"', token);
101
+ }
97
102
  this.version = token.value;
98
103
  }
99
104
 
@@ -489,6 +494,7 @@ class Parser {
489
494
  } else if (this.matchWord('SET')) {
490
495
  clauses.push(this.parseSetClause());
491
496
  } else if (this.matchWord('BIND')) {
497
+ if (this.strictGrammar()) throw this.error('BIND is not part of the SHACL 1.2 Rules grammar; use SET');
492
498
  clauses.push(this.parseBindClause());
493
499
  } else if (this.matchWord('NOT')) {
494
500
  this.expectValue('{');
@@ -596,8 +602,9 @@ class Parser {
596
602
  if (colon < 0) throw this.error(`Expected IRI, prefixed name, literal, blank node, or variable; got ${value}`, token);
597
603
  const prefix = value.slice(0, colon);
598
604
  const local = value.slice(colon + 1);
605
+ if (this.strictGrammar()) validatePrefixedName(prefix, local, value, token, (message, errToken) => this.error(message, errToken));
599
606
  if (!(prefix in this.prefixes)) throw this.error(`Unknown prefix ${prefix}:`, token);
600
- return this.prefixes[prefix] + local;
607
+ return this.prefixes[prefix] + decodePNLocalEscapes(local);
601
608
  }
602
609
 
603
610
  resolveIRI(value, token = null) {
@@ -756,9 +763,76 @@ class Parser {
756
763
  peek() { return this.tokens[this.pos]; }
757
764
  peekN(n) { return this.tokens[this.pos + n] || this.tokens[this.tokens.length - 1]; }
758
765
  previous() { return this.tokens[this.pos - 1]; }
766
+ strictGrammar() { return !!this.options.strictGrammar; }
759
767
  error(message, token = this.peek()) { return new SyntaxErrorWithLocation(message, token); }
760
768
  }
761
769
 
770
+
771
+ function isPnCharsBase(ch) {
772
+ if (!ch) return false;
773
+ return /[A-Za-z]/.test(ch) || ch.codePointAt(0) >= 0x00C0;
774
+ }
775
+
776
+ function isPnCharsU(ch) {
777
+ return isPnCharsBase(ch) || ch === '_';
778
+ }
779
+
780
+ function isPnChars(ch) {
781
+ return isPnCharsU(ch) || /[0-9-]/.test(ch) || ch === '\u00B7' || /[\u0300-\u036F\u203F-\u2040]/u.test(ch);
782
+ }
783
+
784
+ function isValidPNPrefix(prefix) {
785
+ if (prefix === '') return true;
786
+ const chars = Array.from(prefix);
787
+ if (!isPnCharsBase(chars[0])) return false;
788
+ if (chars.length > 1 && chars.at(-1) === '.') return false;
789
+ return chars.slice(1).every((ch) => isPnChars(ch) || ch === '.');
790
+ }
791
+
792
+ function plxLength(text, index) {
793
+ const ch = text[index];
794
+ if (ch === '%' && /[0-9A-Fa-f]/.test(text[index + 1] || '') && /[0-9A-Fa-f]/.test(text[index + 2] || '')) return 3;
795
+ if (ch === '\\' && /[_~.!$&'()*+,;=/?#@%-]/.test(text[index + 1] || '')) return 2;
796
+ return 0;
797
+ }
798
+
799
+ function isPNLocalStartAt(text, index) {
800
+ const ch = text[index];
801
+ return isPnCharsU(ch) || /[0-9:]/.test(ch || '') || plxLength(text, index) > 0;
802
+ }
803
+
804
+ function isPNLocalBodyAt(text, index) {
805
+ const ch = text[index];
806
+ return isPnChars(ch) || ch === '.' || ch === ':' || plxLength(text, index) > 0;
807
+ }
808
+
809
+ function isPNLocalEndAt(text, index) {
810
+ const ch = text[index];
811
+ return isPnChars(ch) || ch === ':' || plxLength(text, index) > 0;
812
+ }
813
+
814
+ function validatePNLocal(local) {
815
+ if (local === '') return true;
816
+ if (!isPNLocalStartAt(local, 0)) return false;
817
+ let lastStart = 0;
818
+ for (let i = 0; i < local.length;) {
819
+ const len = plxLength(local, i) || 1;
820
+ if (i > 0 && !isPNLocalBodyAt(local, i)) return false;
821
+ lastStart = i;
822
+ i += len;
823
+ }
824
+ return isPNLocalEndAt(local, lastStart);
825
+ }
826
+
827
+ function validatePrefixedName(prefix, local, value, token, makeError) {
828
+ if (!isValidPNPrefix(prefix)) throw makeError(`Invalid prefixed name ${value}: invalid prefix`, token);
829
+ if (!validatePNLocal(local)) throw makeError(`Invalid prefixed name ${value}: invalid local name`, token);
830
+ }
831
+
832
+ function decodePNLocalEscapes(local) {
833
+ return String(local).replace(/\\([_~.!$&'()*+,;=/?#@%-])/g, '$1');
834
+ }
835
+
762
836
  function numericLiteral(value) {
763
837
  if (Number.isInteger(value)) return literal(value, XSD_INTEGER);
764
838
  return literal(value, XSD_DECIMAL);
@@ -706,6 +706,17 @@ function rdfManifestsToEarl(result, options = {}) {
706
706
  return lines.join('\n');
707
707
  }
708
708
 
709
+ function defaultRdfReportPath() {
710
+ return path.join(__dirname, '..', 'reports', 'w3c-rdf-earl.ttl');
711
+ }
712
+
713
+ function writeRdfEarlReport(result, file = defaultRdfReportPath(), options = {}) {
714
+ const earl = rdfManifestsToEarl(result, options);
715
+ fs.mkdirSync(path.dirname(file), { recursive: true });
716
+ fs.writeFileSync(file, `${earl}\n`, 'utf8');
717
+ return file;
718
+ }
719
+
709
720
  module.exports = {
710
721
  defaultW3cRdfManifestUrls,
711
722
  parseNQuads,
@@ -721,4 +732,6 @@ module.exports = {
721
732
  formatW3cRdfManifestResult,
722
733
  formatW3cRdfManifestsResult,
723
734
  rdfManifestsToEarl,
735
+ writeRdfEarlReport,
736
+ defaultRdfReportPath,
724
737
  };
@@ -0,0 +1,386 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const eyeleng = require('./index.js');
8
+ const { tripleKey } = require('./term.js');
9
+
10
+ const DEFAULT_MANIFEST = 'https://w3c.github.io/data-shapes/shacl12-test-suite/tests/rules/manifest-rules.ttl';
11
+ const defaultShacl12RulesManifestUrl = DEFAULT_MANIFEST;
12
+ const textCache = new Map();
13
+
14
+ function fetchTimeoutMs(options = {}) {
15
+ return Number(options.fetchTimeoutMs || process.env.EYELENG_SHACL12_FETCH_TIMEOUT_MS || 30000);
16
+ }
17
+
18
+ function isLikelyNetworkError(err) {
19
+ const msg = String(err && (err.stack || err.message || err));
20
+ return /fetch failed|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|network|timed out|GET .* failed/i.test(msg);
21
+ }
22
+
23
+ function isW3cRequired() {
24
+ return process.env.EYELENG_W3C_REQUIRED !== '0';
25
+ }
26
+
27
+ function stripHash(url) {
28
+ const parsed = new URL(url);
29
+ parsed.hash = '';
30
+ return parsed.href;
31
+ }
32
+
33
+ function resolveHref(baseUrl, href) {
34
+ return new URL(href, baseUrl).href;
35
+ }
36
+
37
+ async function fetchText(url, options = {}) {
38
+ const normalized = stripHash(url);
39
+ if (textCache.has(normalized)) return textCache.get(normalized);
40
+
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), fetchTimeoutMs(options));
43
+ try {
44
+ const response = await fetch(normalized, { signal: controller.signal });
45
+ if (!response.ok) throw new Error(`GET ${normalized} failed: ${response.status} ${response.statusText}`);
46
+ const text = await response.text();
47
+ textCache.set(normalized, text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
48
+ return textCache.get(normalized);
49
+ } catch (err) {
50
+ if (err && err.name === 'AbortError') throw new Error(`GET ${normalized} timed out after ${fetchTimeoutMs(options)} ms`);
51
+ throw err;
52
+ } finally {
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+
57
+ function parseIncludedManifests(rootUrl, text) {
58
+ const includeMatch = /mf:include\s*\(([\s\S]*?)\)\s*\./m.exec(text);
59
+ if (!includeMatch) throw new Error(`No mf:include list found in ${rootUrl}`);
60
+ return [...includeMatch[1].matchAll(/<([^>]+)>/g)].map((match) => resolveHref(rootUrl, match[1]));
61
+ }
62
+
63
+ function sectionName(manifestUrl) {
64
+ const pieces = new URL(manifestUrl).pathname.split('/').filter(Boolean);
65
+ if (pieces.length < 2) return manifestUrl;
66
+ return pieces[pieces.length - 2];
67
+ }
68
+
69
+ function manifestStatements(text) {
70
+ const statements = [];
71
+ const re = /([^\s;]+)\s+rdf:type\s+srt:([A-Za-z0-9]+)\s*;([\s\S]*?)\n\s*\./g;
72
+ let match;
73
+ while ((match = re.exec(text)) !== null) {
74
+ statements.push({ id: match[1], type: match[2], body: match[3] });
75
+ }
76
+ return statements;
77
+ }
78
+
79
+ function parseManifestTests(manifestUrl, text) {
80
+ const section = sectionName(manifestUrl);
81
+ const tests = [];
82
+
83
+ for (const statement of manifestStatements(text)) {
84
+ const name = /mf:name\s+"((?:[^"\\]|\\.)*)"/m.exec(statement.body)?.[1] || statement.id;
85
+ const testUrl = resolveHref(manifestUrl, statement.id.replace(/^<([^>]*)>$/, '$1'));
86
+
87
+ if (statement.type === 'RulesEvalTest') {
88
+ const ruleset = /srt:ruleset\s+<([^>]+)>/m.exec(statement.body)?.[1];
89
+ const data = /srt:data\s+<([^>]+)>/m.exec(statement.body)?.[1];
90
+ const result = /mf:result\s+<([^>]+)>/m.exec(statement.body)?.[1];
91
+ if (!ruleset || !data || !result) throw new Error(`Incomplete eval test ${statement.id} in ${manifestUrl}`);
92
+ tests.push({
93
+ section,
94
+ id: statement.id,
95
+ testUrl,
96
+ type: statement.type,
97
+ name,
98
+ manifestUrl,
99
+ rulesetUrl: resolveHref(manifestUrl, ruleset),
100
+ dataUrl: resolveHref(manifestUrl, data),
101
+ resultUrl: resolveHref(manifestUrl, result),
102
+ });
103
+ continue;
104
+ }
105
+
106
+ const action = /mf:action\s+<([^>]+)>/m.exec(statement.body)?.[1];
107
+ if (!action) throw new Error(`No mf:action found for ${statement.id} in ${manifestUrl}`);
108
+ tests.push({
109
+ section,
110
+ id: statement.id,
111
+ testUrl,
112
+ type: statement.type,
113
+ name,
114
+ manifestUrl,
115
+ actionUrl: resolveHref(manifestUrl, action),
116
+ });
117
+ }
118
+
119
+ return tests;
120
+ }
121
+
122
+ async function loadShacl12RulesTests(rootManifestUrl = defaultShacl12RulesManifestUrl, options = {}) {
123
+ const rootText = await fetchText(rootManifestUrl, options);
124
+ const manifestUrls = parseIncludedManifests(rootManifestUrl, rootText);
125
+ const suites = await Promise.all(manifestUrls.map(async (manifestUrl) => ({
126
+ manifestUrl,
127
+ text: await fetchText(manifestUrl, options),
128
+ })));
129
+ return suites.flatMap(({ manifestUrl, text }) => parseManifestTests(manifestUrl, text));
130
+ }
131
+
132
+ function shouldPass(type) {
133
+ return /Positive/.test(type) || type === 'RulesEvalTest';
134
+ }
135
+
136
+ async function runSyntaxOrWellformedTest(test, options = {}) {
137
+ const source = await fetchText(test.actionUrl, options);
138
+ const parseOptions = {
139
+ filename: test.actionUrl,
140
+ baseIRI: test.actionUrl,
141
+ shacl12Conformance: true,
142
+ };
143
+
144
+ if (test.type.includes('Syntax')) {
145
+ if (shouldPass(test.type)) eyeleng.parse(source, parseOptions);
146
+ else assert.throws(() => eyeleng.parse(source, parseOptions), Error);
147
+ return;
148
+ }
149
+
150
+ if (shouldPass(test.type)) eyeleng.compile(source, parseOptions);
151
+ else assert.throws(() => eyeleng.compile(source, parseOptions), Error);
152
+ }
153
+
154
+ async function parseTurtleTriples(url, options = {}) {
155
+ const source = await fetchText(url, options);
156
+ return eyeleng.parseRdfDocument(source, { filename: url, baseIRI: url }).triples;
157
+ }
158
+
159
+ function sortedTripleKeys(triples) {
160
+ return triples.map(tripleKey).sort();
161
+ }
162
+
163
+ function setDiff(actual, expected) {
164
+ const actualSet = new Set(actual);
165
+ return expected.filter((item) => !actualSet.has(item));
166
+ }
167
+
168
+ async function runEvalTest(test, options = {}) {
169
+ const [rulesSource, dataTriples, expectedTriples] = await Promise.all([
170
+ fetchText(test.rulesetUrl, options),
171
+ parseTurtleTriples(test.dataUrl, options),
172
+ parseTurtleTriples(test.resultUrl, options),
173
+ ]);
174
+
175
+ const compileOptions = {
176
+ filename: test.rulesetUrl,
177
+ baseIRI: test.rulesetUrl,
178
+ shacl12Conformance: true,
179
+ };
180
+ const compiled = eyeleng.compile(rulesSource, compileOptions);
181
+ const program = { ...compiled.program, data: [...compiled.program.data, ...dataTriples] };
182
+ const result = eyeleng.evaluate(program, { analysis: compiled.analysis, shacl12Conformance: true });
183
+
184
+ const externalInput = new Set(dataTriples.map(tripleKey));
185
+ const actualTriples = result.closure.filter((triple) => !externalInput.has(tripleKey(triple)));
186
+ const actual = sortedTripleKeys(actualTriples);
187
+ const expected = sortedTripleKeys(expectedTriples);
188
+
189
+ try {
190
+ assert.deepEqual(actual, expected);
191
+ } catch (err) {
192
+ err.message += `\nMissing expected:\n${setDiff(actual, expected).join('\n')}\nUnexpected actual:\n${setDiff(expected, actual).join('\n')}`;
193
+ throw err;
194
+ }
195
+ }
196
+
197
+ async function runOneShacl12RulesTest(test, options = {}) {
198
+ if (test.type === 'RulesEvalTest') return runEvalTest(test, options);
199
+ return runSyntaxOrWellformedTest(test, options);
200
+ }
201
+
202
+ async function runShacl12RulesManifest(rootManifestUrl = defaultShacl12RulesManifestUrl, options = {}) {
203
+ const suiteStart = Date.now();
204
+ const tests = await loadShacl12RulesTests(rootManifestUrl, options);
205
+ const results = [];
206
+ const bySection = new Map();
207
+
208
+ for (let index = 0; index < tests.length; index += 1) {
209
+ const test = tests[index];
210
+ const start = Date.now();
211
+ let status = 'pass';
212
+ let message = 'passed';
213
+ try {
214
+ await runOneShacl12RulesTest(test, options);
215
+ } catch (err) {
216
+ status = 'fail';
217
+ message = err.stack || err.message || String(err);
218
+ }
219
+ const item = { ...test, status, message, durationMs: Date.now() - start };
220
+ results.push(item);
221
+ const section = bySection.get(test.section) || { passed: 0, failed: 0, total: 0 };
222
+ section.total += 1;
223
+ if (status === 'pass') section.passed += 1;
224
+ else section.failed += 1;
225
+ bySection.set(test.section, section);
226
+ if (typeof options.onProgress === 'function') options.onProgress(item, index, tests.length);
227
+ }
228
+
229
+ const counts = {
230
+ total: results.length,
231
+ pass: results.filter((r) => r.status === 'pass').length,
232
+ fail: results.filter((r) => r.status === 'fail').length,
233
+ skip: results.filter((r) => r.status === 'skip').length,
234
+ };
235
+ return {
236
+ ok: counts.fail === 0 && counts.total > 0,
237
+ source: rootManifestUrl,
238
+ counts,
239
+ durationMs: Date.now() - suiteStart,
240
+ bySection: Array.from(bySection, ([section, counts]) => ({ section, ...counts })),
241
+ results,
242
+ };
243
+ }
244
+
245
+ function nullColors() {
246
+ return { g: '', r: '', y: '', dim: '', n: '' };
247
+ }
248
+
249
+ function colorizeStatus(status, colors = nullColors()) {
250
+ if (status === 'pass') return `${colors.g}OK${colors.n}`;
251
+ if (status === 'skip') return `${colors.y}SKIP${colors.n}`;
252
+ return `${colors.r}FAIL${colors.n}`;
253
+ }
254
+
255
+ function colorizeTextForStatus(status, text, colors = nullColors()) {
256
+ if (status === 'pass') return `${colors.g}${text}${colors.n}`;
257
+ if (status === 'skip') return `${colors.y}${text}${colors.n}`;
258
+ return `${colors.r}${text}${colors.n}`;
259
+ }
260
+
261
+ function formatMs(ms, colors = nullColors()) {
262
+ return `${colors.dim}(${ms} ms)${colors.n}`;
263
+ }
264
+
265
+ function formatShacl12RulesProgressLine(item, index, options = {}) {
266
+ const C = options.colors || nullColors();
267
+ const tag = colorizeStatus(item.status, C);
268
+ const idx = `${C.dim}${String(index + 1).padStart(3, '0')}${C.n}`;
269
+ const description = colorizeTextForStatus(item.status, `${item.section}/${item.name}`, C);
270
+ let line = `${idx} ${tag} ${description} ${formatMs(item.durationMs, C)}`;
271
+ if (item.status !== 'pass') line += `\n ${colorizeTextForStatus(item.status, item.message, C)}`;
272
+ return line;
273
+ }
274
+
275
+ function formatShacl12RulesManifestResult(result, options = {}) {
276
+ const C = options.colors || nullColors();
277
+ const lines = [];
278
+ lines.push(`${C.y}==${C.n} W3C SHACL 1.2 Rules`);
279
+ lines.push(`manifest: ${result.source}`);
280
+ (result.results || []).forEach((item, index) => lines.push(formatShacl12RulesProgressLine(item, index, options)));
281
+ for (const section of result.bySection || []) {
282
+ const status = colorizeStatus(section.failed === 0 ? 'pass' : 'fail', C);
283
+ lines.push(`${status} ${section.passed}/${section.total} tests passed — ${section.section}`);
284
+ }
285
+ const status = colorizeStatus(result.counts.fail === 0 ? 'pass' : 'fail', C);
286
+ lines.push(`${status} ${result.counts.pass}/${result.counts.total} tests passed ${formatMs(result.durationMs, C)}`);
287
+ return lines.join('\n');
288
+ }
289
+
290
+ function turtleString(value, lang = null) {
291
+ const escaped = JSON.stringify(String(value));
292
+ return lang ? `${escaped}@${lang}` : escaped;
293
+ }
294
+
295
+ function safeIri(value) {
296
+ return String(value || '').replace(/[<>]/g, '');
297
+ }
298
+
299
+ function typeIri(type) {
300
+ return `srt:${type}`;
301
+ }
302
+
303
+ function shacl12RulesManifestToEarl(result, options = {}) {
304
+ const assertedBy = options.assertedBy || '<https://github.com/eyereasoner/eyeleng>';
305
+ const now = options.date || new Date().toISOString();
306
+ const passed = result.counts?.pass || 0;
307
+ const total = result.counts?.total || 0;
308
+ const lines = [
309
+ '# EARL 1.0 test result report for Eyeleng running the W3C SHACL 1.2 Rules test suite.',
310
+ `# Generated from the manifest at ${result.source || defaultShacl12RulesManifestUrl} .`,
311
+ '',
312
+ '@prefix dct: <http://purl.org/dc/terms/> .',
313
+ '@prefix doap: <http://usefulinc.com/ns/doap#> .',
314
+ '@prefix earl: <http://www.w3.org/ns/earl#> .',
315
+ '@prefix foaf: <http://xmlns.com/foaf/0.1/> .',
316
+ '@prefix srt: <http://www.w3.org/ns/shacl-rules-test#> .',
317
+ '@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .',
318
+ '',
319
+ '<#report>',
320
+ ' a earl:TestResult ;',
321
+ ' dct:title "Eyeleng W3C SHACL 1.2 Rules EARL report"@en ;',
322
+ ` dct:description ${turtleString(`Generated Eyeleng EARL 1.0 report for the W3C SHACL 1.2 Rules test manifest. ${passed}/${total} tests passed.`, 'en')} ;`,
323
+ ` dct:date ${turtleString(now)}^^xsd:dateTime ;`,
324
+ ` earl:outcome ${result.counts?.fail === 0 ? 'earl:passed' : 'earl:failed'} .`,
325
+ '',
326
+ `${assertedBy}`,
327
+ ' a earl:Software, doap:Project, foaf:Agent ;',
328
+ ' dct:title "Eyeleng"@en ;',
329
+ ' doap:name "eyeleng" ;',
330
+ ' foaf:homepage <https://github.com/eyereasoner/eyeleng> .',
331
+ '',
332
+ ];
333
+
334
+ for (const item of result.results || []) {
335
+ const testUri = safeIri(item.testUrl || item.actionUrl || item.rulesetUrl || item.id || `urn:eyeleng:shacl12-rules:${item.name}`);
336
+ const outcome = item.status === 'pass' ? 'earl:passed' : item.status === 'skip' ? 'earl:untested' : 'earl:failed';
337
+ const title = `${item.section}/${item.name}`;
338
+ lines.push(`<${testUri}>`);
339
+ lines.push(` a earl:TestCase, ${typeIri(item.type)} ;`);
340
+ lines.push(` dct:title ${turtleString(title, 'en')} ;`);
341
+ lines.push(` dct:isPartOf <${safeIri(result.source || defaultShacl12RulesManifestUrl)}> .`);
342
+ lines.push('');
343
+ lines.push('[] a earl:Assertion ;');
344
+ lines.push(` earl:assertedBy ${assertedBy} ;`);
345
+ lines.push(` earl:subject ${assertedBy} ;`);
346
+ lines.push(` earl:test <${testUri}> ;`);
347
+ lines.push(' earl:mode earl:automatic ;');
348
+ lines.push(' earl:result [');
349
+ lines.push(' a earl:TestResult ;');
350
+ lines.push(` earl:outcome ${outcome} ;`);
351
+ lines.push(` earl:info ${turtleString(item.status === 'pass' ? 'passed' : item.message || item.status)} ;`);
352
+ lines.push(` dct:date ${turtleString(now)}^^xsd:dateTime`);
353
+ lines.push(' ] .');
354
+ lines.push('');
355
+ }
356
+
357
+ return lines.join('\n');
358
+ }
359
+
360
+ function defaultReportPath() {
361
+ return path.join(__dirname, '..', 'reports', 'w3c-shacl12-rules-earl.ttl');
362
+ }
363
+
364
+ function writeShacl12RulesEarlReport(result, file = defaultReportPath(), options = {}) {
365
+ const earl = shacl12RulesManifestToEarl(result, options);
366
+ fs.mkdirSync(path.dirname(file), { recursive: true });
367
+ fs.writeFileSync(file, `${earl}\n`, 'utf8');
368
+ return file;
369
+ }
370
+
371
+ module.exports = {
372
+ defaultShacl12RulesManifestUrl,
373
+ isLikelyNetworkError,
374
+ isW3cRequired,
375
+ fetchText,
376
+ parseIncludedManifests,
377
+ parseManifestTests,
378
+ loadShacl12RulesTests,
379
+ runOneShacl12RulesTest,
380
+ runShacl12RulesManifest,
381
+ formatShacl12RulesProgressLine,
382
+ formatShacl12RulesManifestResult,
383
+ shacl12RulesManifestToEarl,
384
+ writeShacl12RulesEarlReport,
385
+ defaultReportPath,
386
+ };
package/src/tokenizer.js CHANGED
@@ -9,7 +9,10 @@ class SyntaxErrorWithLocation extends Error {
9
9
  }
10
10
  }
11
11
 
12
- function tokenize(source, filename = '<input>') {
12
+ function tokenize(source, filenameOrOptions = '<input>') {
13
+ const options = typeof filenameOrOptions === 'object' && filenameOrOptions !== null ? filenameOrOptions : { filename: filenameOrOptions };
14
+ const filename = options.filename || '<input>';
15
+ const strictGrammar = !!options.strictGrammar;
13
16
  const tokens = [];
14
17
  let i = 0;
15
18
  let line = 1;
@@ -24,8 +27,8 @@ function tokenize(source, filename = '<input>') {
24
27
  else column += 1;
25
28
  return ch;
26
29
  }
27
- function token(type, value, startLine, startColumn) {
28
- tokens.push({ type, value, line: startLine, column: startColumn, filename });
30
+ function token(type, value, startLine, startColumn, extra = {}) {
31
+ tokens.push({ type, value, line: startLine, column: startColumn, filename, ...extra });
29
32
  }
30
33
  function syntax(message, startLine, startColumn) {
31
34
  throw new SyntaxErrorWithLocation(message, { line: startLine, column: startColumn, filename });
@@ -63,14 +66,37 @@ function tokenize(source, filename = '<input>') {
63
66
  const length = esc === 'u' ? 4 : 8;
64
67
  let hex = '';
65
68
  for (let j = 0; j < length; j += 1) {
66
- if (!/[0-9A-Fa-f]/.test(current() || '')) syntax(`Invalid \${esc} escape`, startLine, startColumn);
69
+ if (!/[0-9A-Fa-f]/.test(current() || '')) syntax(`Invalid \\${esc} escape`, startLine, startColumn);
67
70
  hex += advance();
68
71
  }
69
- return String.fromCodePoint(Number.parseInt(hex, 16));
72
+ const codePoint = Number.parseInt(hex, 16);
73
+ try { return String.fromCodePoint(codePoint); }
74
+ catch { syntax(`Invalid \\${esc} escape`, startLine, startColumn); }
70
75
  }
76
+ if (strictGrammar && !Object.hasOwn(escapeMap, esc)) syntax(`Invalid escape \\${esc}`, startLine, startColumn);
71
77
  return escapeValue(esc);
72
78
  }
73
79
 
80
+ function readIriChar(startLine, startColumn) {
81
+ if (current() === '\\') {
82
+ advance();
83
+ const esc = advance();
84
+ if (esc !== 'u' && esc !== 'U') syntax(`Invalid IRI escape \\${esc}`, startLine, startColumn);
85
+ const length = esc === 'u' ? 4 : 8;
86
+ let hex = '';
87
+ for (let j = 0; j < length; j += 1) {
88
+ if (!/[0-9A-Fa-f]/.test(current() || '')) syntax(`Invalid \\${esc} escape`, startLine, startColumn);
89
+ hex += advance();
90
+ }
91
+ const codePoint = Number.parseInt(hex, 16);
92
+ try { return String.fromCodePoint(codePoint); }
93
+ catch { syntax(`Invalid \\${esc} escape`, startLine, startColumn); }
94
+ }
95
+ const c = current();
96
+ if (strictGrammar && (/[\u0000-\u0020]/.test(c) || /[<>"{}|^`]/.test(c))) syntax(`Invalid character in IRI reference ${JSON.stringify(c)}`, startLine, startColumn);
97
+ return advance();
98
+ }
99
+
74
100
  while (i < source.length) {
75
101
  const ch = current();
76
102
  if (/\s/.test(ch)) { advance(); continue; }
@@ -116,7 +142,7 @@ function tokenize(source, filename = '<input>') {
116
142
  if (ch === '<' && looksLikeIRI(source, i)) {
117
143
  let value = '';
118
144
  advance();
119
- while (i < source.length && current() !== '>') value += advance();
145
+ while (i < source.length && current() !== '>') value += readIriChar(startLine, startColumn);
120
146
  if (current() !== '>') syntax('Unterminated IRI', startLine, startColumn);
121
147
  advance();
122
148
  token('iri', value, startLine, startColumn);
@@ -136,7 +162,7 @@ function tokenize(source, filename = '<input>') {
136
162
  }
137
163
  if (!startsWith(quote.repeat(3))) syntax('Unterminated long string literal', startLine, startColumn);
138
164
  advance(); advance(); advance();
139
- token('string', value, startLine, startColumn);
165
+ token('string', value, startLine, startColumn, { long: true, quote });
140
166
  continue;
141
167
  }
142
168
 
@@ -154,7 +180,7 @@ function tokenize(source, filename = '<input>') {
154
180
  }
155
181
  if (current() !== quote) syntax('Unterminated string literal', startLine, startColumn);
156
182
  advance();
157
- token('string', value, startLine, startColumn);
183
+ token('string', value, startLine, startColumn, { long: false, quote });
158
184
  continue;
159
185
  }
160
186
 
@@ -200,7 +226,16 @@ function tokenize(source, filename = '<input>') {
200
226
  let value = '';
201
227
  while (i < source.length) {
202
228
  const c = current();
203
- if (/\s/.test(c) || '{}()[].,;|'.includes(c) || '=<>+-*/!^~'.includes(c)) break;
229
+ if (c === '\\' && peek() !== undefined) {
230
+ value += advance();
231
+ value += advance();
232
+ continue;
233
+ }
234
+ if (/\s/.test(c) || '{}()[],;|'.includes(c) || '=<>+-*/!^~'.includes(c)) break;
235
+ if (c === '.') {
236
+ const n = peek();
237
+ if (n === undefined || /\s/.test(n) || '{}()[],;|'.includes(n) || '=<>+-*/!^~'.includes(n)) break;
238
+ }
204
239
  if (c === '#') break;
205
240
  value += advance();
206
241
  }
@@ -233,9 +268,10 @@ function looksLikeIRI(source, i) {
233
268
  return false;
234
269
  }
235
270
 
271
+ const escapeMap = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '"': '"', "'": "'", '\\': '\\' };
272
+
236
273
  function escapeValue(esc) {
237
- const map = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '"': '"', "'": "'", '\\': '\\' };
238
- return map[esc] ?? esc;
274
+ return escapeMap[esc] ?? esc;
239
275
  }
240
276
 
241
277
  module.exports = { tokenize, SyntaxErrorWithLocation };
package/test/api.test.js CHANGED
@@ -423,6 +423,38 @@ RULE { :clock :consistent true ; :snapshot ?t1 } WHERE {
423
423
  assert.match(output, /:clock :snapshot "2026-05-15T12:34:56\.000Z"\^\^xsd:dateTime \./);
424
424
  });
425
425
 
426
+
427
+ test('strict grammar mode rejects non-grammar extensions and loose tokens', () => {
428
+ const options = { strictGrammar: true };
429
+ assert.throws(() => parse(`
430
+ PREFIX : <http://example/>
431
+ RULE { :s :p ?x } WHERE { :s :q ?y BIND(?y AS ?x) }
432
+ `, options), /BIND is not part of the SHACL 1.2 Rules grammar/);
433
+ assert.throws(() => parse(`
434
+ PREFIX : <http://example/>
435
+ RULE { :s :p "bad\\q" } WHERE { :s :q ?x }
436
+ `, options), /Invalid escape/);
437
+ assert.throws(() => parse(`
438
+ PREFIX : <http://example/>
439
+ RULE { :bad%ZZ :p ?x } WHERE { :s :q ?x }
440
+ `, options), /Invalid prefixed name/);
441
+ assert.throws(() => parse(`
442
+ VERSION "2.0"
443
+ PREFIX : <http://example/>
444
+ RULE { :s :p ?x } WHERE { :s :q ?x }
445
+ `, options), /VERSION must be the SHACL Rules version label/);
446
+ assert.throws(() => parse(`
447
+ VERSION """1.2"""
448
+ PREFIX : <http://example/>
449
+ RULE { :s :p ?x } WHERE { :s :q ?x }
450
+ `, options), /VERSION must use a short string literal/);
451
+ assert.doesNotThrow(() => parse(`
452
+ VERSION "1.2"
453
+ PREFIX : <http://example/>
454
+ RULE { :s :p ?x } WHERE { :s :q ?x SET(?z := STR(?x)) }
455
+ `, options));
456
+ });
457
+
426
458
  main();
427
459
 
428
460
  test('RDF Message Logs expose Eyeling-style envelopes and payload triples', () => {