eyeling 1.19.1 → 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 +75 -12
- package/eyeling.js +162 -6
- package/lib/builtin-contract.js +121 -0
- package/lib/builtins.js +38 -6
- package/package.json +3 -2
- package/test/builtin-contract.test.js +147 -0
- package/test/fixtures/builtins/bad-export.js +3 -0
- package/test/fixtures/builtins/bad-return.js +5 -0
- package/test/fixtures/builtins/ok-builtins.js +7 -0
- package/test/fixtures/builtins/ok-default-map.js +7 -0
- package/test/fixtures/builtins/ok-map.js +5 -0
- package/test/fixtures/builtins/ok-register.js +7 -0
package/HANDBOOK.md
CHANGED
|
@@ -2085,9 +2085,26 @@ const { reason } = require('eyeling');
|
|
|
2085
2085
|
const out = reason({ builtinModules: ['./hello-builtin.js'] }, n3Text);
|
|
2086
2086
|
```
|
|
2087
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
|
+
|
|
2088
2105
|
### 16.3 What a builtin module may export
|
|
2089
2106
|
|
|
2090
|
-
Eyeling accepts
|
|
2107
|
+
Eyeling accepts these stable module shapes.
|
|
2091
2108
|
|
|
2092
2109
|
#### A function export
|
|
2093
2110
|
|
|
@@ -2124,23 +2141,54 @@ module.exports = {
|
|
|
2124
2141
|
};
|
|
2125
2142
|
```
|
|
2126
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
|
+
|
|
2127
2166
|
If none of those shapes match, Eyeling rejects the module with a descriptive error.
|
|
2128
2167
|
|
|
2129
2168
|
### 16.4 The handler contract
|
|
2130
2169
|
|
|
2131
|
-
Builtin handlers are called with
|
|
2170
|
+
Builtin handlers are called with an **exactly versioned** context object:
|
|
2132
2171
|
|
|
2133
2172
|
- `iri` — the predicate IRI string
|
|
2134
2173
|
- `goal` — the current triple goal
|
|
2135
2174
|
- `subst` — the current substitution
|
|
2136
|
-
- `facts
|
|
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
|
|
2137
2180
|
- `api` — the same registration/helper API used by modules
|
|
2138
2181
|
|
|
2139
|
-
|
|
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**:
|
|
2140
2185
|
|
|
2141
2186
|
- `[]` means failure / no solutions
|
|
2142
|
-
- `[
|
|
2143
|
-
-
|
|
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.
|
|
2144
2192
|
|
|
2145
2193
|
In practice:
|
|
2146
2194
|
|
|
@@ -2153,13 +2201,28 @@ Custom builtin failures are wrapped so the predicate IRI appears in the thrown e
|
|
|
2153
2201
|
|
|
2154
2202
|
### 16.5 The helper API exposed to builtin modules
|
|
2155
2203
|
|
|
2156
|
-
Builtin modules do not need to import internal engine files directly. Eyeling passes a helper API into module registration,
|
|
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`
|
|
2157
2224
|
|
|
2158
|
-
|
|
2159
|
-
- term constructors via `terms` (`Literal`, `Iri`, `Var`, `Blank`, `ListTerm`, `GraphTerm`, `Triple`, `Rule`, ...)
|
|
2160
|
-
- literal/term helpers such as `internLiteral`, `internIri`, `literalParts`, `termToN3`, `termToJsString`
|
|
2161
|
-
- reasoning helpers such as `unifyTerm`, `applySubstTerm`, `applySubstTriple`, `proveGoals`
|
|
2162
|
-
- 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.
|
|
2163
2226
|
|
|
2164
2227
|
That API keeps the extension boundary explicit: custom builtins get the operations they need without reaching into Eyeling’s private module graph.
|
|
2165
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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') {
|
|
@@ -4472,6 +4626,8 @@ module.exports = {
|
|
|
4472
4626
|
registerBuiltinModule,
|
|
4473
4627
|
loadBuiltinModule,
|
|
4474
4628
|
listBuiltinIris,
|
|
4629
|
+
getBuiltinApiVersion,
|
|
4630
|
+
__testBuildBuiltinApi: __buildBuiltinRegistrationApi,
|
|
4475
4631
|
// shared helpers used by engine/explain
|
|
4476
4632
|
parseBooleanLiteralInfo,
|
|
4477
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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') {
|
|
@@ -3992,6 +4022,8 @@ module.exports = {
|
|
|
3992
4022
|
registerBuiltinModule,
|
|
3993
4023
|
loadBuiltinModule,
|
|
3994
4024
|
listBuiltinIris,
|
|
4025
|
+
getBuiltinApiVersion,
|
|
4026
|
+
__testBuildBuiltinApi: __buildBuiltinRegistrationApi,
|
|
3995
4027
|
// shared helpers used by engine/explain
|
|
3996
4028
|
parseBooleanLiteralInfo,
|
|
3997
4029
|
parseNumericLiteralInfo,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eyeling",
|
|
3
|
-
"version": "1.19.
|
|
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",
|
|
@@ -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
|
+
})();
|