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