@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 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 { init, run } from '@dlovans/tenet-core';
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 { init, run, verify } from '@dlovans/tenet-core';
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
- ### `lint(schema): LintResult` *(No WASM required)*
75
- Static analysis - find issues without executing the schema.
72
+ Returns `{ valid: boolean, error?: string }`.
76
73
 
77
- ```javascript
78
- import { lint } from '@dlovans/tenet-core';
74
+ ### `isReady(): boolean`
75
+ Always returns `true`. Kept for backwards compatibility.
79
76
 
80
- const result = lint(schema);
81
- // No init() needed - pure TypeScript!
77
+ ### `init(): Promise<void>` *(deprecated)*
78
+ No-op. Kept for backwards compatibility with v0.1.x.
82
79
 
83
- if (!result.valid) {
84
- for (const issue of result.issues) {
85
- console.log(`${issue.severity}: ${issue.message}`);
86
- }
87
- }
88
- ```
80
+ ## Runtime Validation
89
81
 
90
- ### `isTenetSchema(obj): boolean`
91
- Check if an object is a Tenet schema.
82
+ The VM automatically detects and reports:
92
83
 
93
- ### `isReady(): boolean`
94
- Check if WASM is initialized.
84
+ - **Undefined variables** — `{"var": "unknown_field"}`
85
+ - **Unknown operators** `{"invalid_op": [...]}`
86
+ - **Temporal conflicts** — Overlapping date ranges, same start/end dates
95
87
 
96
- ## JSON Schema (IDE Support)
88
+ All errors are returned in `result.errors` without failing execution.
97
89
 
98
- Add to your schema files for autocompletion:
90
+ ## TypeScript
99
91
 
100
- ```json
101
- {
102
- "$schema": "https://tenet.dev/schema/v1.json",
103
- "definitions": { ... }
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
- ## TypeScript
98
+ ## Migration from v0.1.x
108
99
 
109
- Full type definitions are included. See `TenetSchema`, `Definition`, `Rule`, `LintResult`, etc.
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
 
@@ -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. Evaluate logic tree
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
- // 3. Compute derived state
200
+ // 4. Re-compute derived state (in case logic modified inputs)
199
201
  computeDerived(state);
200
- // 4. Validate definitions
202
+ // 5. Validate definitions
201
203
  validateDefinitions(state);
202
- // 5. Check attestations
204
+ // 6. Check attestations
203
205
  checkAttestations(state, (action, ruleId, lawRef) => {
204
206
  applyAction(state, action, ruleId, lawRef);
205
207
  });
206
- // 6. Determine status and attach errors
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 };
@@ -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
- // Check definitions first
112
- const def = state.schema.definitions[parts[0]];
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
- // Unknown operator - return null
445
+ addError(state, '', '', `Unknown operator '${op}' in logic expression`);
421
446
  return null;
422
447
  }
423
448
  return fn(args, state, resolve);
@@ -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);
@@ -120,8 +120,14 @@ export function validateDefinitions(state) {
120
120
  continue;
121
121
  }
122
122
  // Check required fields
123
- if (def.required && (def.value === undefined || def.value === null)) {
124
- addError(state, id, '', `Required field '${id}' is missing`);
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,7 @@
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
+ export {};
@@ -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.0",
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
- }