@unrdf/hooks 26.4.3 → 26.4.4

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 (56) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +562 -53
  3. package/examples/atomvm-fibo-hooks-demo.mjs +323 -0
  4. package/examples/delta-monitoring-example.mjs +213 -0
  5. package/examples/fibo-jtbd-governance.mjs +388 -0
  6. package/examples/hook-chains/node_modules/.bin/jiti +0 -0
  7. package/examples/hook-chains/node_modules/.bin/msw +0 -0
  8. package/examples/hook-chains/node_modules/.bin/terser +0 -0
  9. package/examples/hook-chains/node_modules/.bin/tsc +0 -0
  10. package/examples/hook-chains/node_modules/.bin/tsserver +0 -0
  11. package/examples/hook-chains/node_modules/.bin/tsx +0 -0
  12. package/examples/hook-chains/node_modules/.bin/validate-hooks +0 -0
  13. package/examples/hook-chains/node_modules/.bin/vite +0 -0
  14. package/examples/hook-chains/node_modules/.bin/vitest +0 -0
  15. package/examples/hook-chains/node_modules/.bin/yaml +0 -0
  16. package/examples/hooks-marketplace.mjs +261 -0
  17. package/examples/n3-reasoning-example.mjs +279 -0
  18. package/examples/policy-hooks/node_modules/.bin/jiti +0 -0
  19. package/examples/policy-hooks/node_modules/.bin/msw +0 -0
  20. package/examples/policy-hooks/node_modules/.bin/terser +0 -0
  21. package/examples/policy-hooks/node_modules/.bin/tsc +0 -0
  22. package/examples/policy-hooks/node_modules/.bin/tsserver +0 -0
  23. package/examples/policy-hooks/node_modules/.bin/tsx +0 -0
  24. package/examples/policy-hooks/node_modules/.bin/validate-hooks +0 -0
  25. package/examples/policy-hooks/node_modules/.bin/vite +0 -0
  26. package/examples/policy-hooks/node_modules/.bin/vitest +0 -0
  27. package/examples/policy-hooks/node_modules/.bin/yaml +0 -0
  28. package/examples/shacl-repair-example.mjs +191 -0
  29. package/examples/window-condition-example.mjs +285 -0
  30. package/package.json +26 -23
  31. package/src/atomvm.mjs +9 -0
  32. package/src/define.mjs +114 -0
  33. package/src/executor.mjs +23 -0
  34. package/src/hooks/atomvm-bridge.mjs +332 -0
  35. package/src/hooks/builtin-hooks.mjs +13 -7
  36. package/src/hooks/condition-evaluator.mjs +684 -77
  37. package/src/hooks/define-hook.mjs +23 -23
  38. package/src/hooks/effect-executor.mjs +630 -0
  39. package/src/hooks/effect-sandbox.mjs +19 -9
  40. package/src/hooks/file-resolver.mjs +155 -1
  41. package/src/hooks/hook-chain-compiler.mjs +11 -1
  42. package/src/hooks/hook-executor.mjs +98 -73
  43. package/src/hooks/knowledge-hook-engine.mjs +133 -7
  44. package/src/hooks/ontology-learner.mjs +190 -0
  45. package/src/hooks/query.mjs +3 -3
  46. package/src/hooks/schemas.mjs +47 -3
  47. package/src/hooks/security/error-sanitizer.mjs +46 -24
  48. package/src/hooks/self-play-autonomics.mjs +423 -0
  49. package/src/hooks/telemetry.mjs +32 -9
  50. package/src/hooks/validate.mjs +100 -33
  51. package/src/index.mjs +2 -0
  52. package/src/lib/admit-hook.mjs +615 -0
  53. package/src/policy-compiler.mjs +12 -2
  54. package/dist/index.d.mts +0 -1738
  55. package/dist/index.d.ts +0 -1738
  56. package/dist/index.mjs +0 -1738
@@ -8,10 +8,363 @@
8
8
  */
9
9
 
10
10
  import { createFileResolver } from './file-resolver.mjs';
11
- import { ask, select } from './query.mjs';
11
+ import { ask, select, construct } from './query.mjs';
12
12
  import { validateShacl } from './validate.mjs';
13
13
  import { createQueryOptimizer } from './query-optimizer.mjs';
