@dlovans/tenet-core 0.1.4 → 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.d.ts +23 -0
- package/dist/core/engine.js +367 -0
- package/dist/core/operators.d.ts +30 -0
- package/dist/core/operators.js +449 -0
- package/dist/core/resolver.d.ts +11 -0
- package/dist/core/resolver.js +34 -0
- package/dist/core/temporal.d.ts +20 -0
- package/dist/core/temporal.js +109 -0
- package/dist/core/types.d.ts +33 -0
- package/dist/core/types.js +5 -0
- package/dist/core/validate.d.ts +21 -0
- package/dist/core/validate.js +200 -0
- package/dist/index.d.ts +9 -18
- package/dist/index.js +15 -80
- package/dist/validation-fixes.test.d.ts +7 -0
- package/dist/validation-fixes.test.js +399 -0
- package/package.json +5 -14
- package/dist/lint.d.ts +0 -31
- package/dist/lint.js +0 -160
- package/wasm/tenet.wasm +0 -0
- package/wasm/wasm_exec.js +0 -575
|
@@ -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.1
|
|
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,20 +9,13 @@
|
|
|
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": [
|
|
19
|
-
"dist"
|
|
20
|
-
"wasm"
|
|
15
|
+
"dist"
|
|
21
16
|
],
|
|
22
17
|
"scripts": {
|
|
23
|
-
"build
|
|
24
|
-
"build:js": "tsc",
|
|
25
|
-
"build": "npm run build:wasm && npm run build:js",
|
|
18
|
+
"build": "tsc",
|
|
26
19
|
"test": "node --test dist/*.test.js",
|
|
27
20
|
"prepublishOnly": "npm run build"
|
|
28
21
|
},
|
|
@@ -32,10 +25,8 @@
|
|
|
32
25
|
"reactive",
|
|
33
26
|
"schema",
|
|
34
27
|
"form",
|
|
35
|
-
"wasm",
|
|
36
28
|
"compliance",
|
|
37
|
-
"temporal"
|
|
38
|
-
"linter"
|
|
29
|
+
"temporal"
|
|
39
30
|
],
|
|
40
31
|
"author": "Dlovan Sharif",
|
|
41
32
|
"license": "MIT",
|
|
@@ -54,4 +45,4 @@
|
|
|
54
45
|
"@types/node": "^25.0.9",
|
|
55
46
|
"typescript": "^5.0.0"
|
|
56
47
|
}
|
|
57
|
-
}
|
|
48
|
+
}
|
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
|
-
}
|
package/wasm/tenet.wasm
DELETED
|
Binary file
|