@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.
- package/LICENSE +24 -0
- package/README.md +566 -53
- package/examples/atomvm-fibo-hooks-demo.mjs +323 -0
- package/examples/delta-monitoring-example.mjs +213 -0
- package/examples/fibo-jtbd-governance.mjs +388 -0
- package/examples/hook-chains/node_modules/.bin/jiti +0 -0
- package/examples/hook-chains/node_modules/.bin/msw +0 -0
- package/examples/hook-chains/node_modules/.bin/terser +0 -0
- package/examples/hook-chains/node_modules/.bin/tsc +0 -0
- package/examples/hook-chains/node_modules/.bin/tsserver +0 -0
- package/examples/hook-chains/node_modules/.bin/tsx +0 -0
- package/examples/hook-chains/node_modules/.bin/validate-hooks +0 -0
- package/examples/hook-chains/node_modules/.bin/vite +0 -0
- package/examples/hook-chains/node_modules/.bin/vitest +0 -0
- package/examples/hook-chains/node_modules/.bin/yaml +0 -0
- package/examples/hook-chains/package.json +2 -2
- package/examples/hooks-marketplace.mjs +261 -0
- package/examples/n3-reasoning-example.mjs +279 -0
- package/examples/policy-hooks/README.md +5 -9
- package/examples/policy-hooks/node_modules/.bin/jiti +0 -0
- package/examples/policy-hooks/node_modules/.bin/msw +0 -0
- package/examples/policy-hooks/node_modules/.bin/terser +0 -0
- package/examples/policy-hooks/node_modules/.bin/tsc +0 -0
- package/examples/policy-hooks/node_modules/.bin/tsserver +0 -0
- package/examples/policy-hooks/node_modules/.bin/tsx +0 -0
- package/examples/policy-hooks/node_modules/.bin/validate-hooks +0 -0
- package/examples/policy-hooks/node_modules/.bin/vite +0 -0
- package/examples/policy-hooks/node_modules/.bin/vitest +0 -0
- package/examples/policy-hooks/node_modules/.bin/yaml +0 -0
- package/examples/policy-hooks/package.json +2 -2
- package/examples/shacl-repair-example.mjs +191 -0
- package/examples/window-condition-example.mjs +285 -0
- package/package.json +27 -23
- package/src/atomvm.mjs +9 -0
- package/src/define.mjs +114 -0
- package/src/executor.mjs +23 -0
- package/src/hooks/atomvm-bridge.mjs +332 -0
- package/src/hooks/builtin-hooks.mjs +17 -9
- package/src/hooks/condition-evaluator.mjs +681 -77
- package/src/hooks/define-hook.mjs +23 -23
- package/src/hooks/effect-executor.mjs +618 -0
- package/src/hooks/effect-sandbox.mjs +19 -9
- package/src/hooks/file-resolver.mjs +155 -1
- package/src/hooks/hook-chain-compiler.mjs +10 -1
- package/src/hooks/hook-executor.mjs +102 -73
- package/src/hooks/knowledge-hook-engine.mjs +133 -7
- package/src/hooks/ontology-learner.mjs +190 -0
- package/src/hooks/query.mjs +3 -3
- package/src/hooks/schemas.mjs +47 -3
- package/src/hooks/security/error-sanitizer.mjs +46 -24
- package/src/hooks/self-play-autonomics.mjs +464 -0
- package/src/hooks/telemetry.mjs +32 -9
- package/src/hooks/validate.mjs +100 -33
- package/src/index.mjs +2 -0
- package/src/lib/admit-hook.mjs +615 -0
- package/src/policy-compiler.mjs +12 -2
- package/dist/index.d.mts +0 -1738
- package/dist/index.d.ts +0 -1738
- 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
|
|
144
|
-
throw new Error('SHACL condition requires ref with uri
|
|
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
|
-
|
|
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
|
-
![
|
|
367
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
1021
|
+
// Calculate change based on delta composition
|
|
1022
|
+
// Positive = more additions (increase), Negative = more removals (decrease)
|
|
497
1023
|
const totalQuads = graph.size;
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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:
|
|
703
|
-
return
|
|
1281
|
+
// Default: true if window has content
|
|
1282
|
+
return aggregateValue > 0;
|
|
704
1283
|
}
|
|
705
1284
|
|
|
706
1285
|
/**
|
|
707
|
-
*
|
|
708
|
-
* @param {
|
|
709
|
-
* @
|
|
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);
|