eyeleng 1.0.11 → 1.0.13

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/analyze.js CHANGED
@@ -6,8 +6,8 @@ const { tripleHasBlankNode } = require('./assignments.js');
6
6
  function analyze(program, options = {}) {
7
7
  const diagnostics = [];
8
8
  const dependency = dependencyGraph(program, options);
9
- const hasRunOnceRules = program.rules.some((rule) => rule.runOnce);
10
- const recursiveIndexes = hasRunOnceRules ? recursiveRuleIndexes(dependency) : new Set();
9
+ const hasTermGeneratingRules = dependency.rules.some((rule) => rule.createsTerms);
10
+ const recursiveIndexes = hasTermGeneratingRules ? recursiveRuleIndexes(dependency) : new Set();
11
11
 
12
12
  program.rules.forEach((rule, index) => {
13
13
  const name = ruleName(rule, index);
@@ -39,12 +39,13 @@ function analyze(program, options = {}) {
39
39
 
40
40
  diagnostics.push(...sequentialWellFormednessDiagnostics(rule.body, name, program.prefixes || {}));
41
41
 
42
- if (rule.runOnce && recursiveIndexes.has(index)) {
42
+ const depRule = dependency.rules[index] || {};
43
+ if (depRule.createsTerms && recursiveIndexes.has(index)) {
43
44
  diagnostics.push({
44
45
  code: 'recursive-assignment-rule',
45
46
  severity: 'warning',
46
47
  rule: name,
47
- message: `${displayRuleName(name, program.prefixes || {})} is a run-once rule in a recursive dependency cycle`,
48
+ message: `${displayRuleName(name, program.prefixes || {})} creates terms in a recursive dependency cycle; relaxed mode allows this but termination is not guaranteed (use --strict to reject it)`,
48
49
  });
49
50
  }
50
51
 
@@ -91,34 +92,47 @@ function dependencyGraph(program, options = {}) {
91
92
  negativePredicates: new Set(negativePatterns.flatMap((triple) => predicateIRIs(triple))),
92
93
  runOnce: !!rule.runOnce,
93
94
  hasAssignment: ruleHasAssignment(rule, options),
95
+ hasTermGeneratingAssignment: ruleHasTermGeneratingAssignment(rule, options),
94
96
  headHasBlankNode: ruleHeadHasBlankNode(rule),
97
+ createsTerms: ruleCreatesTerms(rule, options),
95
98
  };
96
99
  });
97
100
 
98
101
  const edgeMap = new Map();
99
- function addEdge(from, to, negative, predicate) {
102
+ function addEdge(from, to, kind, predicate) {
100
103
  const label = predicate || '*';
101
104
  const key = `${from.index}->${to.index}:${label}`;
105
+ const negated = kind === 'negated';
106
+ const termGeneration = kind === 'term-generation';
102
107
  const existing = edgeMap.get(key);
103
108
  if (existing) {
104
- existing.negative = existing.negative || negative;
109
+ existing.negated = existing.negated || negated;
110
+ existing.termGeneration = existing.termGeneration || termGeneration;
111
+ existing.negative = existing.negated || existing.termGeneration;
105
112
  return;
106
113
  }
107
- edgeMap.set(key, { from: from.index, to: to.index, negative, predicate });
114
+ edgeMap.set(key, {
115
+ from: from.index,
116
+ to: to.index,
117
+ negative: negated || termGeneration,
118
+ negated,
119
+ termGeneration,
120
+ predicate,
121
+ });
108
122
  }
109
123
 
110
124
  const headIndex = buildHeadTemplateIndex(rules);
111
125
 
112
126
  for (const from of rules) {
113
- const forceClosed = from.hasAssignment || from.headHasBlankNode;
127
+ const forceClosed = from.createsTerms;
114
128
  for (const pattern of from.positivePatterns) {
115
129
  for (const candidate of candidateHeadTemplates(headIndex, pattern)) {
116
- if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], forceClosed, dependencyPredicateLabel(pattern));
130
+ if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], forceClosed ? 'term-generation' : 'positive', dependencyPredicateLabel(pattern));
117
131
  }
118
132
  }
119
133
  for (const pattern of from.negativePatterns) {
120
134
  for (const candidate of candidateHeadTemplates(headIndex, pattern)) {
121
- if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], true, dependencyPredicateLabel(pattern));
135
+ if (canPossiblyGenerate(candidate.template, pattern)) addEdge(from, rules[candidate.ruleIndex], 'negated', dependencyPredicateLabel(pattern));
122
136
  }
