eyeling 1.19.0 → 1.19.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/HANDBOOK.md CHANGED
@@ -1820,6 +1820,7 @@ The bundle contains the whole engine. The CLI path is the “canonical behavior
1820
1820
  The current CLI supports a small set of flags (see `lib/cli.js`):
1821
1821
 
1822
1822
  - `-a`, `--ast` — print the parsed AST as JSON and exit.
1823
+ - `--builtin <module.js>` — load a custom builtin module (repeatable).
1823
1824
  - `-d`, `--deterministic-skolem` — make `log:skolem` stable across runs.
1824
1825
  - `-e`, `--enforce-https` — rewrite `http://…` to `https://…` for dereferencing builtins.
1825
1826
  - `-p`, `--proof-comments` — include per-fact proof comment blocks in output.
@@ -2084,9 +2085,26 @@ const { reason } = require('eyeling');
2084
2085
  const out = reason({ builtinModules: ['./hello-builtin.js'] }, n3Text);
2085
2086
  ```
2086
2087
 
2088
+ ### 16.2.1 Stability rule for `--builtin`
2089
+
2090
+ Eyeling now treats the custom-builtin boundary as a **versioned API contract** rather than an informal convenience layer.
2091
+
2092
+ That matters for LLM-generated builtin modules: the model is free to decide **when** a builtin should be used, but it is **not** free to rename helpers, invent new helper names, change handler context fields, or change the expected return shape.
2093
+
2094
+ The stable rule is:
2095
+
2096
+ - builtin module loading accepts only the declared export forms
2097
+ - the helper API exposed by `__buildBuiltinRegistrationApi()` has an **exact key set**
2098
+ - helper names and helper arities are treated as public contract
2099
+ - builtin handlers receive an **exact** context object shape
2100
+ - builtin handlers must return an **array of substitution-delta objects**
2101
+ - any add/remove/rename of these contract elements is a **breaking change** and should bump the builtin API version
2102
+
2103
+ In code, this contract lives in `lib/builtin-contract.js`, is enforced at runtime by `lib/builtins.js`, and is locked by `test/builtin-contract.test.js`.
2104
+
2087
2105
  ### 16.3 What a builtin module may export
2088
2106
 
2089
- Eyeling accepts three module shapes.
2107
+ Eyeling accepts these stable module shapes.
2090
2108
 
2091
2109
  #### A function export
2092
2110
 
@@ -2123,23 +2141,54 @@ module.exports = {
2123
2141
  };
2124
2142
  ```
2125
2143
 
2144
+ #### An object with `.builtins`
2145
+
2146
+ ```js
2147
+ module.exports = {
2148
+ builtins: {
2149
+ 'http://example.org/custom#ok': ({ subst }) => [subst],
2150
+ },
2151
+ };
2152
+ ```
2153
+
2154
+ #### An object with `.default` as a plain object map
2155
+
2156
+ This is mainly an ESM/transpiler compatibility form.
2157
+
2158
+ ```js
2159
+ module.exports = {
2160
+ default: {
2161
+ 'http://example.org/custom#ok': ({ subst }) => [subst],
2162
+ },
2163
+ };
2164
+ ```
2165
+
2126
2166
  If none of those shapes match, Eyeling rejects the module with a descriptive error.
2127
2167
 
2128
2168
  ### 16.4 The handler contract
2129
2169
 
2130
- Builtin handlers are called with a context object like:
2170
+ Builtin handlers are called with an **exactly versioned** context object:
2131
2171
 
2132
2172
  - `iri` — the predicate IRI string
2133
2173
  - `goal` — the current triple goal
2134
2174
  - `subst` — the current substitution
2135
- - `facts`, `backRules`, `depth`, `varGen`, `maxResults`
2175
+ - `facts` the active fact store
2176
+ - `backRules` — the backward-rule set
2177
+ - `depth` — current proof depth
2178
+ - `varGen` — the variable generator state
2179
+ - `maxResults` — current result cap
2136
2180
  - `api` — the same registration/helper API used by modules
2137
2181
 
2138
- A handler returns **an array of substitutions**:
2182
+ The exact key set is part of the contract; adding, removing, or renaming a context field is a breaking change.
2183
+
2184
+ A handler returns **an array of substitution-delta objects**:
2139
2185
 
2140
2186
  - `[]` means failure / no solutions
2141
- - `[subst2]` means one successful continuation
2142
- - multiple substitutions mean a generator builtin
2187
+ - `[{}]` means success with no new bindings
2188
+ - `[{ ...delta }]` means one successful continuation with bindings
2189
+ - multiple objects mean a generator builtin
2190
+
2191
+ Returning a non-array, `null` elements, or non-object delta elements is rejected by the runtime contract wrapper.
2143
2192
 
2144
2193
  In practice:
2145
2194
 
@@ -2152,13 +2201,28 @@ Custom builtin failures are wrapped so the predicate IRI appears in the thrown e
2152
2201
 
2153
2202
  ### 16.5 The helper API exposed to builtin modules
2154
2203
 