14
- import { createStore } from '../../../oxigraph/src/index.mjs';
14
+ import { createStore, dataFactory } from '../../../oxigraph/src/index.mjs';
15
+ import reasoner from 'eyereasoner';
16
+ import { Parser as SparqlParser, Generator as SparqlGenerator } from 'sparqljs';
17
+ import { z } from 'zod';
18
+
19
+ // ─── SPARQL Injection Prevention ────────────────────────────────────────────
20
+ const sparqlParser = new SparqlParser();
21
+ const sparqlGenerator = new SparqlGenerator();
22
+
23
+ /** Safe SPARQL variable name: letters, digits, underscore; must start with letter/underscore */
24
+ const SAFE_SPARQL_VAR_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
25
+
26
+ /**
27
+ * Zod schema for SPARQL parameter values.
28
+ * Allows: string, number, boolean, RDF NamedNode/BlankNode/Literal terms.
29
+ * Rejects: plain objects, arrays, functions, null, undefined.
30
+ */
31
+ export const SparqlParamSchema = z.union([
32
+ z.string(),
33
+ z.number().finite(),
34
+ z.boolean(),
35
+ z.object({
36
+ termType: z.enum(['NamedNode', 'BlankNode', 'Literal']),
37
+ value: z.string(),
38
+ }),
39
+ ]);
40
+
41
+ /**
42
+ * Validate that a string is a safe SPARQL variable name.
43
+ * @param {string} name - Variable name to validate
44
+ * @returns {string} The validated name
45
+ * @throws {Error} If name contains injection characters
46
+ */
47
+ export function validateSparqlVariableName(name) {
48
+ if (typeof name !== 'string' || !SAFE_SPARQL_VAR_RE.test(name)) {
49
+ throw new Error(
50
+ `Invalid SPARQL variable name: "${String(name)}". ` +
51
+ 'Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/.'
52
+ );
53
+ }
54
+ return name;
55
+ }
56
+
57
+ /**
58
+ * Validate and parse a SPARQL query string to prevent injection.
59
+ * Only allows read-only query types (SELECT, ASK, CONSTRUCT, DESCRIBE).
60
+ * Rejects UPDATE, SERVICE, and LOAD operations.
61
+ * @param {string} queryString - SPARQL query to validate
62
+ * @returns {object} Parsed query AST
63
+ * @throws {Error} If query is invalid or disallowed
64
+ */
65
+ export function validateSparqlQuery(queryString) {
66
+ if (typeof queryString !== 'string' || queryString.trim().length === 0) {
67
+ throw new Error('SPARQL query must be a non-empty string');
68
+ }
69
+
70
+ let parsed;
71
+ try {
72
+ parsed = sparqlParser.parse(queryString);
73
+ } catch (error) {
74
+ throw new Error(`Invalid SPARQL syntax: ${error.message}`);
75
+ }
76
+
77
+ // Reject UPDATE operations (INSERT DATA, DELETE, DROP, CLEAR, CREATE, LOAD)
78
+ if (parsed.type === 'update') {
79
+ throw new Error('SPARQL UPDATE operations are not allowed in condition queries');
80
+ }
81
+
82
+ // Walk AST JSON to reject SERVICE clauses (federated query escape)
83
+ const astJson = JSON.stringify(parsed);
84
+ if (astJson.includes('"type":"service"')) {
85
+ throw new Error('SERVICE clauses are not allowed in condition queries');
86
+ }
87
+
88
+ return parsed;
89
+ }
90
+
91
+ /**
92
+ * Safely bind parameter values into a SPARQL query via AST manipulation.
93
+ * @param {string} queryTemplate - SPARQL query with ?variable placeholders
94
+ * @param {Object<string, *>} params - Variable-name → value map
95
+ * @returns {string} Generated SPARQL with values bound
96
+ */
97
+ export function bindSparqlParams(queryTemplate, params = {}) {
98
+ if (!params || Object.keys(params).length === 0) {
99
+ return queryTemplate;
100
+ }
101
+
102
+ // Validate every parameter key and value
103
+ const replacements = new Map();
104
+ for (const [key, value] of Object.entries(params)) {
105
+ const varName = key.startsWith('?') ? key.slice(1) : key;
106
+ validateSparqlVariableName(varName);
107
+
108
+ const result = SparqlParamSchema.safeParse(value);
109
+ if (!result.success) {
110
+ throw new Error(
111
+ `Invalid SPARQL parameter value for ?${varName}: ${result.error.message}`
112
+ );
113
+ }
114
+ replacements.set(varName, toRdfTerm(value));
115
+ }
116
+
117
+ // Parse template → AST, replace variables, regenerate
118
+ const parsed = sparqlParser.parse(queryTemplate);
119
+ replaceVariablesInAst(parsed, replacements);
120
+ return sparqlGenerator.stringify(parsed);
121
+ }
122
+
123
+ /** Convert a JS value to a sparqljs-compatible RDF term node. */
124
+ function toRdfTerm(value) {
125
+ if (typeof value === 'string') {
126
+ return { termType: 'Literal', value };
127
+ }
128
+ if (typeof value === 'number') {
129
+ return {
130
+ termType: 'Literal',
131
+ value: String(value),
132
+ datatype: {
133
+ termType: 'NamedNode',
134
+ value: 'http://www.w3.org/2001/XMLSchema#decimal',
135
+ },
136
+ };
137
+ }
138
+ if (typeof value === 'boolean') {
139
+ return {
140
+ termType: 'Literal',
141
+ value: String(value),
142
+ datatype: {
143
+ termType: 'NamedNode',
144
+ value: 'http://www.w3.org/2001/XMLSchema#boolean',
145
+ },
146
+ };
147
+ }
148
+ if (value && value.termType) {
149
+ return value;
150
+ }
151
+ throw new Error(`Cannot convert value to RDF term: ${typeof value}`);
152
+ }
153
+
154
+ /** Recursively replace Variable nodes in a sparqljs AST. */
155
+ function replaceVariablesInAst(node, replacements) {
156
+ if (!node || typeof node !== 'object') return;
157
+ if (Array.isArray(node)) {
158
+ for (let i = 0; i < node.length; i++) {
159
+ if (node[i]?.termType === 'Variable' && replacements.has(node[i].value)) {
160
+ node[i] = replacements.get(node[i].value);
161
+ } else {
162
+ replaceVariablesInAst(node[i], replacements);
163
+ }
164
+ }
165
+ return;
166
+ }
167
+ for (const key of Object.keys(node)) {
168
+ const val = node[key];
169
+ if (val && typeof val === 'object') {
170
+ if (val.termType === 'Variable' && replacements.has(val.value)) {
171
+ node[key] = replacements.get(val.value);
172
+ } else {
173
+ replaceVariablesInAst(val, replacements);
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * SlidingWindow class for temporal event tracking
181
+ * Maintains a window of events and supports both time-based and event-based windowing
182
+ */
183
+ class SlidingWindow {
184
+ /** @type {number} Memory warning threshold in bytes (100MB) */
185
+ static MEMORY_WARN_BYTES = 100 * 1024 * 1024;
186
+ /** @type {number} Memory hard limit in bytes (500MB) */
187
+ static MEMORY_LIMIT_BYTES = 500 * 1024 * 1024;
188
+
189
+ /**
190
+ * @param {number} size - Window size (milliseconds for time-based, count for event-based)
191
+ * @param {number} [slide] - Slide amount (defaults to size for tumbling window)
192
+ * @param {boolean} [timeWindow=true] - If true, size is in milliseconds; if false, size is event count
193
+ * @param {number} [maxHistorySize=10000] - Maximum number of events to retain (LRU eviction)
194
+ */
195
+ constructor(size, slideAmount = size, timeWindow = true, maxHistorySize = 10000) {
196
+ this.size = size;
197
+ this.slideAmount = slideAmount;
198
+ this.timeWindow = timeWindow;
199
+ this.maxHistorySize = maxHistorySize;
200
+ this.events = []; // Array of { timestamp, value, data }
201
+ this.lastSlideTime = Date.now();
202
+
203
+ // Metrics tracking
204
+ this._metrics = {
205
+ totalEvictions: 0,
206
+ totalEventsAdded: 0,
207
+ peakEventCount: 0,
208
+ lastMemoryEstimate: 0,
209
+ memoryWarnings: 0,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Estimate approximate memory usage of the event queue in bytes.
215
+ * Each event object has: timestamp (8), value (variable), data (variable), index (8).
216
+ * We estimate ~200 bytes per event as a conservative baseline for object overhead + pointers.
217
+ * @returns {number} Estimated memory usage in bytes
218
+ */
219
+ estimateMemoryUsage() {
220
+ const perEventOverhead = 200;
221
+ const estimate = this.events.length * perEventOverhead;
222
+ this._metrics.lastMemoryEstimate = estimate;
223
+ return estimate;
224
+ }
225
+
226
+ /**
227
+ * Add an event to the window
228
+ * @param {*} value - The value to add
229
+ * @param {*} data - Additional data to track
230
+ * @throws {Error} If estimated memory exceeds 500MB hard limit
231
+ */
232
+ add(value, data = null) {
233
+ // Enforce maxHistorySize via LRU eviction (remove oldest first)
234
+ while (this.events.length >= this.maxHistorySize) {
235
+ this.events.shift();
236
+ this._metrics.totalEvictions++;
237
+ }
238
+
239
+ const now = Date.now();
240
+ this.events.push({
241
+ timestamp: now,
242
+ value,
243
+ data,
244
+ index: this._metrics.totalEventsAdded,
245
+ });
246
+ this._metrics.totalEventsAdded++;
247
+
248
+ // Track peak
249
+ if (this.events.length > this._metrics.peakEventCount) {
250
+ this._metrics.peakEventCount = this.events.length;
251
+ }
252
+
253
+ // Memory monitoring (check every 1000 events to avoid overhead)
254
+ if (this._metrics.totalEventsAdded % 1000 === 0) {
255
+ this._checkMemoryLimits();
256
+ }
257
+
258
+ // Clean up expired events
259
+ this.prune();
260
+ }
261
+
262
+ /**
263
+ * Check memory limits and warn/throw as appropriate
264
+ * @private
265
+ */
266
+ _checkMemoryLimits() {
267
+ const memEstimate = this.estimateMemoryUsage();
268
+
269
+ if (memEstimate > SlidingWindow.MEMORY_LIMIT_BYTES) {
270
+ throw new Error(
271
+ `SlidingWindow memory limit exceeded: ${(memEstimate / 1024 / 1024).toFixed(1)}MB > 500MB limit. ` +
272
+ `Events: ${this.events.length}, consider reducing maxHistorySize (current: ${this.maxHistorySize})`
273
+ );
274
+ }
275
+
276
+ if (memEstimate > SlidingWindow.MEMORY_WARN_BYTES) {
277
+ this._metrics.memoryWarnings++;
278
+ console.warn(
279
+ `[SlidingWindow] Memory warning: ~${(memEstimate / 1024 / 1024).toFixed(1)}MB used ` +
280
+ `(${this.events.length} events, limit: ${this.maxHistorySize})`
281
+ );
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Get current window contents
287
+ * @returns {Array} Events within current window
288
+ */
289
+ getWindow() {
290
+ const now = Date.now();
291
+ if (this.timeWindow) {
292
+ // Time-based window: keep events within [now - size, now)
293
+ const cutoff = now - this.size;
294
+ return this.events.filter(e => e.timestamp > cutoff);
295
+ }
296
+ // Event-based window: keep last 'size' events
297
+ if (this.events.length <= this.size) {
298
+ return this.events;
299
+ }
300
+ return this.events.slice(-this.size);
301
+ }
302
+
303
+ /**
304
+ * Remove events outside the window
305
+ */
306
+ prune() {
307
+ const now = Date.now();
308
+ if (this.timeWindow) {
309
+ const cutoff = now - this.size;
310
+ this.events = this.events.filter(e => e.timestamp > cutoff);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Slide the window forward
316
+ * @returns {boolean} True if window slid
317
+ */
318
+ slide() {
319
+ const now = Date.now();
320
+ const shouldSlide = now - this.lastSlideTime >= this.slideAmount;
321
+
322
+ if (shouldSlide) {
323
+ this.lastSlideTime = now;
324
+ this.prune();
325
+ return true;
326
+ }
327
+ return false;
328
+ }
329
+
330
+ /**
331
+ * Clear window state
332
+ */
333
+ clear() {
334
+ this.events = [];
335
+ this.lastSlideTime = Date.now();
336
+ }
337
+
338
+ /**
339
+ * Get window size (number of events in current window)
340
+ * @returns {number}
341
+ */
342
+ length() {
343
+ return this.getWindow().length;
344
+ }
345
+
346
+ /**
347
+ * Get telemetry metrics for this window
348
+ * @returns {Object} Metrics snapshot
349
+ */
350
+ getMetrics() {
351
+ return {
352
+ totalEvictions: this._metrics.totalEvictions,
353
+ totalEventsAdded: this._metrics.totalEventsAdded,
354
+ currentEventCount: this.events.length,
355
+ peakEventCount: this._metrics.peakEventCount,
356
+ estimatedMemoryBytes: this.estimateMemoryUsage(),
357
+ maxHistorySize: this.maxHistorySize,
358
+ memoryWarnings: this._metrics.memoryWarnings,
359
+ };
360
+ }
361
+ }
362
+
363
+ // Export SlidingWindow for testing and external use
364
+ export { SlidingWindow };
365
+
366
+ // Global window state storage (keyed by condition ID)
367
+ const _windowStateMap = new WeakMap();
15
368
 
16
369
  /**
17
370
  * Evaluate a hook condition against a graph.
@@ -51,6 +404,8 @@ export async function evaluateCondition(condition, graph, options = {}) {
51
404
  return await evaluateCount(condition, graph, resolver, env, options);
52
405
  case 'window':
53
406
  return await evaluateWindow(condition, graph, resolver, env, options);
407
+ case 'n3':
408
+ return await evaluateN3(condition, graph, resolver, env);
54
409
  default:
55
410
  throw new Error(`Unsupported condition kind: ${condition.kind}`);
56
411
  }
@@ -130,30 +485,188 @@ async function evaluateSparqlSelect(condition, graph, resolver, env) {
130
485
  }
131
486
 
132
487
  /**
133
- * Evaluate a SHACL validation condition.
488
+ * Evaluate a SHACL validation condition with enforcement modes.
489
+ *
490
+ * Enforcement modes:
491
+ * - 'block' (default): Fail if validation fails. Return false.
492
+ * - 'annotate': Allow write with annotation. Add SHACL report as RDF triples. Return true.
493
+ * - 'repair': Execute repairConstruct query if validation fails. Re-validate. Return result.
494
+ *
134
495
  * @param {Object} condition - The condition definition
135
496
  * @param {Store} graph - The RDF graph
136
497
  * @param {Object} resolver - File resolver instance
137
498
  * @param {Object} env - Environment variables
138
- * @returns {Promise<Object>} SHACL validation result
499
+ * @returns {Promise<Object|boolean>} SHACL validation result or boolean depending on enforcement mode
139
500
  */
140
501
  async function evaluateShacl(condition, graph, resolver, env) {
141
- const { ref } = condition;
502
+ const { ref, enforcementMode = 'block', repairConstruct } = condition;
142
503
 
143
- if (!ref || !ref.uri || !ref.sha256) {
144
- throw new Error('SHACL condition requires ref with uri and sha256');
504
+ if (!ref || !ref.uri) {
505
+ throw new Error('SHACL condition requires ref with uri');
145
506
  }
146
507
 
147
508
  // Load SHACL shapes file
148
509
  const { turtle } = await resolver.loadShacl(ref.uri, ref.sha256);
149
510
 
150
511
  // Execute SHACL validation
151
- const report = validateShacl(graph, turtle, {
512
+ const report = await validateShacl(graph, turtle, {
152
513
  strict: env.strictMode || false,
153
514
  includeDetails: true,
154
515
  });
155
516
 
156
- return report;
517
+ const isValid = report.conforms === true;
518
+
519
+ // Dispatch based on enforcement mode
520
+ switch (enforcementMode) {
521
+ case 'block':
522
+ // Default behavior: return report (caller checks conforms flag)
523
+ return report;
524
+
525
+ case 'annotate': {
526
+ // If validation fails, add SHACL report as RDF triples to store
527
+ if (!isValid) {
528
+ try {
529
+ // Serialize SHACL report to RDF format
530
+ const reportTriples = serializeShaclReport(report);
531
+
532
+ // Add report triples to the store
533
+ for (const triple of reportTriples) {
534
+ graph.add(triple);
535
+ }
536
+
537
+ // Log annotation
538
+ if (env.logAnnotations) {
539
+ console.log(
540
+ `[SHACL Annotation] Added ${reportTriples.length} violation triples to store`
541
+ );
542
+ }
543
+ } catch (error) {
544
+ console.warn(`Failed to add SHACL annotation: ${error.message}`);
545
+ }
546
+ }
547
+
548
+ // Return true to allow write (with or without annotation)
549
+ return true;
550
+ }
551
+
552
+ case 'repair': {
553
+ // If validation fails and repair query provided, attempt repair
554
+ if (!isValid && repairConstruct) {
555
+ try {
556
+ // Execute repair SPARQL CONSTRUCT query to get repair quads
557
+ const repairQuads = await construct(graph, repairConstruct, { env });
558
+
559
+ // Apply repaired quads to the store
560
+ let quadsApplied = 0;
561
+ for (const quad of repairQuads) {
562
+ graph.add(quad);
563
+ quadsApplied++;
564
+ }
565
+
566
+ // Log repair application
567
+ if (env.logRepair) {
568
+ console.log(`[SHACL Repair] Applied ${quadsApplied} repair quads to store`);
569
+ }
570
+
571
+ // Re-validate after repair with updated graph
572
+ const revalidateReport = await validateShacl(graph, turtle, {
573
+ strict: env.strictMode || false,
574
+ includeDetails: true,
575
+ });
576
+
577
+ // Log revalidation result
578
+ if (env.logRepair) {
579
+ console.log(
580
+ `[SHACL Repair] Revalidation result: ${revalidateReport.conforms ? 'conforms' : 'violations remain'}`
581
+ );
582
+ }
583
+
584
+ // Return re-validation result
585
+ return revalidateReport.conforms === true;
586
+ } catch (error) {
587
+ // Repair failed, return original validation result
588
+ console.warn(`SHACL repair failed: ${error.message}`);
589
+ return false;
590
+ }
591
+ }
592
+
593
+ // No repair attempted, return validation result
594
+ return isValid;
595
+ }
596
+
597
+ default:
598
+ // Unknown enforcement mode, default to block
599
+ return report;
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Serialize SHACL validation report to RDF triples.
605
+ * Converts violations into RDF quads that can be added to store.
606
+ *
607
+ * @param {Object} report - SHACL validation report
608
+ * @returns {Array} Array of valid RDF quads
609
+ */
610
+ export function serializeShaclReport(report) {
611
+ const quads = [];
612
+
613
+ if (!report.results || report.results.length === 0) {
614
+ return quads;
615
+ }
616
+
617
+ // SHACL vocabulary IRIs
618
+ const SHACL_NS = 'http://www.w3.org/ns/shacl#';
619
+ const RDF_NS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
620
+
621
+ // Severity level URIs
622
+ const SEVERITY_URIS = {
623
+ violation: `${SHACL_NS}Violation`,
624
+ warning: `${SHACL_NS}Warning`,
625
+ info: `${SHACL_NS}Info`,
626
+ };
627
+
628
+ // For each violation result, create RDF representation
629
+ for (let i = 0; i < report.results.length; i++) {
630
+ const result = report.results[i];
631
+
632
+ // Map severity string to SHACL severity URI
633
+ const severityUri = SEVERITY_URIS[result.severity] || SEVERITY_URIS.violation;
634
+
635
+ // Create unique URI for this validation result
636
+ const resultUri = `http://example.com/validation/result-${i}`;
637
+ const resultNode = dataFactory.namedNode(resultUri);
638
+
639
+ // Triple 1: result rdf:type sh:ValidationResult
640
+ quads.push(
641
+ dataFactory.quad(
642
+ resultNode,
643
+ dataFactory.namedNode(`${RDF_NS}type`),
644
+ dataFactory.namedNode(`${SHACL_NS}ValidationResult`)
645
+ )
646
+ );
647
+
648
+ // Triple 2: result sh:resultMessage "message"
649
+ if (result.message) {
650
+ quads.push(
651
+ dataFactory.quad(
652
+ resultNode,
653
+ dataFactory.namedNode(`${SHACL_NS}resultMessage`),
654
+ dataFactory.literal(result.message)
655
+ )
656
+ );
657
+ }
658
+
659
+ // Triple 3: result sh:resultSeverity sh:Violation (or Warning/Info)
660
+ quads.push(
661
+ dataFactory.quad(
662
+ resultNode,
663
+ dataFactory.namedNode(`${SHACL_NS}resultSeverity`),
664
+ dataFactory.namedNode(severityUri)
665
+ )
666
+ );
667
+ }
668
+
669
+ return quads;
157
670
  }
158
671
 
159
672
  /**
@@ -363,9 +876,17 @@ export function validateCondition(condition) {
363
876
  }
364
877
 
365
878
  if (
366
- !['sparql-ask', 'sparql-select', 'shacl', 'delta', 'threshold', 'count', 'window'].includes(
367
- condition.kind
368
- )
879
+ ![
880
+ 'sparql-ask',
881
+ 'sparql-select',
882
+ 'shacl',
883
+ 'delta',
884
+ 'threshold',
885
+ 'count',
886
+ 'window',
887
+ 'n3',
888
+ 'datalog',
889
+ ].includes(condition.kind)
369
890
  ) {
370
891
  return {
371
892
  valid: false,
@@ -373,14 +894,21 @@ export function validateCondition(condition) {
373
894
  };
374
895
  }
375
896
 
376
- // Support both file reference (ref) and inline content (query/shapes)
897
+ // Support both file reference (ref) and inline content (query/shapes/facts/goal/rules)
377
898
  const hasRef = condition.ref && condition.ref.uri;
378
- const hasInline = condition.query || condition.shapes;
899
+ const hasInline =
900
+ condition.query ||
901
+ condition.shapes ||
902
+ condition.facts ||
903
+ condition.goal ||
904
+ condition.rules ||
905
+ condition.askQuery;
379
906
 
380
907
  if (!hasRef && !hasInline) {
381
908
  return {
382
909
  valid: false,
383
- error: 'Condition must have either ref (file reference) or query/shapes (inline content)',
910
+ error:
911
+ 'Condition must have either ref (file reference) or inline content (query/shapes/facts/goal/rules)',
384
912
  };
385
913
  }
386
914
 
@@ -493,17 +1021,19 @@ async function evaluateDelta(condition, graph, resolver, env, options) {
493
1021
  if (baselineHash && currentHash !== baselineHash) {
494
1022
  changeMagnitude = 1.0; // Full change detected
495
1023
  } else if (options.delta) {
496
- // Calculate change based on delta size
1024
+ // Calculate change based on delta composition
1025
+ // Positive = more additions (increase), Negative = more removals (decrease)
497
1026
  const totalQuads = graph.size;
498
- const deltaSize =
499
- (options.delta.additions?.length || 0) + (options.delta.removals?.length || 0);
500
- changeMagnitude = totalQuads > 0 ? deltaSize / totalQuads : 0;
1027
+ const additions = options.delta.additions?.length || 0;
1028
+ const removals = options.delta.removals?.length || 0;
1029
+ const netChange = additions - removals;
1030
+ changeMagnitude = totalQuads > 0 ? netChange / totalQuads : 0;
501
1031
  }
502
1032
 
503
1033
  // Evaluate change type
504
1034
  switch (change) {
505
1035
  case 'any':
506
- return changeMagnitude > 0;
1036
+ return changeMagnitude !== 0;
507
1037
  case 'increase':
508
1038
  return changeMagnitude > threshold;
509
1039
  case 'decrease':
@@ -528,7 +1058,10 @@ async function evaluateThreshold(condition, graph, _resolver, _env, _options) {
528
1058
  const { spec } = condition;
529
1059
  const { var: variable, op, value, aggregate = 'avg' } = spec;
530
1060
 
531
- // Execute query to get values
1061
+ // Validate variable name to prevent SPARQL injection
1062
+ validateSparqlVariableName(variable);
1063
+
1064
+ // Execute query to get values (variable is now guaranteed safe)
532
1065
  const query = `
533
1066
  SELECT ?${variable} WHERE {
534
1067
  ?s ?p ?${variable}
@@ -609,7 +1142,8 @@ async function evaluateCount(condition, graph, _resolver, _env, _options) {
609
1142
  let count;
610
1143
 
611
1144
  if (countQuery) {
612
- // Use custom query for counting
1145
+ // Validate query to prevent SPARQL injection
1146
+ validateSparqlQuery(countQuery);
613
1147
  const results = await select(graph, countQuery);
614
1148
  count = results.length;
615
1149
  } else {
@@ -645,69 +1179,142 @@ async function evaluateCount(condition, graph, _resolver, _env, _options) {
645
1179
  * @param {Object} options - Evaluation options
646
1180
  * @returns {Promise<boolean>} Window condition result
647
1181
  */
648
- async function evaluateWindow(condition, graph, _resolver, _env, _options) {
649
- const { spec } = condition;
650
- const { size, _slide = size, aggregate, query: windowQuery } = spec;
651
-
652
- // For now, implement a simple window evaluation
653
- // In a full implementation, this would maintain sliding windows over time
654
-
655
- if (windowQuery) {
656
- const results = await select(graph, windowQuery);
657
-
658
- // Calculate aggregate over results
659
- let aggregateValue;
660
- switch (aggregate) {
661
- case 'sum':
662
- aggregateValue = results.reduce((sum, r) => {
663
- const val = parseFloat(Object.values(r)[0]?.value || 0);
664
- return sum + (isNaN(val) ? 0 : val);
665
- }, 0);
666
- break;
667
- case 'avg':
668
- const sum = results.reduce((sum, r) => {
669
- const val = parseFloat(Object.values(r)[0]?.value || 0);
670
- return sum + (isNaN(val) ? 0 : val);
671
- }, 0);
672
- aggregateValue = results.length > 0 ? sum / results.length : 0;
673
- break;
674
- case 'min':
675
- aggregateValue = Math.min(
676
- ...results.map(r => {
677
- const val = parseFloat(Object.values(r)[0]?.value || Infinity);
678
- return isNaN(val) ? Infinity : val;
679
- })
680
- );
681
- break;
682
- case 'max':
683
- aggregateValue = Math.max(
684
- ...results.map(r => {
685
- const val = parseFloat(Object.values(r)[0]?.value || -Infinity);
686
- return isNaN(val) ? -Infinity : val;
687
- })
688
- );
689
- break;
690
- case 'count':
691
- aggregateValue = results.length;
692
- break;
693
- default:
694
- aggregateValue = results.length;
1182
+ async function evaluateWindow(condition, graph, _resolver, _env, _options = {}) {
1183
+ const { spec, id } = condition;
1184
+ if (!spec) {
1185
+ throw new Error('Window condition requires a spec property');
1186
+ }
1187
+
1188
+ const { size, slide = size, aggregate = 'count', query: windowQuery } = spec;
1189
+
1190
+ if (!size || size <= 0) {
1191
+ throw new Error('Window condition spec.size must be positive');
1192
+ }
1193
+
1194
+ // Get or create window state storage from options
1195
+ let windowState = _options.windowState;
1196
+ if (!windowState) {
1197
+ windowState = new Map();
1198
+ if (_options) {
1199
+ _options.windowState = windowState;
695
1200
  }
1201
+ }
1202
+
1203
+ // Use condition ID or a hash as key; fallback to stringified spec
1204
+ const stateKey = id || JSON.stringify(spec);
1205
+
1206
+ // Get or create sliding window instance
1207
+ let window = windowState.get(stateKey);
1208
+ if (!window) {
1209
+ // Assume time-based window (size is in milliseconds)
1210
+ window = new SlidingWindow(size, slide, true);
1211
+ windowState.set(stateKey, window);
1212
+ }
1213
+
1214
+ // Execute the window query to get values
1215
+ if (!windowQuery || typeof windowQuery !== 'string') {
1216
+ throw new Error('Window condition requires a query property');
1217
+ }
1218
+
1219
+ // Validate query to prevent SPARQL injection
1220
+ validateSparqlQuery(windowQuery);
1221
+
1222
+ const results = await select(graph, windowQuery);
1223
+
1224
+ // Extract numeric values from results
1225
+ const values = results
1226
+ .map(r => {
1227
+ // Get first binding value
1228
+ const firstValue = Object.values(r)[0];
1229
+ if (!firstValue) return null;
1230
+
1231
+ const val = parseFloat(firstValue.value);
1232
+ return isNaN(val) ? null : val;
1233
+ })
1234
+ .filter(v => v !== null);
1235
+
1236
+ // Add values to window
1237
+ for (const val of values) {
1238
+ window.add(val, { timestamp: Date.now() });
1239
+ }
1240
+
1241
+ // Slide window if needed
1242
+ window.slide();
1243
+
1244
+ // Get window contents for aggregation
1245
+ const windowContents = window.getWindow();
1246
+
1247
+ // Calculate aggregate over window contents
1248
+ let aggregateValue;
1249
+ switch (aggregate) {
1250
+ case 'sum':
1251
+ aggregateValue = windowContents.reduce((sum, e) => sum + (e.value || 0), 0);
1252
+ break;
1253
+ case 'avg':
1254
+ if (windowContents.length === 0) {
1255
+ aggregateValue = 0;
1256
+ } else {
1257
+ const sum = windowContents.reduce((s, e) => s + (e.value || 0), 0);
1258
+ aggregateValue = sum / windowContents.length;
1259
+ }
1260
+ break;
1261
+ case 'min':
1262
+ aggregateValue =
1263
+ windowContents.length > 0 ? Math.min(...windowContents.map(e => e.value)) : Infinity;
1264
+ break;
1265
+ case 'max':
1266
+ aggregateValue =
1267
+ windowContents.length > 0 ? Math.max(...windowContents.map(e => e.value)) : -Infinity;
1268
+ break;
1269
+ case 'count':
1270
+ aggregateValue = windowContents.length;
1271
+ break;
1272
+ default:
1273
+ aggregateValue = windowContents.length;
1274
+ }
696
1275
 
697
- // For window conditions, we typically check if aggregate exceeds threshold
698
- // This is a simplified implementation
699
- return aggregateValue > 0;
1276
+ // Window conditions typically check if aggregate meets a threshold
1277
+ // Return true if we have data in the window
1278
+ // For rate limiting scenarios, check maxMatches if provided in original API
1279
+ const maxMatches = spec.maxMatches;
1280
+ if (maxMatches !== undefined && maxMatches !== null) {
1281
+ return aggregateValue <= maxMatches;
700
1282
  }
701
1283
 
702
- // Default: check if graph has any data in the window
703
- return graph.size > 0;
1284
+ // Default: true if window has content
1285
+ return aggregateValue > 0;
704
1286
  }
705
1287
 
706
1288
  /**
707
- * Hash a store for delta comparison
708
- * @param {Store} store - RDF store
709
- * @returns {Promise<string>} Store hash
1289
+ * Evaluate an N3 forward-chaining condition via EYE reasoner
1290
+ * @param {Object} condition - The condition definition
1291
+ * @param {Store} graph - The RDF graph
1292
+ * @param {Object} resolver - File resolver instance
1293
+ * @param {Object} env - Environment variables
1294
+ * @returns {Promise<boolean>} N3 condition result
710
1295
  */
1296
+ async function evaluateN3(condition, graph, resolver, env) {
1297
+ const { rules, askQuery } = condition;
1298
+
1299
+ if (!rules || !askQuery) {
1300
+ throw new Error('N3 condition requires both rules and askQuery properties');
1301
+ }
1302
+
1303
+ // Serialize store to N-Quads
1304
+ const dataN3 = await graph.dump({ format: 'application/n-quads' });
1305
+
1306
+ // Run through EYE reasoner
1307
+ const entailedData = await reasoner(dataN3 + '\n\n' + rules);
1308
+
1309
+ // Parse result into temp store
1310
+ const entailedStore = createStore();
1311
+ await entailedStore.load(entailedData, { format: 'application/n-quads' });
1312
+
1313
+ // Evaluate SPARQL ASK over entailed graph
1314
+ const result = await ask(entailedStore, askQuery, { env });
1315
+
1316
+ return result;
1317
+ }
711
1318
  async function hashStore(store) {
712
1319
  // Simple hash implementation - in production, use proper canonicalization
713
1320
  const quads = Array.from(store);