eyeling 1.28.0 → 1.28.2

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/lib/lexer.js CHANGED
@@ -515,11 +515,114 @@ function normalizeRdfCompatibility(inputText) {
515
515
  return text.slice(at, j);
516
516
  }
517
517
 
518
+ function readBalancedTermAt(text, at) {
519
+ const open = text[at];
520
+ const matching = { '{': '}', '[': ']', '(': ')' };
521
+ if (!matching[open]) return null;
522
+
523
+ const stack = [matching[open]];
524
+ let j = at + 1;
525
+ while (j < text.length) {
526
+ const ch = text[j];
527
+ if (ch === '"' || ch === "'") {
528
+ const str = readStringAt(text, j);
529
+ j = str.end;
530
+ continue;
531
+ }
532
+ if (ch === '<' && !text.startsWith('<<', j)) {
533
+ const iri = readIriAt(text, j);
534
+ j = iri.end;
535
+ continue;
536
+ }
537
+ if (ch === '#') {
538
+ while (j < text.length && text[j] !== '\n' && text[j] !== '\r') j += 1;
539
+ continue;
540
+ }
541
+ if (matching[ch]) {
542
+ stack.push(matching[ch]);
543
+ j += 1;
544
+ continue;
545
+ }
546
+ if (ch === stack[stack.length - 1]) {
547
+ stack.pop();
548
+ j += 1;
549
+ if (stack.length === 0) return { text: text.slice(at, j), end: j };
550
+ continue;
551
+ }
552
+ j += 1;
553
+ }
554
+
555
+ throw new N3SyntaxError(`Unterminated term inside RDF 1.2 triple term, expected ${stack[stack.length - 1]}`);
556
+ }
557
+
558
+ function readRdfTripleTermComponent(text, at) {
559
+ const j = skipWsAndComments(text, at);
560
+ if (j >= text.length) return null;
561
+ const ch = text[j];
562
+
563
+ if (ch === '<') return readIriAt(text, j);
564
+
565
+ if (ch === '"' || ch === "'") {
566
+ const str = readStringAt(text, j);
567
+ let end = str.end;
568
+ let termText = str.text;
569
+ if (text.startsWith('^^', end)) {
570
+ const datatype = readRdfTripleTermComponent(text, end + 2);
571
+ if (datatype) {
572
+ termText += '^^' + datatype.text;
573
+ end = datatype.end;
574
+ }
575
+ } else if (text[end] === '@') {
576
+ let k = end + 1;
577
+ if (/[A-Za-z]/.test(text[k] || '')) {
578
+ while (k < text.length && /[A-Za-z0-9-]/.test(text[k])) k += 1;
579
+ termText += text.slice(end, k);
580
+ end = k;
581
+ }
582
+ }
583
+ return { text: termText, end };
584
+ }
585
+
586
+ if (ch === '{' || ch === '[' || ch === '(') return readBalancedTermAt(text, j);
587
+
588
+ let k = j;
589
+ while (k < text.length && !/\s/.test(text[k]) && !'{}[](),;'.includes(text[k])) k += 1;
590
+ if (k === j) return null;
591
+ const value = text.slice(j, k);
592
+ if (!value || value.startsWith('@')) return null;
593
+ return { text: value, end: k };
594
+ }
595
+
596
+ function validateSingleRdfTripleTerm(rawTriple) {
597
+ const triple = rawTriple.trim();
598
+ if (!triple) throw new N3SyntaxError('RDF 1.2 triple term must contain exactly one subject, predicate, and object');
599
+
600
+ let pos = 0;
601
+ for (const label of ['subject', 'predicate', 'object']) {
602
+ const term = readRdfTripleTermComponent(triple, pos);
603
+ if (!term) throw new N3SyntaxError(`RDF 1.2 triple term is missing a ${label}`);
604
+ pos = term.end;
605
+ }
606
+
607
+ const rest = skipWsAndComments(triple, pos);
608
+ if (rest >= triple.length) return;
609
+
610
+ const found = triple[rest];
611
+ if (found === ',') {
612
+ throw new N3SyntaxError("RDF 1.2 triple terms must contain exactly one object; object lists using ',' are not valid inside <<( ... )>>");
613
+ }
614
+ if (found === ';') {
615
+ throw new N3SyntaxError("RDF 1.2 triple terms must contain exactly one predicate-object pair; ';' is not valid inside <<( ... )>>");
616
+ }
617
+ throw new N3SyntaxError(`RDF 1.2 triple term must contain exactly one subject, predicate, and object; unexpected ${JSON.stringify(triple.slice(rest, rest + 20))}`);
618
+ }
619
+
518
620
  function graphTermFromTripleBody(rawBody, parenthesized) {
519
621
  let body = rawBody.trim();
520
622
  if (parenthesized && body.startsWith('(') && body.endsWith(')')) body = body.slice(1, -1).trim();
521
623
  const split = splitTopLevelReifier(body);
522
624
  const triple = split.triple;
625
+ validateSingleRdfTripleTerm(triple);
523
626
  const graph = '{ ' + triple + ' }';
524
627
  if (split.reifier) {
525
628
  const reifier = firstTerm(split.reifier);
@@ -0,0 +1,228 @@
1
+ # RDF Message Logs internals
2
+
3
+ Eyeling handles RDF Message Logs as an RDF-compatibility normalization step. A message log is not parsed as one flat graph first; it is split into message chunks and rewritten into ordinary N3 facts that describe the stream, its ordered message envelopes, and each message payload graph.
4
+
5
+ There are two execution paths:
6
+
7
+ - normal RDF mode, where a whole message log is normalized into one replay document;
8
+ - `--stream-messages`, where the CLI reads one message at a time and runs the rules once per replayed message.
9
+
10
+ Both paths expose the same basic `eymsg:` vocabulary and use N3 quoted formulas for payload graphs.
11
+
12
+ ## Input shape
13
+
14
+ A log is recognized by a message-version directive:
15
+
16
+ ```trig
17
+ VERSION "1.2-messages"
18
+ PREFIX : <urn:example#>
19
+
20
+ :a :value 1 .
21
+
22
+ MESSAGE
23
+
24
+ # Empty heartbeat.
25
+
26
+ MESSAGE
27
+
28
+ :b :value 2 .
29
+ ```
30
+
31
+ The current code accepts message versions matching:
32
+
33
+ ```text
34
+ VERSION "1.1-messages"
35
+ VERSION "1.2-messages"
36
+ VERSION "1.2-basic-messages"
37
+ ```
38
+
39
+ Old-style `@version` and `@message` forms are also recognized.
40
+
41
+ ## Normal, whole-log RDF mode
42
+
43
+ When `lex(input, { rdf: true })` sees a `*-messages` version directive, `normalizeRdfCompatibility()` dispatches to `normalizeRdfMessageLog()` in `lib/lexer.js`.
44
+
45
+ That function:
46
+
47
+ 1. strips the version directive;
48
+ 2. splits the text at top-level `MESSAGE` / `@message` delimiters;
49
+ 3. creates deterministic stream, envelope, and payload IRIs from a hash of the whole source text;
50
+ 4. normalizes each message chunk through the RDF/TriG compatibility layer;
51
+ 5. rewrites blank-node labels so each message gets its own blank-node scope;
52
+ 6. emits ordinary N3 replay facts.
53
+
54
+ Conceptually, the input above becomes something like:
55
+
56
+ ```n3
57
+ <urn:eyeling:message-log:HASH#stream>
58
+ a eymsg:RDFMessageStream ;
59
+ eymsg:messageCount "3"^^xsd:integer ;
60
+ eymsg:orderedEnvelopes (
61
+ <urn:eyeling:message-log:HASH#m001>
62
+ <urn:eyeling:message-log:HASH#m002>
63
+ <urn:eyeling:message-log:HASH#m003>
64
+ ) ;
65
+ eymsg:firstEnvelope <urn:eyeling:message-log:HASH#m001> ;
66
+ eymsg:lastEnvelope <urn:eyeling:message-log:HASH#m003> ;
67
+ eymsg:envelope <urn:eyeling:message-log:HASH#m001>,
68
+ <urn:eyeling:message-log:HASH#m002>,
69
+ <urn:eyeling:message-log:HASH#m003> .
70
+
71
+ <urn:eyeling:message-log:HASH#m001>
72
+ a eymsg:MessageEnvelope ;
73
+ eymsg:offset "1"^^xsd:integer ;
74
+ eymsg:payloadKind eymsg:nonEmpty ;
75
+ eymsg:nextEnvelope <urn:eyeling:message-log:HASH#m002> ;
76
+ eymsg:payloadGraph <urn:eyeling:message-log:HASH#m001/payload> .
77
+
78
+ <urn:eyeling:message-log:HASH#m001/payload>
79
+ log:nameOf {
80
+ :a :value 1 .
81
+ } .
82
+
83
+ <urn:eyeling:message-log:HASH#m002>
84
+ a eymsg:MessageEnvelope ;
85
+ eymsg:offset "2"^^xsd:integer ;
86
+ eymsg:payloadKind eymsg:empty ;
87
+ eymsg:nextEnvelope <urn:eyeling:message-log:HASH#m003> .
88
+
89
+ <urn:eyeling:message-log:HASH#m003>
90
+ a eymsg:MessageEnvelope ;
91
+ eymsg:offset "3"^^xsd:integer ;
92
+ eymsg:payloadKind eymsg:nonEmpty ;
93
+ eymsg:payloadGraph <urn:eyeling:message-log:HASH#m003/payload> .
94
+
95
+ <urn:eyeling:message-log:HASH#m003/payload>
96
+ log:nameOf {
97
+ :b :value 2 .
98
+ } .
99
+ ```
100
+
101
+ After this rewrite, the ordinary N3 parser and reasoner handle the result. There is no separate RDF Message Log AST.
102
+
103
+ ## Payload graphs
104
+
105
+ Payloads are represented exactly like other RDF/TriG named graphs in RDF compatibility mode:
106
+
107
+ ```n3
108
+ ?Payload log:nameOf { ...payload triples... } .
109
+ ```
110
+
111
+ The object is an N3 quoted formula. Rules inspect it with formula-aware built-ins such as `log:includes`:
112
+
113
+ ```n3
114
+ @prefix eymsg: <https://eyereasoner.github.io/eyeling/vocab/message#> .
115
+ @prefix log: <http://www.w3.org/2000/10/swap/log#> .
116
+
117
+ {
118
+ ?Envelope eymsg:payloadGraph ?Payload .
119
+ ?Payload log:nameOf ?Graph .
120
+ ?Graph log:includes { ?Subject :value ?Value . } .
121
+ } => {
122
+ ?Envelope :sawValue ?Value .
123
+ } .
124
+ ```
125
+
126
+ This is the key design choice: message boundaries are preserved by putting each payload in its own quoted graph instead of merging all message triples into the top-level fact set.
127
+
128
+ ## Per-message normalization
129
+
130
+ Each message body is normalized before it is embedded as a payload formula:
131
+
132
+ - RDF 1.2 triple terms are converted to singleton N3 graph terms;
133
+ - RDF 1.2 annotation syntax is expanded;
134
+ - TriG named graphs are converted to `log:nameOf` triples;
135
+ - blank-node labels are rewritten with a message-specific prefix.
136
+
137
+ For example, the same blank-node label in two different messages does not denote the same internal blank node. Message 1 rewrites roughly to:
138
+
139
+ ```n3
140
+ _:eyeling_m001_b :value 1 .
141
+ ```
142
+
143
+ and message 3 rewrites roughly to:
144
+
145
+ ```n3
146
+ _:eyeling_m003_b :value 2 .
147
+ ```
148
+
149
+ If the message log appears as the second parsed source, the parser's usual source prefixing may add another source prefix around those labels, for example `_:src2_eyeling_m001_b`.
150
+
151
+ ## Empty heartbeat messages
152
+
153
+ A message chunk is considered empty when, after directives and comments are ignored, it contains no RDF payload.
154
+
155
+ Empty messages still get an envelope:
156
+
157
+ ```n3
158
+ ?Envelope a eymsg:MessageEnvelope ;
159
+ eymsg:offset "2"^^xsd:integer ;
160
+ eymsg:payloadKind eymsg:empty .
161
+ ```
162
+
163
+ but they do not get an `eymsg:payloadGraph` triple. This lets rules distinguish heartbeats from non-empty data messages without accidentally reusing a previous payload.
164
+
165
+ ## `--stream-messages` mode
166
+
167
+ The CLI streaming path lives in `lib/cli.js`.
168
+
169
+ With:
170
+
171
+ ```bash
172
+ eyeling --rdf --stream-messages rules.n3 messages.trig
173
+ ```
174
+
175
+ the CLI separates ordinary sources from message-log sources. Ordinary sources are parsed once as the reusable rule/program documents. Message logs are then read chunk by chunk.
176
+
177
+ For each chunk, `buildSingleMessageReplayDocument()` creates a small replay document containing one stream, one envelope, and optionally one payload graph:
178
+
179
+ ```n3
180
+ <urn:eyeling:message-stream:HASH#stream>
181
+ a eymsg:RDFMessageStream ;
182
+ eymsg:envelope <urn:eyeling:message-stream:HASH#m000001> ;
183
+ eymsg:orderedEnvelopes (<urn:eyeling:message-stream:HASH#m000001>) ;
184
+ eymsg:firstEnvelope <urn:eyeling:message-stream:HASH#m000001> ;
185
+ eymsg:lastEnvelope <urn:eyeling:message-stream:HASH#m000001> .
186
+
187
+ <urn:eyeling:message-stream:HASH#m000001>
188
+ a eymsg:MessageEnvelope ;
189
+ eymsg:offset "1"^^xsd:integer ;
190
+ eymsg:payloadKind eymsg:nonEmpty ;
191
+ eymsg:payloadGraph <urn:eyeling:message-stream:HASH#m000001/payload> .
192
+
193
+ <urn:eyeling:message-stream:HASH#m000001/payload>
194
+ log:nameOf {
195
+ ...one message payload...
196
+ } .
197
+ ```
198
+
199
+ That one-message replay document is merged with the already-parsed program sources and run immediately. The next message gets a fresh replay document and a fresh run.
200
+
201
+ The streaming replay document intentionally does not expose the whole global message list. It only exposes the current message envelope, because the point of `--stream-messages` is one-message-at-a-time processing without materializing the whole log.
202
+
203
+ ## Remote logs
204
+
205
+ For local files, `--stream-messages` reads line by line. For HTTP(S) sources, the CLI first checks the prefix to detect a message-version directive. When processing a remote text/plain RDF Message Log, it downloads the source into a temporary file and then streams that local copy line by line.
206
+
207
+ ## Output behavior
208
+
209
+ RDF Message Logs do not introduce special output syntax. Once replay facts have been produced, output is the normal Eyeling output path:
210
+
211
+ - `log:outputString` facts are collected and printed as text;
212
+ - `log:query` output is printed from query conclusions;
213
+ - in RDF mode, triples are printed with RDF-compatible output formatting where possible.
214
+
215
+ ## Practical mental model
216
+
217
+ RDF Message Log support is best understood as:
218
+
219
+ ```text
220
+ message-log syntax
221
+ -> split into message chunks
222
+ -> normalize each chunk as RDF/TriG/RDF 1.2
223
+ -> wrap each chunk in an eymsg: envelope
224
+ -> expose the payload as log:nameOf { ... }
225
+ -> run the ordinary N3 reasoner
226
+ ```
227
+
228
+ So the feature is not a separate streaming RDF reasoner. It is a replay encoding that turns message boundaries into explicit N3 facts and quoted payload graphs, which ordinary Eyeling rules can inspect.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.28.0",
3
+ "version": "1.28.2",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
package/test/api.test.js CHANGED
@@ -2916,6 +2916,22 @@ _:b a ex:Person ; ex:name "B" .
2916
2916
  expect: [/:observation\s+:entails\s+<<\(\s+:sensor\s+:needs\s+:inspection\s*\)>>\s*\./m],
2917
2917
  },
2918
2918
 
2919
+ {
2920
+ name: 'RDF mode rejects object lists inside RDF 1.2 triple terms',
2921
+ opt: { proofComments: false, rdf: true },
2922
+ input: `VERSION "1.2"
2923
+ @prefix : <http://example.org/ns#> .
2924
+ @prefix coll: <http://example.org/collection#> .
2925
+
2926
+ :climateDataset coll:contains
2927
+ <<( :asiaDataset coll:contains
2928
+ <<( :China :avgTemp :18C )>>,
2929
+ <<( :Thailand :avgTemp :20C )>>
2930
+ )>> .
2931
+ `,
2932
+ expectError: true,
2933
+ },
2934
+
2919
2935
  {
2920
2936
  name: 'RDF mode accepts PREFIX and reified triple terms with reifiers',
2921
2937
  opt: { proofComments: false, rdf: true },
@@ -161,6 +161,88 @@ function startFileServer(file) {
161
161
  throw new Error('test HTTP server did not start');
162
162
  }
163
163
 
164
+
165
+ function startOpenEndedMessageServer() {
166
+ const dir = mkTmpDir();
167
+ const script = path.join(dir, 'server.js');
168
+ const portFile = path.join(dir, 'server.port');
169
+ fs.writeFileSync(
170
+ script,
171
+ [
172
+ "const http = require('node:http');",
173
+ "const fs = require('node:fs');",
174
+ 'const portFile = process.argv[2];',
175
+ 'const server = http.createServer((req, res) => {',
176
+ " res.writeHead(200, { 'content-type': 'text/plain' });",
177
+ " res.write('VERSION \\\"1.2-messages\\\"\\nPREFIX : <urn:test#>\\n');",
178
+ " res.write(':a :line \\\"one\\\\n\\\".\\nMESSAGE\\n');",
179
+ " setInterval(() => res.write('# keepalive\\n'), 1000);",
180
+ '});',
181
+ "server.listen(0, '127.0.0.1', () => fs.writeFileSync(portFile, String(server.address().port)));",
182
+ '',
183
+ ].join('\n'),
184
+ 'utf8',
185
+ );
186
+ const child = cp.spawn(process.execPath, [script, portFile], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
187
+ const deadline = Date.now() + 5000;
188
+ while (Date.now() < deadline) {
189
+ if (fs.existsSync(portFile)) {
190
+ const port = fs.readFileSync(portFile, 'utf8').trim();
191
+ return {
192
+ url: `http://127.0.0.1:${port}/messages.txt`,
193
+ stop: () => {
194
+ child.kill();
195
+ rmrf(dir);
196
+ },
197
+ };
198
+ }
199
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
200
+ }
201
+ child.kill();
202
+ rmrf(dir);
203
+ throw new Error('test open-ended HTTP server did not start');
204
+ }
205
+
206
+ function waitForEyelingOutput(args, pattern, opts = {}) {
207
+ const timeoutMs = opts.timeoutMs || 4000;
208
+ return new Promise((resolve, reject) => {
209
+ const child = cp.spawn(process.execPath, [eyelingJsPath, ...args], {
210
+ cwd: root,
211
+ stdio: ['ignore', 'pipe', 'pipe'],
212
+ });
213
+ let stdout = '';
214
+ let stderr = '';
215
+ let settled = false;
216
+ const timer = setTimeout(() => {
217
+ if (settled) return;
218
+ settled = true;
219
+ child.kill();
220
+ reject(new Error(`timed out waiting for ${pattern}; stdout=${JSON.stringify(stdout)} stderr=${JSON.stringify(stderr)}`));
221
+ }, timeoutMs);
222
+
223
+ function finish(err) {
224
+ if (settled) return;
225
+ settled = true;
226
+ clearTimeout(timer);
227
+ child.kill();
228
+ if (err) reject(err);
229
+ else resolve(stdout);
230
+ }
231
+
232
+ child.stdout.on('data', (chunk) => {
233
+ stdout += chunk.toString('utf8');
234
+ if (pattern.test(stdout)) finish();
235
+ });
236
+ child.stderr.on('data', (chunk) => {
237
+ stderr += chunk.toString('utf8');
238
+ });
239
+ child.on('error', finish);
240
+ child.on('exit', (code) => {
241
+ if (!settled && code !== 0) finish(new Error(`eyeling exited with ${code}; stdout=${stdout}; stderr=${stderr}`));
242
+ });
243
+ });
244
+ }
245
+
164
246
  const cases = [
165
247
  {
166
248
  name: 'scoped payload rules run once per RDF Message',
@@ -220,6 +302,20 @@ const cases = [
220
302
  }
221
303
  },
222
304
  },
305
+ {
306
+ name: 'remote HTTP RDF Message Logs emit before the response ends',
307
+ async run(tmp) {
308
+ const rules = path.join(tmp, 'rules.n3');
309
+ writeScopedPayloadRules(rules);
310
+ const server = startOpenEndedMessageServer();
311
+ try {
312
+ const out = await waitForEyelingOutput(['-r', '--stream-messages', rules, server.url], /^one\n/);
313
+ assert.equal(out, 'one\n');
314
+ } finally {
315
+ server.stop();
316
+ }
317
+ },
318
+ },
223
319
  {
224
320
  name: 'MARC extraction rules fire over each streamed payload graph',
225
321
  run(tmp) {
@@ -251,7 +347,7 @@ const cases = [
251
347
  },
252
348
  ];
253
349
 
254
- (function main() {
350
+ (async function main() {
255
351
  const suiteStart = msNow();
256
352
  info(`Running ${cases.length} stream-message tests`);
257
353
  let passed = 0;
@@ -261,7 +357,7 @@ const cases = [
261
357
  const testName = numberedName(index, tc.name);
262
358
  const start = msNow();
263
359
  try {
264
- tc.run(tmp);
360
+ await tc.run(tmp);
265
361
  ok(`${testName} ${C.dim}(${msNow() - start} ms)${C.n}`);
266
362
  passed++;
267
363
  } catch (e) {
@@ -281,4 +377,7 @@ const cases = [
281
377
  }
282
378
  fail(`Some stream-message tests failed (${passed}/${cases.length})`);
283
379
  process.exit(1);
284
- })();
380
+ })().catch((e) => {
381
+ fail(e && e.stack ? e.stack : String(e));
382
+ process.exit(1);
383
+ });
package/tools/bundle.js CHANGED
@@ -195,7 +195,13 @@ function buildBundleSource({ autoRunMain }) {
195
195
  out.push(
196
196
  ' if (__outerModule && __outerRequire && __outerRequire.main === __outerModule && typeof __entry.main === "function") {',
197
197
  );
198
- out.push(' __entry.main();');
198
+ out.push(' const __mainResult = __entry.main();');
199
+ out.push(' if (__mainResult && typeof __mainResult.then === "function") {');
200
+ out.push(' __mainResult.catch((e) => {');
201
+ out.push(' try { if (typeof console !== "undefined" && console.error) console.error(e && e.stack ? e.stack : e && e.message ? e.message : String(e)); } catch (ignoredError) {}');
202
+ out.push(' try { if (typeof process !== "undefined" && process.exit) process.exit(1); } catch (ignoredError) {}');
203
+ out.push(' });');
204
+ out.push(' }');
199
205
  out.push(' }');
200
206
  out.push(' } catch (ignoredError) {}');
201
207
  }