2155
- Builtin modules do not need to import internal engine files directly. Eyeling passes a helper API into module registration, including:
2204
+ Builtin modules do not need to import internal engine files directly. Eyeling passes a helper API into module registration, and that helper surface is now treated as an **exact public contract**.
2205
+
2206
+ The current builtin API version is exposed as:
2207
+
2208
+ - `getBuiltinApiVersion()`
2209
+
2210
+ The stable helper function set is:
2211
+
2212
+ - `registerBuiltin`, `unregisterBuiltin`, `listBuiltinIris`
2213
+ - `internIri`, `internLiteral`, `literalParts`
2214
+ - `termToJsString`, `termToJsStringDecoded`, `termToN3`, `iriValue`
2215
+ - `unifyTerm`, `applySubstTerm`, `applySubstTriple`, `proveGoals`, `isGroundTerm`
2216
+ - `computeConclusionFromFormula`, `skolemIriFromGroundTerm`
2217
+ - `parseBooleanLiteralInfo`, `parseNumericLiteralInfo`, `parseXsdDecimalToBigIntScale`, `pow10n`
2218
+ - `normalizeLiteralForFastKey`, `literalsEquivalentAsXsdString`, `materializeRdfLists`
2219
+
2220
+ The stable namespace bags are:
2221
+
2222
+ - `terms`: `Literal`, `Iri`, `Var`, `Blank`, `ListTerm`, `OpenListTerm`, `GraphTerm`, `Triple`, `Rule`
2223
+ - `ns`: `RDF_NS`, `XSD_NS`, `CRYPTO_NS`, `MATH_NS`, `TIME_NS`, `LIST_NS`, `LOG_NS`, `STRING_NS`
2156
2224
 
2157
- - registration helpers: `registerBuiltin`, `unregisterBuiltin`, `listBuiltinIris`
2158
- - term constructors via `terms` (`Literal`, `Iri`, `Var`, `Blank`, `ListTerm`, `GraphTerm`, `Triple`, `Rule`, ...)
2159
- - literal/term helpers such as `internLiteral`, `internIri`, `literalParts`, `termToN3`, `termToJsString`
2160
- - reasoning helpers such as `unifyTerm`, `applySubstTerm`, `applySubstTriple`, `proveGoals`
2161
- - namespace constants via `ns`
2225
+ The contract is intentionally strict: if a helper is added, removed, renamed, or its callable shape changes, the builtin contract tests fail until the change is made explicit and the version is bumped.
2162
2226
 
2163
2227
  That API keeps the extension boundary explicit: custom builtins get the operations they need without reaching into Eyeling’s private module graph.
2164
2228
 
package/eyeling.js CHANGED
@@ -9,6 +9,130 @@
9
9
  const __cache = Object.create(null);
10
10
 
11
11
  // ---- bundled modules ----