123
137
  }
124
138
  }
@@ -134,7 +148,7 @@ function dependencyGraph(program, options = {}) {
134
148
  const unstratifiedCycles = [];
135
149
  const seen = new Set();
136
150
  for (const edge of edges) {
137
- if (!edge.negative) continue;
151
+ if (!edge.negated) continue;
138
152
  if (edge.from === edge.to && rules[edge.from].runOnce && !rules[edge.from].headHasBlankNode) continue;
139
153
  if (componentOf.get(edge.from) !== componentOf.get(edge.to)) continue;
140
154
  const component = components[componentOf.get(edge.from)];
@@ -157,7 +171,10 @@ function dependencyGraph(program, options = {}) {
157
171
  positivePredicates: Array.from(rule.positivePredicates),
158
172
  negativePredicates: Array.from(rule.negativePredicates),
159
173
  runOnce: rule.runOnce,
174
+ hasAssignment: rule.hasAssignment,
175
+ hasTermGeneratingAssignment: rule.hasTermGeneratingAssignment,
160
176
  headHasBlankNode: rule.headHasBlankNode,
177
+ createsTerms: rule.createsTerms,
161
178
  })),
162
179
  edges,
163
180
  components: components.map((component) => component.map((ruleIndex) => rules[ruleIndex].name)),
