@dlovans/tenet-core 0.2.0 → 0.2.1
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/README.md +41 -39
- package/dist/core/engine.js +7 -5
- package/dist/core/operators.js +40 -15
- package/dist/core/temporal.js +40 -0
- package/dist/core/validate.js +8 -2
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/dist/validation-fixes.test.d.ts +7 -0
- package/dist/validation-fixes.test.js +399 -0
- package/package.json +2 -7
- package/dist/lint.d.ts +0 -31
- package/dist/lint.js +0 -160
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Declarative logic VM for JSON schemas. Reactive validation, temporal routing, and computed state.
|
|
4
4
|
|
|
5
|
+
**Pure TypeScript** — No WASM, no native dependencies. Works in browsers, Node.js, Deno, Bun.
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -13,11 +15,8 @@ npm install @dlovans/tenet-core
|
|
|
13
15
|
### Browser
|
|
14
16
|
|
|
15
17
|
```html
|
|
16
|
-
<script src="https://unpkg.com/@dlovans/tenet-core/wasm/wasm_exec.js"></script>
|
|
17
18
|
<script type="module">
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
await init('/path/to/tenet.wasm');
|
|
19
|
+
import { run } from '@dlovans/tenet-core';
|
|
21
20
|
|
|
22
21
|
const schema = {
|
|
23
22
|
definitions: {
|
|
@@ -33,26 +32,23 @@ const schema = {
|
|
|
33
32
|
};
|
|
34
33
|
|
|
35
34
|
const result = run(schema);
|
|
36
|
-
console.log(result);
|
|
35
|
+
console.log(result.result.status); // 'READY'
|
|
37
36
|
</script>
|
|
38
37
|
```
|
|
39
38
|
|
|
40
39
|
### Node.js
|
|
41
40
|
|
|
42
41
|
```javascript
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
// Initialize WASM
|
|
46
|
-
await init('./node_modules/@dlovans/tenet-core/wasm/tenet.wasm');
|
|
42
|
+
import { run, verify } from '@dlovans/tenet-core';
|
|
47
43
|
|
|
48
|
-
// Run schema logic
|
|
44
|
+
// Run schema logic - no initialization needed
|
|
49
45
|
const result = run(schema, new Date());
|
|
50
46
|
|
|
51
47
|
if (result.error) {
|
|
52
48
|
console.error(result.error);
|
|
53
49
|
} else {
|
|
54
50
|
console.log(result.result.status); // 'READY', 'INCOMPLETE', or 'INVALID'
|
|
55
|
-
console.log(result.result.errors); // Validation errors
|
|
51
|
+
console.log(result.result.errors); // Validation errors (if any)
|
|
56
52
|
}
|
|
57
53
|
|
|
58
54
|
// Verify transformation
|
|
@@ -62,51 +58,57 @@ console.log(verification.valid);
|
|
|
62
58
|
|
|
63
59
|
## API
|
|
64
60
|
|
|
65
|
-
### `init(wasmPath?: string): Promise<void>`
|
|
66
|
-
Initialize the WASM module. Must be called before `run()` or `verify()`.
|
|
67
|
-
|
|
68
61
|
### `run(schema, date?): TenetResult`
|
|
69
62
|
Execute the schema logic for the given effective date.
|
|
70
63
|
|
|
64
|
+
- `schema` — TenetSchema object or JSON string
|
|
65
|
+
- `date` — Effective date for temporal routing (default: now)
|
|
66
|
+
|
|
67
|
+
Returns `{ result: TenetSchema }` or `{ error: string }`.
|
|
68
|
+
|
|
71
69
|
### `verify(newSchema, oldSchema): TenetVerifyResult`
|
|
72
70
|
Verify that a transformation is legal by replaying the logic.
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
Static analysis - find issues without executing the schema.
|
|
72
|
+
Returns `{ valid: boolean, error?: string }`.
|
|
76
73
|
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
### `isReady(): boolean`
|
|
75
|
+
Always returns `true`. Kept for backwards compatibility.
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
### `init(): Promise<void>` *(deprecated)*
|
|
78
|
+
No-op. Kept for backwards compatibility with v0.1.x.
|
|
82
79
|
|
|
83
|
-
|
|
84
|
-
for (const issue of result.issues) {
|
|
85
|
-
console.log(`${issue.severity}: ${issue.message}`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
```
|
|
80
|
+
## Runtime Validation
|
|
89
81
|
|
|
90
|
-
|
|
91
|
-
Check if an object is a Tenet schema.
|
|
82
|
+
The VM automatically detects and reports:
|
|
92
83
|
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
- **Undefined variables** — `{"var": "unknown_field"}`
|
|
85
|
+
- **Unknown operators** — `{"invalid_op": [...]}`
|
|
86
|
+
- **Temporal conflicts** — Overlapping date ranges, same start/end dates
|
|
95
87
|
|
|
96
|
-
|
|
88
|
+
All errors are returned in `result.errors` without failing execution.
|
|
97
89
|
|
|
98
|
-
|
|
90
|
+
## TypeScript
|
|
99
91
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
92
|
+
Full type definitions included:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import type { TenetSchema, TenetResult, Definition, Rule } from '@dlovans/tenet-core';
|
|
105
96
|
```
|
|
106
97
|
|
|
107
|
-
##
|
|
98
|
+
## Migration from v0.1.x
|
|
108
99
|
|
|
109
|
-
|
|
100
|
+
```javascript
|
|
101
|
+
// Before (v0.1.x with WASM)
|
|
102
|
+
import { init, run, lint } from '@dlovans/tenet-core';
|
|
103
|
+
await init('./tenet.wasm');
|
|
104
|
+
const result = run(schema);
|
|
105
|
+
const issues = lint(schema);
|
|
106
|
+
|
|
107
|
+
// After (v0.2.x pure TypeScript)
|
|
108
|
+
import { run } from '@dlovans/tenet-core';
|
|
109
|
+
const result = run(schema);
|
|
110
|
+
// Validation errors are now in result.result.errors
|
|
111
|
+
```
|
|
110
112
|
|
|
111
113
|
## License
|
|
112
114
|
|
package/dist/core/engine.js
CHANGED
|
@@ -193,17 +193,19 @@ export function run(schema, effectiveDate = new Date()) {
|
|
|
193
193
|
};
|
|
194
194
|
// 1. Select temporal branch and prune inactive rules
|
|
195
195
|
applyTemporalRouting(state);
|
|
196
|
-
// 2.
|
|
196
|
+
// 2. Compute derived state (so logic tree can use derived values)
|
|
197
|
+
computeDerived(state);
|
|
198
|
+
// 3. Evaluate logic tree
|
|
197
199
|
evaluateLogicTree(state);
|
|
198
|
-
//
|
|
200
|
+
// 4. Re-compute derived state (in case logic modified inputs)
|
|
199
201
|
computeDerived(state);
|
|
200
|
-
//
|
|
202
|
+
// 5. Validate definitions
|
|
201
203
|
validateDefinitions(state);
|
|
202
|
-
//
|
|
204
|
+
// 6. Check attestations
|
|
203
205
|
checkAttestations(state, (action, ruleId, lawRef) => {
|
|
204
206
|
applyAction(state, action, ruleId, lawRef);
|
|
205
207
|
});
|
|
206
|
-
//
|
|
208
|
+
// 7. Determine status and attach errors
|
|
207
209
|
state.schema.errors = state.errors.length > 0 ? state.errors : undefined;
|
|
208
210
|
state.schema.status = determineStatus(state);
|
|
209
211
|
return { result: state.schema };
|
package/dist/core/operators.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* JSON-logic operators for the Tenet VM.
|
|
3
3
|
* All operators are nil-safe: operations on nil/undefined return appropriate defaults.
|
|
4
4
|
*/
|
|
5
|
+
import { addError } from './validate.js';
|
|
5
6
|
/**
|
|
6
7
|
* Convert a value to a number if possible.
|
|
7
8
|
*/
|
|
@@ -99,17 +100,50 @@ function resolveArgs(args, expected, resolve, state) {
|
|
|
99
100
|
}
|
|
100
101
|
return result;
|
|
101
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Check if a variable name is defined in the schema.
|
|
105
|
+
*/
|
|
106
|
+
function isVariableDefined(name, state) {
|
|
107
|
+
// Check definitions
|
|
108
|
+
if (state.schema.definitions[name]) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
// Check derived state
|
|
112
|
+
if (state.schema.state_model?.derived?.[name]) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
102
117
|
/**
|
|
103
118
|
* Get variable value from schema definitions or current element context.
|
|
119
|
+
* Now accepts an optional resolve function to evaluate derived expressions on-demand.
|
|
104
120
|
*/
|
|
105
|
-
function getVar(path, state) {
|
|
121
|
+
function getVar(path, state, resolve) {
|
|
106
122
|
// Empty path returns current element context (for some/all/none)
|
|
107
123
|
if (path === '') {
|
|
108
124
|
return state.currentElement;
|
|
109
125
|
}
|
|
110
126
|
const parts = path.split('.');
|
|
111
|
-
|
|
112
|
-
|
|
127
|
+
const rootVar = parts[0];
|
|
128
|
+
// Check if variable is defined (only for root-level vars, not nested access)
|
|
129
|
+
if (!isVariableDefined(rootVar, state) && state.currentElement === undefined) {
|
|
130
|
+
addError(state, '', '', `Undefined variable '${rootVar}' in logic expression`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
// First, check derived state (derived values take precedence)
|
|
134
|
+
if (state.schema.state_model?.derived && resolve) {
|
|
135
|
+
const derived = state.schema.state_model.derived[rootVar];
|
|
136
|
+
if (derived?.eval) {
|
|
137
|
+
// Evaluate the derived expression on-demand
|
|
138
|
+
const result = resolve(derived.eval, state);
|
|
139
|
+
if (parts.length === 1) {
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
return accessPath(result, parts.slice(1));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Then, check definitions
|
|
146
|
+
const def = state.schema.definitions[rootVar];
|
|
113
147
|
if (def) {
|
|
114
148
|
if (parts.length === 1) {
|
|
115
149
|
return def.value;
|
|
@@ -117,15 +151,6 @@ function getVar(path, state) {
|
|
|
117
151
|
// Nested access into the value
|
|
118
152
|
return accessPath(def.value, parts.slice(1));
|
|
119
153
|
}
|
|
120
|
-
// Check derived state
|
|
121
|
-
if (state.schema.state_model?.derived) {
|
|
122
|
-
const derived = state.schema.state_model.derived[parts[0]];
|
|
123
|
-
if (derived) {
|
|
124
|
-
// Note: derived values should already be computed by this point
|
|
125
|
-
// This is a fallback for direct access
|
|
126
|
-
return undefined;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
154
|
return undefined;
|
|
130
155
|
}
|
|
131
156
|
/**
|
|
@@ -147,9 +172,9 @@ function accessPath(value, parts) {
|
|
|
147
172
|
// ============================================================
|
|
148
173
|
const operators = {
|
|
149
174
|
// === Variable Access ===
|
|
150
|
-
'var': (args, state) => {
|
|
175
|
+
'var': (args, state, resolve) => {
|
|
151
176
|
const path = typeof args === 'string' ? args : '';
|
|
152
|
-
return getVar(path, state);
|
|
177
|
+
return getVar(path, state, resolve);
|
|
153
178
|
},
|
|
154
179
|
// === Comparison Operators ===
|
|
155
180
|
'==': (args, state, resolve) => {
|
|
@@ -417,7 +442,7 @@ const operators = {
|
|
|
417
442
|
export function applyOperator(op, args, state, resolve) {
|
|
418
443
|
const fn = operators[op];
|
|
419
444
|
if (!fn) {
|
|
420
|
-
|
|
445
|
+
addError(state, '', '', `Unknown operator '${op}' in logic expression`);
|
|
421
446
|
return null;
|
|
422
447
|
}
|
|
423
448
|
return fn(args, state, resolve);
|
package/dist/core/temporal.js
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
* Temporal branch selection and rule pruning.
|
|
3
3
|
* Routes logic based on effective dates for bitemporal support.
|
|
4
4
|
*/
|
|
5
|
+
import { addError } from './validate.js';
|
|
6
|
+
/**
|
|
7
|
+
* Validate temporal_map for configuration errors.
|
|
8
|
+
* Checks for same start/end dates and overlapping ranges.
|
|
9
|
+
*/
|
|
10
|
+
function validateTemporalMap(state) {
|
|
11
|
+
const { schema } = state;
|
|
12
|
+
if (!schema.temporal_map || schema.temporal_map.length === 0) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
for (let i = 0; i < schema.temporal_map.length; i++) {
|
|
16
|
+
const branch = schema.temporal_map[i];
|
|
17
|
+
if (!branch) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const start = branch.valid_range[0];
|
|
21
|
+
const end = branch.valid_range[1];
|
|
22
|
+
// Check for same start/end date (invalid zero-length range)
|
|
23
|
+
if (start && end && start === end) {
|
|
24
|
+
addError(state, '', '', `Temporal branch ${i} has same start and end date '${start}' (invalid range)`);
|
|
25
|
+
}
|
|
26
|
+
// Check for overlapping with previous branch
|
|
27
|
+
if (i > 0) {
|
|
28
|
+
const prev = schema.temporal_map[i - 1];
|
|
29
|
+
if (prev) {
|
|
30
|
+
const prevEnd = prev.valid_range[1]
|
|
31
|
+
? new Date(prev.valid_range[1]).getTime()
|
|
32
|
+
: Infinity;
|
|
33
|
+
const currStart = start
|
|
34
|
+
? new Date(start).getTime()
|
|
35
|
+
: -Infinity;
|
|
36
|
+
if (currStart <= prevEnd) {
|
|
37
|
+
addError(state, '', '', `Temporal branch ${i} overlaps with branch ${i - 1} (ranges must not overlap)`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
5
43
|
/**
|
|
6
44
|
* Find the active temporal branch for a given effective date.
|
|
7
45
|
* Returns undefined if no branch matches (uses default/unversioned logic).
|
|
@@ -62,6 +100,8 @@ export function pruneRules(state, activeBranch) {
|
|
|
62
100
|
* Call this at the start of Run().
|
|
63
101
|
*/
|
|
64
102
|
export function applyTemporalRouting(state) {
|
|
103
|
+
// Validate temporal_map configuration
|
|
104
|
+
validateTemporalMap(state);
|
|
65
105
|
const branch = selectBranch(state);
|
|
66
106
|
if (branch) {
|
|
67
107
|
pruneRules(state, branch);
|
package/dist/core/validate.js
CHANGED
|
@@ -120,8 +120,14 @@ export function validateDefinitions(state) {
|
|
|
120
120
|
continue;
|
|
121
121
|
}
|
|
122
122
|
// Check required fields
|
|
123
|
-
if (def.required
|
|
124
|
-
|
|
123
|
+
if (def.required) {
|
|
124
|
+
if (def.value === undefined || def.value === null) {
|
|
125
|
+
addError(state, id, '', `Required field '${id}' is missing`);
|
|
126
|
+
}
|
|
127
|
+
else if ((def.type === 'string' || def.type === 'select') && def.value === '') {
|
|
128
|
+
// Empty string is also considered "missing" for required string/select fields
|
|
129
|
+
addError(state, id, '', `Required field '${id}' is missing`);
|
|
130
|
+
}
|
|
125
131
|
}
|
|
126
132
|
// Validate type if value is present
|
|
127
133
|
if (def.value !== undefined && def.value !== null) {
|
package/dist/index.d.ts
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
* This module provides a pure TypeScript implementation of the Tenet VM.
|
|
5
5
|
* Works in both browser and Node.js environments with no WASM dependencies.
|
|
6
6
|
*/
|
|
7
|
-
export { lint, isTenetSchema, SCHEMA_URL } from './lint.js';
|
|
8
|
-
export type { LintIssue, LintResult } from './lint.js';
|
|
9
7
|
export interface TenetResult {
|
|
10
8
|
result?: TenetSchema;
|
|
11
9
|
error?: string;
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
* This module provides a pure TypeScript implementation of the Tenet VM.
|
|
5
5
|
* Works in both browser and Node.js environments with no WASM dependencies.
|
|
6
6
|
*/
|
|
7
|
-
// Re-export lint functions (pure TypeScript)
|
|
8
|
-
export { lint, isTenetSchema, SCHEMA_URL } from './lint.js';
|
|
9
7
|
// Import core engine functions
|
|
10
8
|
import { run as coreRun, verify as coreVerify } from './core/engine.js';
|
|
11
9
|
/**
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for validation fixes:
|
|
3
|
+
* - Issue 1: Empty string passes required validation
|
|
4
|
+
* - Issue 2: Derived fields shadowed by definitions
|
|
5
|
+
* - Issue 3: Execution order - logic before derived
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert';
|
|
9
|
+
import { run } from './index.js';
|
|
10
|
+
// ===========================================================================
|
|
11
|
+
// Issue 1: Empty String Required Validation
|
|
12
|
+
// ===========================================================================
|
|
13
|
+
describe('Issue 1: Empty String Required Validation', () => {
|
|
14
|
+
it('should treat empty string as missing for required string fields', () => {
|
|
15
|
+
const schema = {
|
|
16
|
+
protocol: 'Test_v1',
|
|
17
|
+
schema_id: 'test',
|
|
18
|
+
definitions: {
|
|
19
|
+
name: { type: 'string', value: '', required: true },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const result = run(schema);
|
|
23
|
+
assert.ok(!result.error, `Run failed: ${result.error}`);
|
|
24
|
+
assert.ok(result.result);
|
|
25
|
+
// Status should be INCOMPLETE because empty string is "missing"
|
|
26
|
+
assert.strictEqual(result.result.status, 'INCOMPLETE', `Expected status INCOMPLETE for empty required string, got ${result.result.status}`);
|
|
27
|
+
// Should have an error for the missing field
|
|
28
|
+
assert.ok(result.result.errors && result.result.errors.length > 0, 'Expected error for empty required string');
|
|
29
|
+
});
|
|
30
|
+
it('should accept empty string for non-required fields', () => {
|
|
31
|
+
const schema = {
|
|
32
|
+
protocol: 'Test_v1',
|
|
33
|
+
schema_id: 'test',
|
|
34
|
+
definitions: {
|
|
35
|
+
notes: { type: 'string', value: '', required: false },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const result = run(schema);
|
|
39
|
+
assert.ok(!result.error);
|
|
40
|
+
assert.ok(result.result);
|
|
41
|
+
// Status should be READY because field is not required
|
|
42
|
+
assert.strictEqual(result.result.status, 'READY');
|
|
43
|
+
});
|
|
44
|
+
it('should accept zero for required number fields', () => {
|
|
45
|
+
const schema = {
|
|
46
|
+
protocol: 'Test_v1',
|
|
47
|
+
schema_id: 'test',
|
|
48
|
+
definitions: {
|
|
49
|
+
quantity: { type: 'number', value: 0, required: true },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const result = run(schema);
|
|
53
|
+
assert.ok(!result.error);
|
|
54
|
+
assert.ok(result.result);
|
|
55
|
+
// Status should be READY (0 is a valid value for required number)
|
|
56
|
+
assert.strictEqual(result.result.status, 'READY');
|
|
57
|
+
});
|
|
58
|
+
it('should catch empty allergy_note in survey schema', () => {
|
|
59
|
+
const schema = {
|
|
60
|
+
protocol: 'CoffeePreferenceSurvey_v1',
|
|
61
|
+
schema_id: 'coffee-pref-001',
|
|
62
|
+
definitions: {
|
|
63
|
+
respondent_name: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
label: 'Your Name',
|
|
66
|
+
required: true,
|
|
67
|
+
value: 'Jane Doe',
|
|
68
|
+
},
|
|
69
|
+
allergy_note: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
label: 'Please describe your allergy',
|
|
72
|
+
required: true,
|
|
73
|
+
visible: true,
|
|
74
|
+
value: '',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const result = run(schema);
|
|
79
|
+
assert.ok(!result.error);
|
|
80
|
+
assert.ok(result.result);
|
|
81
|
+
// Should be INCOMPLETE because allergy_note is required but empty
|
|
82
|
+
assert.strictEqual(result.result.status, 'INCOMPLETE');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
// ===========================================================================
|
|
86
|
+
// Issue 2: Derived Fields Shadowing
|
|
87
|
+
// ===========================================================================
|
|
88
|
+
describe('Issue 2: Derived Fields Take Precedence', () => {
|
|
89
|
+
it('should compute derived value even when field exists in definitions', () => {
|
|
90
|
+
const schema = {
|
|
91
|
+
protocol: 'Test_v1',
|
|
92
|
+
schema_id: 'test',
|
|
93
|
+
definitions: {
|
|
94
|
+
gross: { type: 'number', value: 100 },
|
|
95
|
+
tax: { type: 'number', value: null, readonly: true },
|
|
96
|
+
},
|
|
97
|
+
state_model: {
|
|
98
|
+
inputs: ['gross'],
|
|
99
|
+
derived: {
|
|
100
|
+
tax: { eval: { '*': [{ var: 'gross' }, 0.1] } },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const result = run(schema);
|
|
105
|
+
assert.ok(!result.error);
|
|
106
|
+
assert.ok(result.result);
|
|
107
|
+
// Tax should be computed as 10 (100 * 0.1)
|
|
108
|
+
const taxDef = result.result.definitions.tax;
|
|
109
|
+
assert.ok(taxDef, 'Expected tax definition to exist');
|
|
110
|
+
assert.strictEqual(taxDef.value, 10, `Expected tax = 10, got ${taxDef.value}`);
|
|
111
|
+
});
|
|
112
|
+
it('should allow logic tree to use derived values', () => {
|
|
113
|
+
const schema = {
|
|
114
|
+
protocol: 'Test_v1',
|
|
115
|
+
schema_id: 'test',
|
|
116
|
+
definitions: {
|
|
117
|
+
gross: { type: 'number', value: 100 },
|
|
118
|
+
},
|
|
119
|
+
state_model: {
|
|
120
|
+
inputs: ['gross'],
|
|
121
|
+
derived: {
|
|
122
|
+
tax: { eval: { '*': [{ var: 'gross' }, 0.1] } },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
logic_tree: [
|
|
126
|
+
{
|
|
127
|
+
id: 'check_tax',
|
|
128
|
+
when: { '>': [{ var: 'tax' }, 5] },
|
|
129
|
+
then: { set: { high_tax: true } },
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
const result = run(schema);
|
|
134
|
+
assert.ok(!result.error);
|
|
135
|
+
assert.ok(result.result);
|
|
136
|
+
// high_tax should be set because tax (10) > 5
|
|
137
|
+
const highTaxDef = result.result.definitions.high_tax;
|
|
138
|
+
assert.ok(highTaxDef, 'Expected high_tax definition to be created');
|
|
139
|
+
assert.strictEqual(highTaxDef.value, true, `Expected high_tax = true, got ${highTaxDef.value}`);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
// ===========================================================================
|
|
143
|
+
// Issue 3: Execution Order (Logic Before Derived)
|
|
144
|
+
// ===========================================================================
|
|
145
|
+
describe('Issue 3: Logic Can Use Derived Values', () => {
|
|
146
|
+
it('should compute derived values before logic tree evaluation', () => {
|
|
147
|
+
const schema = {
|
|
148
|
+
protocol: 'Test_v1',
|
|
149
|
+
schema_id: 'test',
|
|
150
|
+
definitions: {
|
|
151
|
+
income: { type: 'number', value: 50000 },
|
|
152
|
+
deductions: { type: 'number', value: 10000 },
|
|
153
|
+
},
|
|
154
|
+
state_model: {
|
|
155
|
+
inputs: ['income', 'deductions'],
|
|
156
|
+
derived: {
|
|
157
|
+
taxable_income: { eval: { '-': [{ var: 'income' }, { var: 'deductions' }] } },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
logic_tree: [
|
|
161
|
+
{
|
|
162
|
+
id: 'high_income_bracket',
|
|
163
|
+
when: { '>': [{ var: 'taxable_income' }, 30000] },
|
|
164
|
+
then: { set: { tax_bracket: 'high' } },
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
const result = run(schema);
|
|
169
|
+
assert.ok(!result.error);
|
|
170
|
+
assert.ok(result.result);
|
|
171
|
+
// taxable_income should be 40000 (50000 - 10000)
|
|
172
|
+
const taxableIncomeDef = result.result.definitions.taxable_income;
|
|
173
|
+
assert.ok(taxableIncomeDef, 'Expected taxable_income definition');
|
|
174
|
+
assert.strictEqual(taxableIncomeDef.value, 40000);
|
|
175
|
+
// tax_bracket should be "high" because taxable_income (40000) > 30000
|
|
176
|
+
const taxBracketDef = result.result.definitions.tax_bracket;
|
|
177
|
+
assert.ok(taxBracketDef, 'Expected tax_bracket to be set by logic rule');
|
|
178
|
+
assert.strictEqual(taxBracketDef.value, 'high');
|
|
179
|
+
});
|
|
180
|
+
it('should calculate effective tax rate in tax calculator schema', () => {
|
|
181
|
+
const schema = {
|
|
182
|
+
protocol: 'IncomeTaxCalculator_v1',
|
|
183
|
+
schema_id: 'tax-calc-001',
|
|
184
|
+
definitions: {
|
|
185
|
+
gross_annual_income: { type: 'currency', value: 85000 },
|
|
186
|
+
filing_status: {
|
|
187
|
+
type: 'select',
|
|
188
|
+
options: ['single', 'married_joint', 'married_separate'],
|
|
189
|
+
value: 'single',
|
|
190
|
+
},
|
|
191
|
+
standard_deduction: { type: 'currency', readonly: true, value: null },
|
|
192
|
+
taxable_income: { type: 'currency', readonly: true, value: null },
|
|
193
|
+
effective_tax_rate: { type: 'number', readonly: true, value: null },
|
|
194
|
+
},
|
|
195
|
+
state_model: {
|
|
196
|
+
inputs: ['gross_annual_income', 'filing_status'],
|
|
197
|
+
derived: {
|
|
198
|
+
standard_deduction: {
|
|
199
|
+
eval: {
|
|
200
|
+
if: [
|
|
201
|
+
{ '==': [{ var: 'filing_status' }, 'single'] },
|
|
202
|
+
14600,
|
|
203
|
+
{ '==': [{ var: 'filing_status' }, 'married_joint'] },
|
|
204
|
+
29200,
|
|
205
|
+
21900,
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
taxable_income: {
|
|
210
|
+
eval: {
|
|
211
|
+
'-': [{ var: 'gross_annual_income' }, { var: 'standard_deduction' }],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
logic_tree: [
|
|
217
|
+
{
|
|
218
|
+
id: 'calc_effective_rate',
|
|
219
|
+
when: { '>': [{ var: 'taxable_income' }, 0] },
|
|
220
|
+
then: {
|
|
221
|
+
set: {
|
|
222
|
+
effective_tax_rate: {
|
|
223
|
+
'/': [
|
|
224
|
+
{ '*': [{ var: 'taxable_income' }, 0.22] },
|
|
225
|
+
{ var: 'gross_annual_income' },
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
const result = run(schema);
|
|
234
|
+
assert.ok(!result.error);
|
|
235
|
+
assert.ok(result.result);
|
|
236
|
+
// Check standard_deduction is 14600 for single
|
|
237
|
+
const stdDedDef = result.result.definitions.standard_deduction;
|
|
238
|
+
assert.ok(stdDedDef, 'Expected standard_deduction definition');
|
|
239
|
+
assert.strictEqual(stdDedDef.value, 14600);
|
|
240
|
+
// Check taxable_income is 70400 (85000 - 14600)
|
|
241
|
+
const taxableIncomeDef = result.result.definitions.taxable_income;
|
|
242
|
+
assert.ok(taxableIncomeDef, 'Expected taxable_income definition');
|
|
243
|
+
assert.strictEqual(taxableIncomeDef.value, 70400);
|
|
244
|
+
// Check effective_tax_rate is calculated (70400 * 0.22 / 85000 = ~0.182)
|
|
245
|
+
const effectiveRateDef = result.result.definitions.effective_tax_rate;
|
|
246
|
+
assert.ok(effectiveRateDef, 'Expected effective_tax_rate to be calculated');
|
|
247
|
+
const effectiveVal = effectiveRateDef.value;
|
|
248
|
+
assert.ok(effectiveVal > 0.18 && effectiveVal < 0.19, `Expected effective_tax_rate around 0.182, got ${effectiveVal}`);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
// ===========================================================================
|
|
252
|
+
// Edge Cases & Complex Scenarios
|
|
253
|
+
// ===========================================================================
|
|
254
|
+
describe('Edge Cases', () => {
|
|
255
|
+
it('should handle chained derived fields', () => {
|
|
256
|
+
const schema = {
|
|
257
|
+
protocol: 'Test_v1',
|
|
258
|
+
schema_id: 'test',
|
|
259
|
+
definitions: {
|
|
260
|
+
base: { type: 'number', value: 100 },
|
|
261
|
+
},
|
|
262
|
+
state_model: {
|
|
263
|
+
inputs: ['base'],
|
|
264
|
+
derived: {
|
|
265
|
+
level1: { eval: { '*': [{ var: 'base' }, 2] } },
|
|
266
|
+
level2: { eval: { '*': [{ var: 'level1' }, 3] } },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const result = run(schema);
|
|
271
|
+
assert.ok(!result.error);
|
|
272
|
+
assert.ok(result.result);
|
|
273
|
+
// level1 = 100 * 2 = 200
|
|
274
|
+
const level1Def = result.result.definitions.level1;
|
|
275
|
+
assert.ok(level1Def);
|
|
276
|
+
assert.strictEqual(level1Def.value, 200);
|
|
277
|
+
// level2 = 200 * 3 = 600
|
|
278
|
+
const level2Def = result.result.definitions.level2;
|
|
279
|
+
assert.ok(level2Def);
|
|
280
|
+
assert.strictEqual(level2Def.value, 600);
|
|
281
|
+
});
|
|
282
|
+
it('should re-compute derived after logic modifies inputs', () => {
|
|
283
|
+
const schema = {
|
|
284
|
+
protocol: 'Test_v1',
|
|
285
|
+
schema_id: 'test',
|
|
286
|
+
definitions: {
|
|
287
|
+
discount_eligible: { type: 'boolean', value: false },
|
|
288
|
+
base_price: { type: 'number', value: 100 },
|
|
289
|
+
},
|
|
290
|
+
state_model: {
|
|
291
|
+
inputs: ['discount_eligible', 'base_price'],
|
|
292
|
+
derived: {
|
|
293
|
+
final_price: {
|
|
294
|
+
eval: {
|
|
295
|
+
if: [
|
|
296
|
+
{ var: 'discount_eligible' },
|
|
297
|
+
{ '*': [{ var: 'base_price' }, 0.9] },
|
|
298
|
+
{ var: 'base_price' },
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
logic_tree: [
|
|
305
|
+
{
|
|
306
|
+
id: 'apply_discount',
|
|
307
|
+
when: { '>': [{ var: 'base_price' }, 50] },
|
|
308
|
+
then: { set: { discount_eligible: true } },
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
const result = run(schema);
|
|
313
|
+
assert.ok(!result.error);
|
|
314
|
+
assert.ok(result.result);
|
|
315
|
+
// discount_eligible should be true
|
|
316
|
+
const discountDef = result.result.definitions.discount_eligible;
|
|
317
|
+
assert.ok(discountDef);
|
|
318
|
+
assert.strictEqual(discountDef.value, true);
|
|
319
|
+
// final_price should be 90 (100 * 0.9) because discount_eligible was set by logic
|
|
320
|
+
const finalPriceDef = result.result.definitions.final_price;
|
|
321
|
+
assert.ok(finalPriceDef);
|
|
322
|
+
assert.strictEqual(finalPriceDef.value, 90);
|
|
323
|
+
});
|
|
324
|
+
it('should catch multiple required empty strings', () => {
|
|
325
|
+
const schema = {
|
|
326
|
+
protocol: 'Test_v1',
|
|
327
|
+
schema_id: 'test',
|
|
328
|
+
definitions: {
|
|
329
|
+
first_name: { type: 'string', value: '', required: true },
|
|
330
|
+
last_name: { type: 'string', value: '', required: true },
|
|
331
|
+
nickname: { type: 'string', value: '', required: false },
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
const result = run(schema);
|
|
335
|
+
assert.ok(!result.error);
|
|
336
|
+
assert.ok(result.result);
|
|
337
|
+
// Status should be INCOMPLETE
|
|
338
|
+
assert.strictEqual(result.result.status, 'INCOMPLETE');
|
|
339
|
+
// Should have errors for both first_name and last_name
|
|
340
|
+
assert.ok(result.result.errors && result.result.errors.length >= 2, 'Expected at least 2 errors');
|
|
341
|
+
});
|
|
342
|
+
it('should use derived value when definition has null value', () => {
|
|
343
|
+
const schema = {
|
|
344
|
+
protocol: 'Test_v1',
|
|
345
|
+
schema_id: 'test',
|
|
346
|
+
definitions: {
|
|
347
|
+
input_a: { type: 'number', value: 10 },
|
|
348
|
+
input_b: { type: 'number', value: 20 },
|
|
349
|
+
result: { type: 'number', value: null, readonly: true },
|
|
350
|
+
},
|
|
351
|
+
state_model: {
|
|
352
|
+
inputs: ['input_a', 'input_b'],
|
|
353
|
+
derived: {
|
|
354
|
+
result: { eval: { '+': [{ var: 'input_a' }, { var: 'input_b' }] } },
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
const result = run(schema);
|
|
359
|
+
assert.ok(!result.error);
|
|
360
|
+
assert.ok(result.result);
|
|
361
|
+
// result should be 30 (10 + 20), not null
|
|
362
|
+
const resultDef = result.result.definitions.result;
|
|
363
|
+
assert.ok(resultDef);
|
|
364
|
+
assert.strictEqual(resultDef.value, 30);
|
|
365
|
+
});
|
|
366
|
+
it('should compare two derived values in logic', () => {
|
|
367
|
+
const schema = {
|
|
368
|
+
protocol: 'Test_v1',
|
|
369
|
+
schema_id: 'test',
|
|
370
|
+
definitions: {
|
|
371
|
+
price_a: { type: 'number', value: 100 },
|
|
372
|
+
price_b: { type: 'number', value: 80 },
|
|
373
|
+
},
|
|
374
|
+
state_model: {
|
|
375
|
+
inputs: ['price_a', 'price_b'],
|
|
376
|
+
derived: {
|
|
377
|
+
discounted_a: { eval: { '*': [{ var: 'price_a' }, 0.8] } },
|
|
378
|
+
discounted_b: { eval: { '*': [{ var: 'price_b' }, 0.9] } },
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
logic_tree: [
|
|
382
|
+
{
|
|
383
|
+
id: 'compare_prices',
|
|
384
|
+
when: { '>': [{ var: 'discounted_a' }, { var: 'discounted_b' }] },
|
|
385
|
+
then: { set: { best_deal: 'B' } },
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
};
|
|
389
|
+
const result = run(schema);
|
|
390
|
+
assert.ok(!result.error);
|
|
391
|
+
assert.ok(result.result);
|
|
392
|
+
// discounted_a = 100 * 0.8 = 80
|
|
393
|
+
// discounted_b = 80 * 0.9 = 72
|
|
394
|
+
// 80 > 72, so best_deal = "B"
|
|
395
|
+
const bestDealDef = result.result.definitions.best_deal;
|
|
396
|
+
assert.ok(bestDealDef, 'Expected best_deal to be set');
|
|
397
|
+
assert.strictEqual(bestDealDef.value, 'B');
|
|
398
|
+
});
|
|
399
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dlovans/tenet-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Declarative logic VM for JSON schemas - reactive validation, temporal routing, and computed state",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,10 +9,6 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"import": "./dist/index.js",
|
|
11
11
|
"types": "./dist/index.d.ts"
|
|
12
|
-
},
|
|
13
|
-
"./lint": {
|
|
14
|
-
"import": "./dist/lint.js",
|
|
15
|
-
"types": "./dist/lint.d.ts"
|
|
16
12
|
}
|
|
17
13
|
},
|
|
18
14
|
"files": [
|
|
@@ -30,8 +26,7 @@
|
|
|
30
26
|
"schema",
|
|
31
27
|
"form",
|
|
32
28
|
"compliance",
|
|
33
|
-
"temporal"
|
|
34
|
-
"linter"
|
|
29
|
+
"temporal"
|
|
35
30
|
],
|
|
36
31
|
"author": "Dlovan Sharif",
|
|
37
32
|
"license": "MIT",
|
package/dist/lint.d.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tenet Linter - Static Analysis for Tenet Schemas
|
|
3
|
-
*
|
|
4
|
-
* Pure TypeScript implementation - no WASM required.
|
|
5
|
-
* Can be used in browsers, Node.js, and edge runtimes.
|
|
6
|
-
*/
|
|
7
|
-
import type { TenetSchema } from './index';
|
|
8
|
-
export declare const SCHEMA_URL = "https://tenet.dev/schema/v1.json";
|
|
9
|
-
export interface LintIssue {
|
|
10
|
-
severity: 'error' | 'warning' | 'info';
|
|
11
|
-
field?: string;
|
|
12
|
-
rule?: string;
|
|
13
|
-
message: string;
|
|
14
|
-
}
|
|
15
|
-
export interface LintResult {
|
|
16
|
-
valid: boolean;
|
|
17
|
-
issues: LintIssue[];
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Perform static analysis on a Tenet schema without executing it.
|
|
21
|
-
* Detects potential issues like undefined variables, cycles, and missing fields.
|
|
22
|
-
*
|
|
23
|
-
* @param schema - The schema object or JSON string
|
|
24
|
-
* @returns Lint result with issues found
|
|
25
|
-
*/
|
|
26
|
-
export declare function lint(schema: TenetSchema | string): LintResult;
|
|
27
|
-
/**
|
|
28
|
-
* Check if a schema is a valid Tenet schema (basic detection).
|
|
29
|
-
* Useful for IDE integration to detect Tenet files.
|
|
30
|
-
*/
|
|
31
|
-
export declare function isTenetSchema(schema: unknown): schema is TenetSchema;
|
package/dist/lint.js
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tenet Linter - Static Analysis for Tenet Schemas
|
|
3
|
-
*
|
|
4
|
-
* Pure TypeScript implementation - no WASM required.
|
|
5
|
-
* Can be used in browsers, Node.js, and edge runtimes.
|
|
6
|
-
*/
|
|
7
|
-
// JSON Schema URL for IDE integration
|
|
8
|
-
export const SCHEMA_URL = 'https://tenet.dev/schema/v1.json';
|
|
9
|
-
/**
|
|
10
|
-
* Perform static analysis on a Tenet schema without executing it.
|
|
11
|
-
* Detects potential issues like undefined variables, cycles, and missing fields.
|
|
12
|
-
*
|
|
13
|
-
* @param schema - The schema object or JSON string
|
|
14
|
-
* @returns Lint result with issues found
|
|
15
|
-
*/
|
|
16
|
-
export function lint(schema) {
|
|
17
|
-
let parsed;
|
|
18
|
-
try {
|
|
19
|
-
parsed = typeof schema === 'string' ? JSON.parse(schema) : schema;
|
|
20
|
-
}
|
|
21
|
-
catch (e) {
|
|
22
|
-
return {
|
|
23
|
-
valid: false,
|
|
24
|
-
issues: [{ severity: 'error', message: `Parse error: ${e}` }]
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
const result = {
|
|
28
|
-
valid: true,
|
|
29
|
-
issues: []
|
|
30
|
-
};
|
|
31
|
-
// Collect all defined field names
|
|
32
|
-
const definedFields = new Set();
|
|
33
|
-
if (parsed.definitions) {
|
|
34
|
-
for (const name of Object.keys(parsed.definitions)) {
|
|
35
|
-
definedFields.add(name);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
// Add derived fields
|
|
39
|
-
if (parsed.state_model?.derived) {
|
|
40
|
-
for (const name of Object.keys(parsed.state_model.derived)) {
|
|
41
|
-
definedFields.add(name);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
// Check 1: Schema identification
|
|
45
|
-
if (!parsed.protocol && !parsed['$schema']) {
|
|
46
|
-
result.issues.push({
|
|
47
|
-
severity: 'info',
|
|
48
|
-
message: `Consider adding "protocol": "Tenet_v1.0" or "$schema": "${SCHEMA_URL}" for IDE support`
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
// Check 2: Undefined variables in logic tree
|
|
52
|
-
if (parsed.logic_tree) {
|
|
53
|
-
for (const rule of parsed.logic_tree) {
|
|
54
|
-
if (!rule)
|
|
55
|
-
continue;
|
|
56
|
-
const varsInWhen = extractVars(rule.when);
|
|
57
|
-
for (const v of varsInWhen) {
|
|
58
|
-
if (!definedFields.has(v)) {
|
|
59
|
-
addError(result, v, rule.id, `Undefined variable '${v}' in rule condition`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
// Check 3: Potential cycles (fields set by multiple rules)
|
|
65
|
-
const fieldSetBy = new Map();
|
|
66
|
-
if (parsed.logic_tree) {
|
|
67
|
-
for (const rule of parsed.logic_tree) {
|
|
68
|
-
if (!rule?.then?.set)
|
|
69
|
-
continue;
|
|
70
|
-
for (const field of Object.keys(rule.then.set)) {
|
|
71
|
-
const rules = fieldSetBy.get(field) || [];
|
|
72
|
-
rules.push(rule.id);
|
|
73
|
-
fieldSetBy.set(field, rules);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
for (const [field, rules] of fieldSetBy) {
|
|
78
|
-
if (rules.length > 1) {
|
|
79
|
-
addWarning(result, field, '', `Field '${field}' may be set by multiple rules: [${rules.sort().join(', ')}] (potential cycle)`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
// Check 4: Temporal map validation
|
|
83
|
-
if (parsed.temporal_map) {
|
|
84
|
-
for (let i = 0; i < parsed.temporal_map.length; i++) {
|
|
85
|
-
const branch = parsed.temporal_map[i];
|
|
86
|
-
if (!branch)
|
|
87
|
-
continue;
|
|
88
|
-
if (!branch.logic_version) {
|
|
89
|
-
addWarning(result, '', '', `Temporal branch ${i} has no logic_version`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
// Check 5: Empty type in definitions
|
|
94
|
-
if (parsed.definitions) {
|
|
95
|
-
for (const [name, def] of Object.entries(parsed.definitions)) {
|
|
96
|
-
if (!def)
|
|
97
|
-
continue;
|
|
98
|
-
if (!def.type) {
|
|
99
|
-
addWarning(result, name, '', `Definition '${name}' has no type specified`);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Check if a schema is a valid Tenet schema (basic detection).
|
|
107
|
-
* Useful for IDE integration to detect Tenet files.
|
|
108
|
-
*/
|
|
109
|
-
export function isTenetSchema(schema) {
|
|
110
|
-
if (typeof schema !== 'object' || schema === null)
|
|
111
|
-
return false;
|
|
112
|
-
const obj = schema;
|
|
113
|
-
// Check for $schema URL
|
|
114
|
-
if (obj['$schema'] === SCHEMA_URL)
|
|
115
|
-
return true;
|
|
116
|
-
// Check for protocol field
|
|
117
|
-
if (typeof obj.protocol === 'string' && obj.protocol.startsWith('Tenet'))
|
|
118
|
-
return true;
|
|
119
|
-
// Check for definitions + logic_tree structure
|
|
120
|
-
if (obj.definitions && typeof obj.definitions === 'object')
|
|
121
|
-
return true;
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
// Helper functions
|
|
125
|
-
function addError(result, field, rule, message) {
|
|
126
|
-
result.valid = false;
|
|
127
|
-
result.issues.push({ severity: 'error', field, rule, message });
|
|
128
|
-
}
|
|
129
|
-
function addWarning(result, field, rule, message) {
|
|
130
|
-
result.issues.push({ severity: 'warning', field, rule, message });
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Extract all variable references from a JSON-logic expression.
|
|
134
|
-
*/
|
|
135
|
-
function extractVars(node) {
|
|
136
|
-
if (node === null || node === undefined)
|
|
137
|
-
return [];
|
|
138
|
-
const vars = [];
|
|
139
|
-
if (typeof node === 'object') {
|
|
140
|
-
if (Array.isArray(node)) {
|
|
141
|
-
for (const elem of node) {
|
|
142
|
-
vars.push(...extractVars(elem));
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
else {
|
|
146
|
-
const obj = node;
|
|
147
|
-
// Check if this is a var reference
|
|
148
|
-
if ('var' in obj && typeof obj.var === 'string') {
|
|
149
|
-
// Get root variable name (before any dot notation)
|
|
150
|
-
const varName = obj.var.split('.')[0];
|
|
151
|
-
vars.push(varName);
|
|
152
|
-
}
|
|
153
|
-
// Recurse into all values
|
|
154
|
-
for (const val of Object.values(obj)) {
|
|
155
|
-
vars.push(...extractVars(val));
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return vars;
|
|
160
|
-
}
|