12
+ __modules["lib/builtin-contract.js"] = function(require, module, exports){
13
+ 'use strict';
14
+
15
+ const CONTRACT = {
16
+ version: 1,
17
+ moduleExportForms: [
18
+ 'function',
19
+ 'object.register',
20
+ 'object.builtins',
21
+ 'plain-object-map',
22
+ 'object.default-object-map',
23
+ ],
24
+ api: {
25
+ functions: {
26
+ getBuiltinApiVersion: { arity: 0, required: true },
27
+ registerBuiltin: { arity: 2, required: true },
28
+ unregisterBuiltin: { arity: 1, required: true },
29
+ listBuiltinIris: { arity: 0, required: true },
30
+ internIri: { arity: 1, required: true },
31
+ internLiteral: { arity: 1, required: true },
32
+ literalParts: { arity: 1, required: true },
33
+ termToJsString: { arity: 1, required: true },
34
+ termToJsStringDecoded: { arity: 1, required: true },
35
+ termToN3: { arity: 2, required: true },
36
+ iriValue: { arity: 1, required: true },
37
+ unifyTerm: { arity: 3, required: true },
38
+ applySubstTerm: { arity: 2, required: true },
39
+ applySubstTriple: { arity: 2, required: true },
40
+ proveGoals: { arity: 9, required: true },
41
+ isGroundTerm: { arity: 1, required: true },
42
+ computeConclusionFromFormula: { arity: 1, required: true },
43
+ skolemIriFromGroundTerm: { arity: 1, required: true },
44
+ parseBooleanLiteralInfo: { arity: 1, required: true },
45
+ parseNumericLiteralInfo: { arity: 1, required: true },
46
+ parseXsdDecimalToBigIntScale: { arity: 1, required: true },
47
+ pow10n: { arity: 1, required: true },
48
+ normalizeLiteralForFastKey: { arity: 1, required: true },
49
+ literalsEquivalentAsXsdString: { arity: 2, required: true },
50
+ materializeRdfLists: { arity: 3, required: true },
51
+ },
52
+ namespaces: {
53
+ terms: ['Literal', 'Iri', 'Var', 'Blank', 'ListTerm', 'OpenListTerm', 'GraphTerm', 'Triple', 'Rule'],
54
+ ns: ['RDF_NS', 'XSD_NS', 'CRYPTO_NS', 'MATH_NS', 'TIME_NS', 'LIST_NS', 'LOG_NS', 'STRING_NS'],
55
+ },
56
+ },
57
+ handler: {
58
+ ctxKeys: ['iri', 'goal', 'subst', 'facts', 'backRules', 'depth', 'varGen', 'maxResults', 'api'],
59
+ return: 'array-of-substitution-deltas',
60
+ },
61
+ };
62
+
63
+ const EXACT_API_KEYS = new Set([...Object.keys(CONTRACT.api.functions), ...Object.keys(CONTRACT.api.namespaces)]);
64
+
65
+ function assertExactKeys(obj, expectedKeys, label) {
66
+ const got = Object.keys(obj).sort();
67
+ const exp = Array.from(expectedKeys).sort();
68
+ if (got.length !== exp.length || got.some((k, i) => k !== exp[i])) {
69
+ throw new Error(`${label} keys changed. expected: ${exp.join(', ')}; got: ${got.join(', ')}`);
70
+ }
71
+ }
72
+
73
+ function assertFunctionArity(name, fn, spec) {
74
+ if (typeof fn !== 'function') throw new TypeError(`Builtin API member ${name} must be a function`);
75
+ if (Number.isInteger(spec.arity) && fn.length !== spec.arity) {
76
+ throw new Error(`Builtin API member ${name} arity changed: expected ${spec.arity}, got ${fn.length}`);
77
+ }
78
+ if (Number.isInteger(spec.arityMin) && fn.length < spec.arityMin) {
79
+ throw new Error(`Builtin API member ${name} arity too small: expected >= ${spec.arityMin}, got ${fn.length}`);
80
+ }
81
+ }
82
+
83
+ function deepFreezeBuiltinApi(api) {
84
+ Object.freeze(api.terms);
85
+ Object.freeze(api.ns);
86
+ return Object.freeze(api);
87
+ }
88
+
89
+ function assertBuiltinApiShape(api) {
90
+ assertExactKeys(api, EXACT_API_KEYS, 'Builtin registration API');
91
+
92
+ for (const [name, spec] of Object.entries(CONTRACT.api.functions)) {
93
+ assertFunctionArity(name, api[name], spec);
94
+ }
95
+
96
+ for (const name of CONTRACT.api.namespaces.terms) {
97
+ if (!api.terms || typeof api.terms[name] !== 'function') {
98
+ throw new TypeError(`Builtin API terms.${name} missing or invalid`);
99
+ }
100
+ }
101
+
102
+ for (const name of CONTRACT.api.namespaces.ns) {
103
+ if (!api.ns || typeof api.ns[name] !== 'string') {
104
+ throw new TypeError(`Builtin API ns.${name} missing or invalid`);
105
+ }
106
+ }
107
+
108
+ return api;
109
+ }
110
+
111
+ function assertBuiltinCtxShape(ctx) {
112
+ assertExactKeys(ctx, new Set(CONTRACT.handler.ctxKeys), 'Builtin handler ctx');
113
+ }
114
+
115
+ function assertBuiltinResultShape(out, iri) {
116
+ if (out == null) return;
117
+ if (!Array.isArray(out)) {
118
+ throw new TypeError(`Custom builtin ${iri} must return an array of substitution deltas`);
119
+ }
120
+ for (const delta of out) {
121
+ if (delta === null || typeof delta !== 'object' || Array.isArray(delta)) {
122
+ throw new TypeError(`Custom builtin ${iri} returned a non-object substitution delta`);
123
+ }
124
+ }
125
+ }
126
+
127
+ module.exports = {
128
+ CONTRACT,
129
+ assertBuiltinApiShape,
130
+ assertBuiltinCtxShape,
131
+ assertBuiltinResultShape,
132
+ deepFreezeBuiltinApi,
133
+ };
134
+
135
+ };
12
136
  __modules["lib/builtin-sudoku.js"] = function(require, module, exports){
13
137
  'use strict';
14
138
 
@@ -515,6 +639,13 @@ const { termToN3 } = require('./printing');
515
639
  const trace = require('./trace');
516
640
  const time = require('./time');
517
641
  const deref = require('./deref');
642
+ const {
643
+ CONTRACT: BUILTIN_CONTRACT,
644
+ assertBuiltinApiShape,
645
+ assertBuiltinCtxShape,
646
+ assertBuiltinResultShape,
647
+ deepFreezeBuiltinApi,
648
+ } = require('./builtin-contract');
518
649
 
519
650
  let nodeCrypto = null;
520
651
  try {
@@ -558,8 +689,21 @@ function registerBuiltin(iri, handler) {
558
689
  if (typeof handler !== 'function') {
559
690
  throw new TypeError(`Custom builtin ${iri} must be registered with a function handler`);
560
691
  }
561
- __customBuiltinHandlers.set(iri, handler);
562
- return handler;
692
+
693
+ const wrapped = function builtinContractWrapper(ctx) {
694
+ assertBuiltinCtxShape(ctx);
695
+ const out = handler(ctx);
696
+ assertBuiltinResultShape(out, iri);
697
+ return out;
698
+ };
699
+
700
+ Object.defineProperty(wrapped, '__builtinContractWrapped', {
701
+ value: true,
702
+ enumerable: false,
703
+ });
704
+
705
+ __customBuiltinHandlers.set(iri, wrapped);
706
+ return wrapped;
563
707
  }
564
708
 
565
709
  function unregisterBuiltin(iri) {
@@ -570,8 +714,17 @@ function listBuiltinIris() {
570
714
  return Array.from(__customBuiltinHandlers.keys()).sort();
571
715
  }
572
716
 
717
+ let __builtinApiSingleton = null;
718
+
719
+ function getBuiltinApiVersion() {
720
+ return BUILTIN_CONTRACT.version;
721
+ }
722
+
573
723
  function __buildBuiltinRegistrationApi() {
574
- return {
724
+ if (__builtinApiSingleton) return __builtinApiSingleton;
725
+
726
+ const api = {
727
+ getBuiltinApiVersion,
575
728
  registerBuiltin,
576
729
  unregisterBuiltin,
577
730
  listBuiltinIris,
@@ -599,6 +752,9 @@ function __buildBuiltinRegistrationApi() {
599
752
  terms: { Literal, Iri, Var, Blank, ListTerm, OpenListTerm, GraphTerm, Triple, Rule },
600
753
  ns: { RDF_NS, XSD_NS, CRYPTO_NS, MATH_NS, TIME_NS, LIST_NS, LOG_NS, STRING_NS },
601
754
  };
755
+
756
+ __builtinApiSingleton = deepFreezeBuiltinApi(assertBuiltinApiShape(api));
757
+ return __builtinApiSingleton;
602
758
  }
603
759
 
604
760
  function registerBuiltinModule(mod, origin = '<builtin-module>') {
@@ -675,9 +831,7 @@ function __evalRegisteredBuiltin(pv, goal, subst, facts, backRules, depth, varGe
675
831
  try {
676
832
  const out = handler(ctx);
677
833
  if (out == null) return [];
678
- if (!Array.isArray(out)) {
679
- throw new TypeError(`Custom builtin ${pv} must return an array of substitution deltas`);
680
- }
834
+ assertBuiltinResultShape(out, pv);
681
835
  return out;
682
836
  } catch (err) {
683
837
  if (err && typeof err === 'object' && typeof err.message === 'string') {
@@ -3670,7 +3824,7 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3670
3824
  // - subject = GraphTerm: explicit scope, run immediately (no closure gating)
3671
3825
  // - subject = positive integer literal N (>= 1): delay until saturated closure level >= N
3672
3826
  // - subject = Var: treat as priority 1 (do not bind)
3673
- // - any other subject: backward-compatible default priority 1
3827
+ // - any other subject: invalid, so the builtin fails
3674
3828
  if (pv === LOG_NS + 'includes') {
3675
3829
  let scopeFacts = null;
3676
3830
  let scopeBackRules = backRules;
@@ -3698,7 +3852,8 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3698
3852
  prio = 1; // do not bind
3699
3853
  } else {
3700
3854
  const p0 = __logNaturalPriorityFromTerm(g.s);
3701
- if (p0 !== null) prio = p0;
3855
+ if (p0 === null) return [];
3856
+ prio = p0;
3702
3857
  }
3703
3858
 
3704
3859
  const snap = facts.__scopedSnapshot || null;
@@ -3781,7 +3936,8 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3781
3936
  prio = 1; // do not bind
3782
3937
  } else {
3783
3938
  const p0 = __logNaturalPriorityFromTerm(g.s);
3784
- if (p0 !== null) prio = p0;
3939
+ if (p0 === null) return [];
3940
+ prio = p0;
3785
3941
  }
3786
3942
 
3787
3943
  const snap = facts.__scopedSnapshot || null;
@@ -4470,6 +4626,8 @@ module.exports = {
4470
4626
  registerBuiltinModule,
4471
4627
  loadBuiltinModule,
4472
4628
  listBuiltinIris,
4629
+ getBuiltinApiVersion,
4630
+ __testBuildBuiltinApi: __buildBuiltinRegistrationApi,
4473
4631
  // shared helpers used by engine/explain
4474
4632
  parseBooleanLiteralInfo,
4475
4633
  parseNumericLiteralInfo,
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ const CONTRACT = {
4
+ version: 1,
5
+ moduleExportForms: [
6
+ 'function',
7
+ 'object.register',
8
+ 'object.builtins',
9
+ 'plain-object-map',
10
+ 'object.default-object-map',
11
+ ],
12
+ api: {
13
+ functions: {
14
+ getBuiltinApiVersion: { arity: 0, required: true },
15
+ registerBuiltin: { arity: 2, required: true },
16
+ unregisterBuiltin: { arity: 1, required: true },
17
+ listBuiltinIris: { arity: 0, required: true },
18
+ internIri: { arity: 1, required: true },
19
+ internLiteral: { arity: 1, required: true },
20
+ literalParts: { arity: 1, required: true },
21
+ termToJsString: { arity: 1, required: true },
22
+ termToJsStringDecoded: { arity: 1, required: true },
23
+ termToN3: { arity: 2, required: true },
24
+ iriValue: { arity: 1, required: true },
25
+ unifyTerm: { arity: 3, required: true },
26
+ applySubstTerm: { arity: 2, required: true },
27
+ applySubstTriple: { arity: 2, required: true },
28
+ proveGoals: { arity: 9, required: true },
29
+ isGroundTerm: { arity: 1, required: true },
30
+ computeConclusionFromFormula: { arity: 1, required: true },
31
+ skolemIriFromGroundTerm: { arity: 1, required: true },
32
+ parseBooleanLiteralInfo: { arity: 1, required: true },
33
+ parseNumericLiteralInfo: { arity: 1, required: true },
34
+ parseXsdDecimalToBigIntScale: { arity: 1, required: true },
35
+ pow10n: { arity: 1, required: true },
36
+ normalizeLiteralForFastKey: { arity: 1, required: true },
37
+ literalsEquivalentAsXsdString: { arity: 2, required: true },
38
+ materializeRdfLists: { arity: 3, required: true },
39
+ },
40
+ namespaces: {
41
+ terms: ['Literal', 'Iri', 'Var', 'Blank', 'ListTerm', 'OpenListTerm', 'GraphTerm', 'Triple', 'Rule'],
42
+ ns: ['RDF_NS', 'XSD_NS', 'CRYPTO_NS', 'MATH_NS', 'TIME_NS', 'LIST_NS', 'LOG_NS', 'STRING_NS'],
43
+ },
44
+ },
45
+ handler: {
46
+ ctxKeys: ['iri', 'goal', 'subst', 'facts', 'backRules', 'depth', 'varGen', 'maxResults', 'api'],
47
+ return: 'array-of-substitution-deltas',
48
+ },
49
+ };
50
+
51
+ const EXACT_API_KEYS = new Set([...Object.keys(CONTRACT.api.functions), ...Object.keys(CONTRACT.api.namespaces)]);
52
+
53
+ function assertExactKeys(obj, expectedKeys, label) {
54
+ const got = Object.keys(obj).sort();
55
+ const exp = Array.from(expectedKeys).sort();
56
+ if (got.length !== exp.length || got.some((k, i) => k !== exp[i])) {
57
+ throw new Error(`${label} keys changed. expected: ${exp.join(', ')}; got: ${got.join(', ')}`);
58
+ }
59
+ }
60
+
61
+ function assertFunctionArity(name, fn, spec) {
62
+ if (typeof fn !== 'function') throw new TypeError(`Builtin API member ${name} must be a function`);
63
+ if (Number.isInteger(spec.arity) && fn.length !== spec.arity) {
64
+ throw new Error(`Builtin API member ${name} arity changed: expected ${spec.arity}, got ${fn.length}`);
65
+ }
66
+ if (Number.isInteger(spec.arityMin) && fn.length < spec.arityMin) {
67
+ throw new Error(`Builtin API member ${name} arity too small: expected >= ${spec.arityMin}, got ${fn.length}`);
68
+ }
69
+ }
70
+
71
+ function deepFreezeBuiltinApi(api) {
72
+ Object.freeze(api.terms);
73
+ Object.freeze(api.ns);
74
+ return Object.freeze(api);
75
+ }
76
+
77
+ function assertBuiltinApiShape(api) {
78
+ assertExactKeys(api, EXACT_API_KEYS, 'Builtin registration API');
79
+
80
+ for (const [name, spec] of Object.entries(CONTRACT.api.functions)) {
81
+ assertFunctionArity(name, api[name], spec);
82
+ }
83
+
84
+ for (const name of CONTRACT.api.namespaces.terms) {
85
+ if (!api.terms || typeof api.terms[name] !== 'function') {
86
+ throw new TypeError(`Builtin API terms.${name} missing or invalid`);
87
+ }
88
+ }
89
+
90
+ for (const name of CONTRACT.api.namespaces.ns) {
91
+ if (!api.ns || typeof api.ns[name] !== 'string') {
92
+ throw new TypeError(`Builtin API ns.${name} missing or invalid`);
93
+ }
94
+ }
95
+
96
+ return api;
97
+ }
98
+
99
+ function assertBuiltinCtxShape(ctx) {
100
+ assertExactKeys(ctx, new Set(CONTRACT.handler.ctxKeys), 'Builtin handler ctx');
101
+ }
102
+
103
+ function assertBuiltinResultShape(out, iri) {
104
+ if (out == null) return;
105
+ if (!Array.isArray(out)) {
106
+ throw new TypeError(`Custom builtin ${iri} must return an array of substitution deltas`);
107
+ }
108
+ for (const delta of out) {
109
+ if (delta === null || typeof delta !== 'object' || Array.isArray(delta)) {
110
+ throw new TypeError(`Custom builtin ${iri} returned a non-object substitution delta`);
111
+ }
112
+ }
113
+ }
114
+
115
+ module.exports = {
116
+ CONTRACT,
117
+ assertBuiltinApiShape,
118
+ assertBuiltinCtxShape,
119
+ assertBuiltinResultShape,
120
+ deepFreezeBuiltinApi,
121
+ };
package/lib/builtins.js CHANGED
@@ -35,6 +35,13 @@ const { termToN3 } = require('./printing');
35
35
  const trace = require('./trace');
36
36
  const time = require('./time');
37
37
  const deref = require('./deref');
38
+ const {
39
+ CONTRACT: BUILTIN_CONTRACT,
40
+ assertBuiltinApiShape,
41
+ assertBuiltinCtxShape,
42
+ assertBuiltinResultShape,
43
+ deepFreezeBuiltinApi,
44
+ } = require('./builtin-contract');
38
45
 
39
46
  let nodeCrypto = null;
40
47
  try {
@@ -78,8 +85,21 @@ function registerBuiltin(iri, handler) {
78
85
  if (typeof handler !== 'function') {
79
86
  throw new TypeError(`Custom builtin ${iri} must be registered with a function handler`);
80
87
  }
81
- __customBuiltinHandlers.set(iri, handler);
82
- return handler;
88
+
89
+ const wrapped = function builtinContractWrapper(ctx) {
90
+ assertBuiltinCtxShape(ctx);
91
+ const out = handler(ctx);
92
+ assertBuiltinResultShape(out, iri);
93
+ return out;
94
+ };
95
+
96
+ Object.defineProperty(wrapped, '__builtinContractWrapped', {
97
+ value: true,
98
+ enumerable: false,
99
+ });
100
+
101
+ __customBuiltinHandlers.set(iri, wrapped);
102
+ return wrapped;
83
103
  }
84
104
 
85
105
  function unregisterBuiltin(iri) {
@@ -90,8 +110,17 @@ function listBuiltinIris() {
90
110
  return Array.from(__customBuiltinHandlers.keys()).sort();
91
111
  }
92
112
 
113
+ let __builtinApiSingleton = null;
114
+
115
+ function getBuiltinApiVersion() {
116
+ return BUILTIN_CONTRACT.version;
117
+ }
118
+
93
119
  function __buildBuiltinRegistrationApi() {
94
- return {
120
+ if (__builtinApiSingleton) return __builtinApiSingleton;
121
+
122
+ const api = {
123
+ getBuiltinApiVersion,
95
124
  registerBuiltin,
96
125
  unregisterBuiltin,
97
126
  listBuiltinIris,
@@ -119,6 +148,9 @@ function __buildBuiltinRegistrationApi() {
119
148
  terms: { Literal, Iri, Var, Blank, ListTerm, OpenListTerm, GraphTerm, Triple, Rule },
120
149
  ns: { RDF_NS, XSD_NS, CRYPTO_NS, MATH_NS, TIME_NS, LIST_NS, LOG_NS, STRING_NS },
121
150
  };
151
+
152
+ __builtinApiSingleton = deepFreezeBuiltinApi(assertBuiltinApiShape(api));
153
+ return __builtinApiSingleton;
122
154
  }
123
155
 
124
156
  function registerBuiltinModule(mod, origin = '<builtin-module>') {
@@ -195,9 +227,7 @@ function __evalRegisteredBuiltin(pv, goal, subst, facts, backRules, depth, varGe
195
227
  try {
196
228
  const out = handler(ctx);
197
229
  if (out == null) return [];
198
- if (!Array.isArray(out)) {
199
- throw new TypeError(`Custom builtin ${pv} must return an array of substitution deltas`);
200
- }
230
+ assertBuiltinResultShape(out, pv);
201
231
  return out;
202
232
  } catch (err) {
203
233
  if (err && typeof err === 'object' && typeof err.message === 'string') {
@@ -3190,7 +3220,7 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3190
3220
  // - subject = GraphTerm: explicit scope, run immediately (no closure gating)
3191
3221
  // - subject = positive integer literal N (>= 1): delay until saturated closure level >= N
3192
3222
  // - subject = Var: treat as priority 1 (do not bind)
3193
- // - any other subject: backward-compatible default priority 1
3223
+ // - any other subject: invalid, so the builtin fails
3194
3224
  if (pv === LOG_NS + 'includes') {
3195
3225
  let scopeFacts = null;
3196
3226
  let scopeBackRules = backRules;
@@ -3218,7 +3248,8 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3218
3248
  prio = 1; // do not bind
3219
3249
  } else {
3220
3250
  const p0 = __logNaturalPriorityFromTerm(g.s);
3221
- if (p0 !== null) prio = p0;
3251
+ if (p0 === null) return [];
3252
+ prio = p0;
3222
3253
  }
3223
3254
 
3224
3255
  const snap = facts.__scopedSnapshot || null;
@@ -3301,7 +3332,8 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
3301
3332
  prio = 1; // do not bind
3302
3333
  } else {
3303
3334
  const p0 = __logNaturalPriorityFromTerm(g.s);
3304
- if (p0 !== null) prio = p0;
3335
+ if (p0 === null) return [];
3336
+ prio = p0;
3305
3337
  }
3306
3338
 
3307
3339
  const snap = facts.__scopedSnapshot || null;
@@ -3990,6 +4022,8 @@ module.exports = {
3990
4022
  registerBuiltinModule,
3991
4023
  loadBuiltinModule,
3992
4024
  listBuiltinIris,
4025
+ getBuiltinApiVersion,
4026
+ __testBuildBuiltinApi: __buildBuiltinRegistrationApi,
3993
4027
  // shared helpers used by engine/explain
3994
4028
  parseBooleanLiteralInfo,
3995
4029
  parseNumericLiteralInfo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.19.0",
3
+ "version": "1.19.2",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
@@ -40,12 +40,13 @@
40
40
  "build": "node tools/bundle.js",
41
41
  "test:packlist": "node test/packlist.test.js",
42
42
  "test:api": "node test/api.test.js",
43
+ "test:builtin-contract": "node test/builtin-contract.test.js",
43
44
  "test:n3gen": "node test/n3gen.test.js",
44
45
  "test:examples": "node test/examples.test.js",
45
46
  "test:manifest": "node test/manifest.test.js",
46
47
  "test:playground": "node test/playground.test.js",
47
48
  "test:package": "node test/package.test.js",
48
- "test:all": "npm run test:api && npm run test:n3gen && npm run test:examples && npm run test:manifest && npm run test:playground",
49
+ "test:all": "npm run test:api && npm run test:builtin-contract && npm run test:n3gen && npm run test:examples && npm run test:manifest && npm run test:playground",
49
50
  "pretest": "npm run build && npm run test:packlist",
50
51
  "test": "npm run test:all",
51
52
  "posttest": "npm run test:package",
package/test/api.test.js CHANGED
@@ -1462,6 +1462,42 @@ _:x :hates { _:foo :making :mess }.
1462
1462
  notExpect: [/:(?:test)\s+:(?:is)\s+false\s*\./],
1463
1463
  },
1464
1464
 
1465
+ {
1466
+ name: '59b regression: log:includes rejects non-scope literal or term subjects',
1467
+ opt: { proofComments: false },
1468
+ input: `@prefix : <http://example.org/> .
1469
+ @prefix log: <http://www.w3.org/2000/10/swap/log#> .
1470
+ @base <http://example.org/> .
1471
+
1472
+ { false log:includes true. } => { :result :has :fail-literal-1. }.
1473
+ { "foo" log:includes true. } => { :result :has :fail-literal-2. }.
1474
+ { :foo log:includes true. } => { :result :has :fail-literal-3. }.
1475
+ { 0 log:includes true. } => { :result :has :fail-literal-4. }.
1476
+ { 42.3 log:includes true. } => { :result :has :fail-literal-5. }.
1477
+ { (:foo 1 _:x) log:includes true. } => { :result :has :fail-literal-6. }.
1478
+
1479
+ { } => {
1480
+ :test :contains :fail-literal-1, :fail-literal-2, :fail-literal-3, :fail-literal-4, :fail-literal-5, :fail-literal-6.
1481
+ }.
1482
+ `,
1483
+ expect: [
1484
+ /:(?:test)\s+:(?:contains)\s+:(?:fail-literal-1)\s*\./,
1485
+ /:(?:test)\s+:(?:contains)\s+:(?:fail-literal-2)\s*\./,
1486
+ /:(?:test)\s+:(?:contains)\s+:(?:fail-literal-3)\s*\./,
1487
+ /:(?:test)\s+:(?:contains)\s+:(?:fail-literal-4)\s*\./,
1488
+ /:(?:test)\s+:(?:contains)\s+:(?:fail-literal-5)\s*\./,
1489
+ /:(?:test)\s+:(?:contains)\s+:(?:fail-literal-6)\s*\./,
1490
+ ],
1491
+ notExpect: [
1492
+ /:(?:result)\s+:(?:has)\s+:(?:fail-literal-1)\s*\./,
1493
+ /:(?:result)\s+:(?:has)\s+:(?:fail-literal-2)\s*\./,
1494
+ /:(?:result)\s+:(?:has)\s+:(?:fail-literal-3)\s*\./,
1495
+ /:(?:result)\s+:(?:has)\s+:(?:fail-literal-4)\s*\./,
1496
+ /:(?:result)\s+:(?:has)\s+:(?:fail-literal-5)\s*\./,
1497
+ /:(?:result)\s+:(?:has)\s+:(?:fail-literal-6)\s*\./,
1498
+ ],
1499
+ },
1500
+
1465
1501
  {
1466
1502
  name: '60 regression: log:includes must match quoted triples with variable predicates',
1467
1503
  opt: { proofComments: false },
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const path = require('node:path');
5
+
6
+ const TTY = process.stdout.isTTY;
7
+ const C = TTY
8
+ ? { g: '\x1b[32m', r: '\x1b[31m', y: '\x1b[33m', dim: '\x1b[2m', n: '\x1b[0m' }
9
+ : { g: '', r: '', y: '', dim: '', n: '' };
10
+
11
+ function ok(msg) {
12
+ console.log(`${C.g}OK ${C.n} ${msg}`);
13
+ }
14
+ function info(msg) {
15
+ console.log(`${C.y}==${C.n} ${msg}`);
16
+ }
17
+ function fail(msg) {
18
+ console.error(`${C.r}FAIL${C.n} ${msg}`);
19
+ }
20
+
21
+ const fixtures = path.join(__dirname, 'fixtures', 'builtins');
22
+ const builtins = require('../lib/builtins');
23
+ const { CONTRACT } = require('../lib/builtin-contract');
24
+ require('../lib/engine');
25
+
26
+ const expectedApiKeys = [...Object.keys(CONTRACT.api.functions), ...Object.keys(CONTRACT.api.namespaces)].sort();
27
+
28
+ const expectedFunctionArities = Object.fromEntries(
29
+ Object.entries(CONTRACT.api.functions).map(([name, spec]) => [name, spec]),
30
+ );
31
+
32
+ const cases = [
33
+ {
34
+ name: 'builtin API exact helper surface is stable',
35
+ run() {
36
+ const api = builtins.__testBuildBuiltinApi();
37
+ assert.deepEqual(Object.keys(api).sort(), expectedApiKeys);
38
+ assert.equal(Object.isFrozen(api), true);
39
+ assert.equal(Object.isFrozen(api.terms), true);
40
+ assert.equal(Object.isFrozen(api.ns), true);
41
+ for (const [name, spec] of Object.entries(expectedFunctionArities)) {
42
+ assert.equal(typeof api[name], 'function', `${name} must be a function`);
43
+ if (Number.isInteger(spec.arity)) assert.equal(api[name].length, spec.arity, `${name} arity drifted`);
44
+ if (Number.isInteger(spec.arityMin)) assert.ok(api[name].length >= spec.arityMin, `${name} arity drifted`);
45
+ }
46
+ assert.deepEqual(Object.keys(api.terms).sort(), CONTRACT.api.namespaces.terms.slice().sort());
47
+ assert.deepEqual(Object.keys(api.ns).sort(), CONTRACT.api.namespaces.ns.slice().sort());
48
+ assert.equal(api.getBuiltinApiVersion(), CONTRACT.version);
49
+ },
50
+ },
51
+ {
52
+ name: 'registerBuiltinModule accepts all declared module export forms',
53
+ run() {
54
+ assert.doesNotThrow(() => builtins.registerBuiltinModule(require(path.join(fixtures, 'ok-map.js')), 'ok-map'));
55
+ assert.doesNotThrow(() =>
56
+ builtins.registerBuiltinModule(require(path.join(fixtures, 'ok-register.js')), 'ok-register'),
57
+ );
58
+ assert.doesNotThrow(() =>
59
+ builtins.registerBuiltinModule(require(path.join(fixtures, 'ok-builtins.js')), 'ok-builtins'),
60
+ );
61
+ assert.doesNotThrow(() =>
62
+ builtins.registerBuiltinModule(require(path.join(fixtures, 'ok-default-map.js')), 'ok-default-map'),
63
+ );
64
+ },
65
+ },
66
+ {
67
+ name: 'registerBuiltinModule rejects unsupported module exports',
68
+ run() {
69
+ assert.throws(
70
+ () => builtins.registerBuiltinModule(require(path.join(fixtures, 'bad-export.js')), 'bad-export'),
71
+ /must export a function, a \{ register\(\) \} object, or an object mapping predicate IRIs to handlers/,
72
+ );
73
+ },
74
+ },
75
+ {
76
+ name: 'registered builtin handlers must return substitution-delta arrays',
77
+ run() {
78
+ builtins.registerBuiltinModule(require(path.join(fixtures, 'bad-return.js')), 'bad-return');
79
+ assert.throws(() => {
80
+ const h = builtins.registerBuiltin('http://example.org/test#shape-check', () => ({ nope: true }));
81
+ h({
82
+ iri: 'http://example.org/test#shape-check',
83
+ goal: {},
84
+ subst: {},
85
+ facts: [],
86
+ backRules: [],
87
+ depth: 0,
88
+ varGen: 0,
89
+ maxResults: 1,
90
+ api: builtins.__testBuildBuiltinApi(),
91
+ });
92
+ }, /must return an array of substitution deltas/);
93
+ },
94
+ },
95
+ {
96
+ name: 'registered builtin handlers receive the exact stable ctx shape',
97
+ run() {
98
+ const wrapped = builtins.registerBuiltin('http://example.org/test#ctx-shape', ({ subst }) => [subst]);
99
+ assert.throws(
100
+ () =>
101
+ wrapped({
102
+ iri: 'http://example.org/test#ctx-shape',
103
+ goal: {},
104
+ subst: {},
105
+ facts: [],
106
+ backRules: [],
107
+ depth: 0,
108
+ varGen: 0,
109
+ maxResults: 1,
110
+ api: builtins.__testBuildBuiltinApi(),
111
+ extra: true,
112
+ }),
113
+ /Builtin handler ctx keys changed|Builtin handler ctx shape changed/,
114
+ );
115
+ },
116
+ },
117
+ ];
118
+
119
+ let passed = 0;
120
+ let failed = 0;
121
+
122
+ (function main() {
123
+ const suiteStart = Date.now();
124
+ info(`Running ${cases.length} builtin contract tests`);
125
+
126
+ for (const tc of cases) {
127
+ const start = Date.now();
128
+ try {
129
+ tc.run();
130
+ ok(`${tc.name} ${C.dim}(${Date.now() - start} ms)${C.n}`);
131
+ passed++;
132
+ } catch (e) {
133
+ fail(`${tc.name} ${C.dim}(${Date.now() - start} ms)${C.n}`);
134
+ fail(e && e.stack ? e.stack : String(e));
135
+ failed++;
136
+ }
137
+ }
138
+
139
+ console.log('');
140
+ console.log(`${C.y}==${C.n} Total elapsed: ${Date.now() - suiteStart} ms`);
141
+ if (failed === 0) {
142
+ ok(`All builtin contract tests passed (${passed}/${cases.length})`);
143
+ process.exit(0);
144
+ }
145
+ fail(`Some builtin contract tests failed (${passed}/${cases.length})`);
146
+ process.exit(1);
147
+ })();
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = 42;
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ 'http://example.org/test#bad-return': () => ({ nope: true }),
5
+ };
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ builtins: {
5
+ 'http://example.org/test#ok-builtins': ({ subst }) => [subst],
6
+ },
7
+ };
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ default: {
5
+ 'http://example.org/test#ok-default-map': ({ subst }) => [subst],
6
+ },
7
+ };
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ 'http://example.org/test#ok': ({ subst }) => [subst],
5
+ };
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ register(api) {
5
+ api.registerBuiltin('http://example.org/test#ok-register', ({ subst }) => [subst]);
6
+ },
7
+ };