@@ -327,7 +344,14 @@ function componentMin(component) {
327
344
  function recursiveRuleIndexes(dependency) {
328
345
  const out = new Set();
329
346
  const ruleByName = new Map(dependency.rules.map((rule) => [rule.name, rule]));
330
- const ruleByIndex = new Map(dependency.rules.map((rule) => [rule.index, rule]));
347
+ const componentOf = new Map();
348
+
349
+ dependency.components.forEach((component, componentIndex) => {
350
+ for (const name of component) {
351
+ const rule = ruleByName.get(name);
352
+ if (rule) componentOf.set(rule.index, componentIndex);
353
+ }
354
+ });
331
355
 
332
356
  for (const component of dependency.components) {
333
357
  if (component.length <= 1) continue;
@@ -338,9 +362,9 @@ function recursiveRuleIndexes(dependency) {
338
362
  }
339
363
 
340
364
  for (const edge of dependency.edges) {
341
- const rule = ruleByIndex.get(edge.from);
342
- if (edge.from === edge.to && edge.negative && rule && rule.runOnce && !rule.headHasBlankNode) continue;
343
- out.add(edge.from);
365
+ const rule = (dependency.rules || []).find((candidate) => candidate.index === edge.from);
366
+ if (edge.from === edge.to && edge.negated && rule && rule.runOnce && !rule.headHasBlankNode) continue;
367
+ if (edge.from === edge.to || componentOf.get(edge.from) === componentOf.get(edge.to)) out.add(edge.from);
344
368
  }
345
369
  return out;
346
370
  }
@@ -613,7 +637,26 @@ function substituteAssignedConstant(term, constants) {
613
637
  }
614
638
 
615
639
  function ruleHasAssignment(rule, options = {}) {
616
- return !!options.shacl12Conformance && (rule.body || []).some((clause) => clause.type === 'set' || clause.type === 'bind');
640
+ return (rule.body || []).some((clause) => clause.type === 'set' || clause.type === 'bind');
641
+ }
642
+
643
+ function ruleHasTermGeneratingAssignment(rule, options = {}) {
644
+ return (rule.body || []).some((clause) => (clause.type === 'set' || clause.type === 'bind') && assignmentMayCreateNewTerm(clause.expr));
645
+ }
646
+
647
+ function ruleCreatesTerms(rule, options = {}) {
648
+ return ruleHeadHasBlankNode(rule) || ruleHasTermGeneratingAssignment(rule, options);
649
+ }
650
+
651
+ function assignmentMayCreateNewTerm(expr) {
652
+ // Simple aliases and constants are not term generators: they select from a
653
+ // fixed term or from already-bound terms. Expressions that compute values
654
+ // through operators or functions may create an unbounded sequence when used
655
+ // recursively, e.g. SET(?v1 := ?v + 1), so they remain part of the strict
656
+ // recursive-new-terms check.
657
+ if (!expr) return false;
658
+ if (expr.type === 'var' || expr.type === 'term' || expr.type === 'literal') return false;
659
+ return true;
617
660
  }
618
661
 
619
662
  function ruleHeadHasBlankNode(rule) {
package/src/cli.js CHANGED
@@ -34,7 +34,7 @@ function readPackageVersion() {
34
34
  const VERSION = readPackageVersion();
35
35
 
36
36
  function help() {
37
- return `eyeleng ${VERSION}\n\nA dependency-free JavaScript implementation experiment for the SHACL 1.2 Rules draft, including SRL and RDF Rules syntax front-ends.\n\nUsage:\n eyeleng [options] [file ...]\n\nOptions:\n --all Print the full closure, including input facts\n --json Print JSON instead of compact triples/bindings\n --trace Print derivation trace to stderr, or include it in JSON\n --stats Print iteration and triple counts to stderr\n --check Parse and analyze only; do not run rules\n --strict Treat static warnings as errors\n --deps Print rule dependency edges during --check\n --query TEXT Run a raw SRL body pattern over the closure\n --query-file FILE Read a raw SRL body pattern from a file\n --max-iterations N Stop after N fixpoint iterations within a recursive layer\n --no-imports Parse IMPORTS/owl:imports but do not load imported rule sets\n --rdf-messages Parse input as an RDF Message Log\n --stream-messages Replay RDF Message Log envelopes\n --include-message-facts Include payload facts while parsing RDF Message Logs\n --syntax MODE Use srl, rdf, or auto syntax detection (default auto)\n --ruleset TERM In RDF syntax, run only the selected srl:RuleSet\n --version Print version\n -h, --help Print this help\n\nWith no file arguments, eyeleng reads from stdin.\n`;
37
+ return `eyeleng ${VERSION}\n\nA dependency-free JavaScript implementation experiment for the SHACL 1.2 Rules draft, including SRL and RDF Rules syntax front-ends.\n\nUsage:\n eyeleng [options] [file ...]\n\nOptions:\n --all Print the full closure, including input facts\n --json Print JSON instead of compact triples/bindings\n --trace Print derivation trace to stderr, or include it in JSON\n --stats Print iteration and triple counts to stderr\n --check Parse and analyze only; do not run rules\n --strict Treat static warnings as errors, including recursive term generation\n --deps Print rule dependency edges during --check\n --query TEXT Run a raw SRL body pattern over the closure\n --query-file FILE Read a raw SRL body pattern from a file\n --max-iterations N Stop after N fixpoint iterations within a recursive layer\n --no-imports Parse IMPORTS/owl:imports but do not load imported rule sets\n --rdf-messages Parse input as an RDF Message Log\n --stream-messages Replay RDF Message Log envelopes\n --include-message-facts Include payload facts while parsing RDF Message Logs\n --syntax MODE Use srl, rdf, or auto syntax detection (default auto)\n --ruleset TERM In RDF syntax, run only the selected srl:RuleSet\n --version Print version\n -h, --help Print this help\n\nWith no file arguments, eyeleng reads from stdin.\n`;
38
38
  }
39
39
 
40
40
  function parseArgs(argv) {
@@ -142,7 +142,7 @@ function printDependencies(analysis, prefixes, stderr) {
142
142
  for (const edge of edges) {
143
143
  const from = formatRuleName(analysis.dependency.rules[edge.from].name, prefixes);
144
144
  const to = formatRuleName(analysis.dependency.rules[edge.to].name, prefixes);
145
- const kind = edge.negative ? 'NOT' : 'uses';
145
+ const kind = edge.negated ? 'NOT' : (edge.termGeneration ? 'generates' : 'uses');
146
146
  stderr.write(`eyeleng: deps: ${from} --${kind} ${edge.predicate ? compactIRI(edge.predicate, prefixes) : '*'}--> ${to}\n`);
147
147
  }
148
148
  if (analysis.dependency.layers && analysis.dependency.layers.length > 0) {
package/src/engine.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const { TripleStore, bindingKey, instantiateTriple } = require('./store.js');
4
- const { tripleKey, termEquals } = require('./term.js');
3
+ const { TripleStore, bindingKey } = require('./store.js');
4
+ const { tripleKey, termKey, termEquals, blankNode, tripleTerm } = require('./term.js');
5
5
  const { evalExpression, booleanValue, asTerm } = require('./builtins.js');
6
6
  const { analyze } = require('./analyze.js');
7
7
 
@@ -21,7 +21,7 @@ function evaluate(program, options = {}) {
21
21
  runOnce: !!rule.runOnce,
22
22
  }));
23
23
 
24
- const analysis = options.analysis || analyze(program);
24
+ const analysis = options.analysis || analyze(program, options);
25
25
  if (analysis.errors && analysis.errors.length > 0 && !options.ignoreAnalysisErrors) {
26
26
  throw new Error(`Analysis failed: ${analysis.errors.map((error) => error.message).join('; ')}`);
27
27
  }
@@ -32,6 +32,9 @@ function evaluate(program, options = {}) {
32
32
  layerIndexes,
33
33
  analysis.dependency ? analysis.dependency.edges : [],
34
34
  );
35
+ const relaxedRecursiveRunOnce = options.relaxedRecursion === false
36
+ ? new Set()
37
+ : recursiveTermGenerationRuleIndexes(analysis);
35
38
  const baseContext = {
36
39
  ...evalOptions,
37
40
  maxIterations,
@@ -47,8 +50,8 @@ function evaluate(program, options = {}) {
47
50
 
48
51
  for (let layerIndex = 0; layerIndex < layerIndexes.length; layerIndex += 1) {
49
52
  const layer = layerIndexes[layerIndex];
50
- const ordinary = layer.filter((ruleIndex) => !program.rules[ruleIndex].runOnce);
51
- const runOnce = layer.filter((ruleIndex) => program.rules[ruleIndex].runOnce);
53
+ const ordinary = layer.filter((ruleIndex) => !program.rules[ruleIndex].runOnce || relaxedRecursiveRunOnce.has(ruleIndex));
54
+ const runOnce = layer.filter((ruleIndex) => program.rules[ruleIndex].runOnce && !relaxedRecursiveRunOnce.has(ruleIndex));
52
55
 
53
56
  if (runOnce.length > 0) {
54
57
  iterations += 1;
@@ -148,6 +151,7 @@ function applyRuleOnce(program, store, ruleIndex, context) {
148
151
  let added = 0;
149
152
  const dedupeBindings = rule.body.some((clause) => clause.type === 'path');
150
153
  const seenBindings = dedupeBindings ? new Set() : null;
154
+ const headBlankLabels = collectHeadBlankLabels(rule.head);
151
155
 
152
156
  const bodyBindings = rule.body.length === 1 && rule.body[0].type === 'triple'
153
157
  ? store.match(rule.body[0].triple, {})
@@ -162,8 +166,10 @@ function applyRuleOnce(program, store, ruleIndex, context) {
162
166
  applications += 1;
163
167
  context.perRule[ruleIndex].applications += 1;
164
168
 
169
+ const headBlankMap = headBlankLabels.size > 0 ? new Map() : null;
170
+ const skolemKey = headBlankMap ? skolemizationKey(ruleIndex, binding) : null;
165
171
  for (const head of rule.head) {
166
- const triple = instantiateTriple(head, binding);
172
+ const triple = instantiateHeadTriple(head, binding, headBlankLabels, headBlankMap, skolemKey);
167
173
  if (!triple) continue;
168
174
  if (store.add(triple)) {
169
175
  added += 1;
@@ -185,6 +191,94 @@ function applyRuleOnce(program, store, ruleIndex, context) {
185
191
  return { applications, added };
186
192
  }
187
193
 
194
+ function recursiveTermGenerationRuleIndexes(analysis) {
195
+ const out = new Set();
196
+ if (!analysis || !analysis.dependency || !analysis.diagnostics) return out;
197
+ const byName = new Map((analysis.dependency.rules || []).map((rule) => [rule.name, rule.index]));
198
+ for (const diagnostic of analysis.diagnostics) {
199
+ if (diagnostic.code !== 'recursive-assignment-rule') continue;
200
+ if (byName.has(diagnostic.rule)) out.add(byName.get(diagnostic.rule));
201
+ }
202
+ return out;
203
+ }
204
+
205
+ function instantiateHeadTriple(pattern, binding, headBlankLabels, headBlankMap, skolemKey) {
206
+ const s = instantiateHeadTerm(pattern.s, binding, headBlankLabels, headBlankMap, skolemKey);
207
+ const p = instantiateHeadTerm(pattern.p, binding, headBlankLabels, headBlankMap, skolemKey);
208
+ const o = instantiateHeadTerm(pattern.o, binding, headBlankLabels, headBlankMap, skolemKey);
209
+ if (!s || !p || !o) return null;
210
+ if (p.type !== 'iri') return null;
211
+ return { s, p, o };
212
+ }
213
+
214
+ function instantiateHeadTerm(term, binding, headBlankLabels, headBlankMap, skolemKey) {
215
+ if (term.type === 'var') return binding[term.value] || null;
216
+ if (term.type === 'blank' && headBlankLabels.has(term.value)) {
217
+ let label = headBlankMap.get(term.value);
218
+ if (!label) {
219
+ label = `sk_${deterministicSkolemIdFromKey(`${skolemKey}|${term.value}`).replace(/-/g, '_')}`;
220
+ headBlankMap.set(term.value, label);
221
+ }
222
+ return blankNode(label);
223
+ }
224
+ if (term.type === 'triple') {
225
+ const s = instantiateHeadTerm(term.s, binding, headBlankLabels, headBlankMap, skolemKey);
226
+ const p = instantiateHeadTerm(term.p, binding, headBlankLabels, headBlankMap, skolemKey);
227
+ const o = instantiateHeadTerm(term.o, binding, headBlankLabels, headBlankMap, skolemKey);
228
+ if (!s || !p || !o) return null;
229
+ return tripleTerm(s, p, o);
230
+ }
231
+ return term;
232
+ }
233
+
234
+ function collectHeadBlankLabels(head) {
235
+ const labels = new Set();
236
+ for (const triple of head || []) {
237
+ collectBlankLabelsFromTerm(triple.s, labels);
238
+ collectBlankLabelsFromTerm(triple.p, labels);
239
+ collectBlankLabelsFromTerm(triple.o, labels);
240
+ }
241
+ return labels;
242
+ }
243
+
244
+ function collectBlankLabelsFromTerm(term, labels) {
245
+ if (!term) return;
246
+ if (term.type === 'blank') {
247
+ labels.add(term.value);
248
+ return;
249
+ }
250
+ if (term.type === 'triple') {
251
+ collectBlankLabelsFromTerm(term.s, labels);
252
+ collectBlankLabelsFromTerm(term.p, labels);
253
+ collectBlankLabelsFromTerm(term.o, labels);
254
+ }
255
+ }
256
+
257
+ function skolemizationKey(ruleIndex, binding) {
258
+ let out = `rule:${ruleIndex}`;
259
+ for (const name of Object.keys(binding).sort()) {
260
+ const value = binding[name];
261
+ out += `|${name}=${value ? termKey(value) : 'unbound'}`;
262
+ }
263
+ return out;
264
+ }
265
+
266
+ function deterministicSkolemIdFromKey(key) {
267
+ let h1 = 0x811c9dc5;
268
+ let h2 = 0x811c9dc5;
269
+ let h3 = 0x811c9dc5;
270
+ let h4 = 0x811c9dc5;
271
+ for (let i = 0; i < key.length; i += 1) {
272
+ const c = key.charCodeAt(i);
273
+ h1 ^= c; h1 = Math.imul(h1, 0x01000193) >>> 0;
274
+ h2 ^= c + 1; h2 = Math.imul(h2, 0x01000193) >>> 0;
275
+ h3 ^= c + 2; h3 = Math.imul(h3, 0x01000193) >>> 0;
276
+ h4 ^= c + 3; h4 = Math.imul(h4, 0x01000193) >>> 0;
277
+ }
278
+ return [h1, h2, h3, h4].map((h) => h.toString(16).padStart(8, '0')).join('');
279
+ }
280
+
281
+
188
282
  function evaluateBody(clauses, store, initialBinding = {}, options = {}) {
189
283
  const bindings = [];
190
284
  const seen = new Set();
@@ -139,6 +139,10 @@ async function runSyntaxOrWellformedTest(test, options = {}) {
139
139
  filename: test.actionUrl,
140
140
  baseIRI: test.actionUrl,
141
141
  shacl12Conformance: true,
142
+ // The W3C manifest defines the strict, guaranteed-terminating conformance profile.
143
+ // Eyeleng's relaxed mode remains available through the normal API/CLI, but
144
+ // negative well-formedness tests for recursive term generation must fail here.
145
+ strict: true,
142
146
  };
143
147
 
144
148
  if (test.type.includes('Syntax')) {
@@ -176,6 +180,7 @@ async function runEvalTest(test, options = {}) {
176
180
  filename: test.rulesetUrl,
177
181
  baseIRI: test.rulesetUrl,
178
182
  shacl12Conformance: true,
183
+ strict: true,
179
184
  };
180
185
  const compiled = eyeleng.compile(rulesSource, compileOptions);
181
186
  const program = { ...compiled.program, data: [...compiled.program.data, ...dataTriples] };
package/test/api.test.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { test, main } = require('./harness.js').createHarness('API');
4
4
  const assert = require('node:assert/strict');
5
5
  const { parse, compile, run, runToString } = require('../src/index.js');
6
+ const { tripleKey } = require('../src/term.js');
6
7
 
7
8
  test('parse reads prefixes, data, and rules', () => {
8
9
  const program = parse(`
@@ -455,7 +456,70 @@ RULE { :s :p ?x } WHERE { :s :q ?x SET(?z := STR(?x)) }
455
456
  `, options));
456
457
  });
457
458
 
458
- main();
459
+
460
+ test('relaxed mode permits recursive deterministic assignments with max-iteration safety', () => {
461
+ const source = `
462
+ PREFIX : <http://example/>
463
+ DATA { :counter :value 0 . :limit :max 3 . }
464
+ RULE { :counter :value ?next } WHERE {
465
+ :counter :value ?value .
466
+ :limit :max ?max .
467
+ FILTER(?value < ?max)
468
+ SET(?next := ?value + 1)
469
+ }
470
+ `;
471
+ const compiled = compile(source, { shacl12Conformance: true, throwOnDiagnostics: false });
472
+ assert.equal(compiled.analysis.warnings[0].code, 'recursive-assignment-rule');
473
+ assert.throws(() => compile(source, { shacl12Conformance: true, strict: true }), /termination is not guaranteed/);
474
+ const output = runToString(source, { shacl12Conformance: true, maxIterations: 20 });
475
+ assert.match(output, /:counter :value 1 \./);
476
+ assert.match(output, /:counter :value 2 \./);
477
+ assert.match(output, /:counter :value 3 \./);
478
+ });
479
+
480
+ test('head blank nodes are deterministically skolemized with all universal bindings', () => {
481
+ const source = `
482
+ PREFIX : <http://example/>
483
+ DATA { :a :p :o . :b :p :o . }
484
+ RULE { [] :source ?s ; :value :o } WHERE { ?s :p :o }
485
+ `;
486
+ const first = run(source);
487
+ const second = run(source);
488
+ assert.deepEqual(first.inferred.map(tripleKey).sort(), second.inferred.map(tripleKey).sort());
489
+ const sourcePredicate = 'http://example/source';
490
+ const witnessSubjects = first.inferred
491
+ .filter((triple) => triple.p.type === 'iri' && triple.p.value === sourcePredicate)
492
+ .map((triple) => triple.s.value);
493
+ assert.equal(witnessSubjects.length, 2);
494
+ assert.equal(new Set(witnessSubjects).size, 2);
495
+ });
496
+
497
+
498
+ test('head blank skolemization is a standard function of all universals', () => {
499
+ const source = `
500
+ PREFIX : <http://example.org/#>
501
+ DATA { :s1 :p :o . :s2 :p :o . }
502
+ RULE { [] :witnessFor ?s } WHERE { ?s :p :o }
503
+ `;
504
+ const result = run(source, { maxIterations: 20, throwOnDiagnostics: false });
505
+ const witnessSubjects = result.inferred
506
+ .filter((triple) => triple.p.value === 'http://example.org/#witnessFor')
507
+ .map((triple) => triple.s.value);
508
+ assert.equal(witnessSubjects.length, 2);
509
+ assert.equal(new Set(witnessSubjects).size, 2);
510
+ });
511
+
512
+ test('recursive existential rules may not terminate with all-universal skolemization', () => {
513
+ const source = `
514
+ PREFIX : <http://example.org/#>
515
+ DATA { :s :p :o . }
516
+ RULE { [] :p :o } WHERE { ?s :p :o }
517
+ `;
518
+ assert.throws(
519
+ () => run(source, { maxIterations: 5, throwOnDiagnostics: false }),
520
+ /Reached maxIterations=5/,
521
+ );
522
+ });
459
523
 
460
524
  test('RDF Message Logs expose Eyeling-style envelopes and payload triples', () => {
461
525
  const messages = `VERSION "1.2-messages"
@@ -519,3 +583,20 @@ RULE { :test :annotation ?source } WHERE {
519
583
  assert.match(output, /:root :first 1 \./);
520
584
  assert.match(output, /:test :annotation :witness \./);
521
585
  });
586
+
587
+
588
+ test('strict conformance allows recursive constant BIND aliases', () => {
589
+ assert.doesNotThrow(() => compile(`
590
+ PREFIX : <http://example.org/#>
591
+ RULE { ?s ?p ?o } WHERE { ?s :p ?o . BIND(:p AS ?p) }
592
+ `, { shacl12Conformance: true, strict: true }));
593
+ });
594
+
595
+ test('strict conformance rejects recursive computed assignments', () => {
596
+ assert.throws(() => compile(`
597
+ PREFIX : <http://example.org/#>
598
+ RULE { ?x :p ?v1 } WHERE { ?x :p ?v . SET(?v1 := ?v + 1) }
599
+ `, { shacl12Conformance: true, strict: true }), /creates terms in a recursive dependency cycle/);
600
+ });
601
+
602
+ main();
package/test/run.js CHANGED
@@ -18,7 +18,6 @@ const preferred = [
18
18
  'browser-bundle.test.js',
19
19
  'examples.test.js',
20
20
  'shacl12-rules.test.js',
21
- 'w3c-rdf.test.js',
22
21
  ];
23
22
  const files = preferred.filter((name) => fs.existsSync(path.join(__dirname, name)));
24
23
 
package/tools/w3c-rdf.js CHANGED
@@ -8,7 +8,6 @@ const {
8
8
  runW3cRdfManifests,
9
9
  formatW3cRdfProgressLine,
10
10
  formatW3cRdfManifestsResult,
11
- rdfManifestsToEarl,
12
11
  writeRdfEarlReport,
13
12
  defaultRdfReportPath,
14
13
  } = require('../src/rdfManifest.js');
@@ -19,12 +18,18 @@ function argValue(argv, name) {
19
18
  return argv[index + 1] || null;
20
19
  }
21
20
 
21
+ function loggerForMode({ json, earl }) {
22
+ // Keep stdout machine-readable or empty in output modes. Progress still stays visible.
23
+ return json || earl ? console.error : console.log;
24
+ }
25
+
22
26
  async function main(argv = process.argv.slice(2)) {
23
27
  const json = argv.includes('--json');
24
28
  const earl = argv.includes('--earl');
25
29
  const noReport = argv.includes('--no-report');
26
- const quiet = json || earl || argv.includes('--quiet');
30
+ const quiet = argv.includes('--quiet');
27
31
  const output = argValue(argv, '--output') || defaultRdfReportPath();
32
+ const log = loggerForMode({ json, earl });
28
33
  const manifests = argv.filter((arg, index) => {
29
34
  if (arg.startsWith('--')) return false;
30
35
  if (argv[index - 1] === '--output') return false;
@@ -33,19 +38,18 @@ async function main(argv = process.argv.slice(2)) {
33
38
  const resources = manifests.length ? manifests : defaultW3cRdfManifestUrls;
34
39
  const result = await runW3cRdfManifests(resources, {
35
40
  onManifestStart(resource, index, total) {
36
- if (!quiet) console.log(`${C.y}==${C.n} W3C RDF manifest ${index + 1}/${total}: ${resource}`);
41
+ if (!quiet) log(`${C.y}==${C.n} W3C RDF manifest ${index + 1}/${total}: ${resource}`);
37
42
  },
38
43
  onProgress(item, index) {
39
- if (!quiet) console.log(formatW3cRdfProgressLine(item, index, { colors: C }));
44
+ if (!quiet) log(formatW3cRdfProgressLine(item, index, { colors: C }));
40
45
  },
41
46
  });
42
47
  if (!noReport) {
43
48
  const reportPath = writeRdfEarlReport(result, output, { assertedBy: '<https://github.com/eyereasoner/eyeleng>' });
44
- if (!quiet) console.log(`${C.dim}EARL report: ${path.relative(path.join(__dirname, '..'), reportPath)}${C.n}`);
49
+ if (!quiet) log(`${C.dim}EARL report: ${path.relative(path.join(__dirname, '..'), reportPath)}${C.n}`);
45
50
  }
46
51
  if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
47
- else if (earl) process.stdout.write(`${rdfManifestsToEarl(result, { assertedBy: '<https://github.com/eyereasoner/eyeleng>' })}\n`);
48
- else process.stdout.write(`${formatW3cRdfManifestsResult(result, { colors: C })}\n`);
52
+ else if (!earl) process.stdout.write(`${formatW3cRdfManifestsResult(result, { colors: C })}\n`);
49
53
  return result.counts.fail === 0 ? 0 : 1;
50
54
  }
51
55
 
@@ -8,7 +8,6 @@ const {
8
8
  runShacl12RulesManifest,
9
9
  formatShacl12RulesProgressLine,
10
10
  formatShacl12RulesManifestResult,
11
- shacl12RulesManifestToEarl,
12
11
  writeShacl12RulesEarlReport,
13
12
  defaultReportPath,
14
13
  } = require('../src/shacl12RulesManifest.js');
@@ -19,12 +18,18 @@ function argValue(argv, name) {
19
18
  return argv[index + 1] || null;
20
19
  }
21
20
 
21
+ function loggerForMode({ json, earl }) {
22
+ // Keep stdout machine-readable or empty in output modes. Progress still stays visible.
23
+ return json || earl ? console.error : console.log;
24
+ }
25
+
22
26
  async function main(argv = process.argv.slice(2)) {
23
27
  const json = argv.includes('--json');
24
28
  const earl = argv.includes('--earl');
25
29
  const noReport = argv.includes('--no-report');
26
- const quiet = json || earl || argv.includes('--quiet');
30
+ const quiet = argv.includes('--quiet');
27
31
  const output = argValue(argv, '--output') || defaultReportPath();
32
+ const log = loggerForMode({ json, earl });
28
33
  const manifests = argv.filter((arg, index) => {
29
34
  if (arg.startsWith('--')) return false;
30
35
  if (argv[index - 1] === '--output') return false;
@@ -32,20 +37,20 @@ async function main(argv = process.argv.slice(2)) {
32
37
  });
33
38
  const manifest = manifests[0] || process.env.EYELENG_SHACL12_RULES_MANIFEST || defaultShacl12RulesManifestUrl;
34
39
 
40
+ if (!quiet) log(`${C.y}==${C.n} W3C SHACL 1.2 Rules manifest: ${manifest}`);
35
41
  const result = await runShacl12RulesManifest(manifest, {
36
42
  onProgress(item, index) {
37
- if (!quiet) console.log(formatShacl12RulesProgressLine(item, index, { colors: C }));
43
+ if (!quiet) log(formatShacl12RulesProgressLine(item, index, { colors: C }));
38
44
  },
39
45
  });
40
46
 
41
47
  if (!noReport) {
42
48
  const reportPath = writeShacl12RulesEarlReport(result, output);
43
- if (!quiet) console.log(`${C.dim}EARL report: ${path.relative(path.join(__dirname, '..'), reportPath)}${C.n}`);
49
+ if (!quiet) log(`${C.dim}EARL report: ${path.relative(path.join(__dirname, '..'), reportPath)}${C.n}`);
44
50
  }
45
51
 
46
52
  if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
47
- else if (earl) process.stdout.write(`${shacl12RulesManifestToEarl(result)}\n`);
48
- else process.stdout.write(`${formatShacl12RulesManifestResult(result, { colors: C })}\n`);
53
+ else if (!earl) process.stdout.write(`${formatShacl12RulesManifestResult(result, { colors: C })}\n`);
49
54
  return result.counts.fail === 0 ? 0 : 1;
50
55
  }
51
56