@dlovans/tenet-core 0.2.1 → 0.4.0
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/dist/core/engine.d.ts +4 -1
- package/dist/core/engine.js +117 -47
- package/dist/core/lint.d.ts +20 -0
- package/dist/core/lint.js +622 -0
- package/dist/core/operators.js +9 -3
- package/dist/core/temporal.js +2 -2
- package/dist/core/types.d.ts +111 -5
- package/dist/core/types.js +2 -2
- package/dist/core/validate.d.ts +4 -4
- package/dist/core/validate.js +35 -36
- package/dist/index.d.ts +12 -85
- package/dist/index.js +11 -1
- package/dist/lint.test.d.ts +5 -0
- package/dist/lint.test.js +570 -0
- package/package.json +1 -1
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the lint() static schema analyzer.
|
|
3
|
+
* Organized into: Errors, Warnings, Valid schemas, Edge cases.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { lint } from './index.js';
|
|
8
|
+
// Helper: build a minimal valid schema, then override
|
|
9
|
+
function base(overrides) {
|
|
10
|
+
return {
|
|
11
|
+
definitions: {
|
|
12
|
+
name: { type: 'string', value: 'test' },
|
|
13
|
+
},
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
// Helper: assert a specific issue code exists
|
|
18
|
+
function expectIssue(result, code, severity) {
|
|
19
|
+
const found = result.issues.find(i => i.code === code && (!severity || i.severity === severity));
|
|
20
|
+
assert.ok(found, `Expected issue '${code}' (${severity ?? 'any'}) but got: ${JSON.stringify(result.issues.map(i => i.code))}`);
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
function expectNoIssue(result, code) {
|
|
24
|
+
const found = result.issues.find(i => i.code === code);
|
|
25
|
+
assert.ok(!found, `Did not expect issue '${code}' but found: ${found?.message}`);
|
|
26
|
+
}
|
|
27
|
+
// ===========================================================================
|
|
28
|
+
// Group 1: Errors (valid: false)
|
|
29
|
+
// ===========================================================================
|
|
30
|
+
describe('Lint Errors', () => {
|
|
31
|
+
it('E1: no_definitions — empty definitions object', () => {
|
|
32
|
+
const result = lint({ definitions: {} });
|
|
33
|
+
assert.strictEqual(result.valid, false);
|
|
34
|
+
expectIssue(result, 'no_definitions', 'error');
|
|
35
|
+
});
|
|
36
|
+
it('E2: invalid_field_type — unknown type', () => {
|
|
37
|
+
const result = lint({
|
|
38
|
+
definitions: {
|
|
39
|
+
field1: { type: 'potato', value: 'x' },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
assert.strictEqual(result.valid, false);
|
|
43
|
+
expectIssue(result, 'invalid_field_type', 'error');
|
|
44
|
+
});
|
|
45
|
+
it('E3: duplicate_rule_id — two rules with same id', () => {
|
|
46
|
+
const result = lint(base({
|
|
47
|
+
logic_tree: [
|
|
48
|
+
{ id: 'r1', when: { '==': [1, 1] }, then: { set: { name: 'a' } } },
|
|
49
|
+
{ id: 'r1', when: { '==': [2, 2] }, then: { set: { name: 'b' } } },
|
|
50
|
+
],
|
|
51
|
+
}));
|
|
52
|
+
assert.strictEqual(result.valid, false);
|
|
53
|
+
expectIssue(result, 'duplicate_rule_id', 'error');
|
|
54
|
+
});
|
|
55
|
+
it('E4: empty_rule_id — rule with empty id', () => {
|
|
56
|
+
const result = lint(base({
|
|
57
|
+
logic_tree: [
|
|
58
|
+
{ id: '', when: { '==': [1, 1] }, then: { set: { name: 'a' } } },
|
|
59
|
+
],
|
|
60
|
+
}));
|
|
61
|
+
assert.strictEqual(result.valid, false);
|
|
62
|
+
expectIssue(result, 'empty_rule_id', 'error');
|
|
63
|
+
});
|
|
64
|
+
it('E5: rule_missing_when and rule_missing_then', () => {
|
|
65
|
+
const result = lint(base({
|
|
66
|
+
logic_tree: [
|
|
67
|
+
{ id: 'r1', when: {}, then: { set: { name: 'a' } } },
|
|
68
|
+
{ id: 'r2', when: { '==': [1, 1] } },
|
|
69
|
+
],
|
|
70
|
+
}));
|
|
71
|
+
assert.strictEqual(result.valid, false);
|
|
72
|
+
expectIssue(result, 'rule_missing_when', 'error');
|
|
73
|
+
expectIssue(result, 'rule_missing_then', 'error');
|
|
74
|
+
});
|
|
75
|
+
it('E6: select_missing_options — select without options', () => {
|
|
76
|
+
const result = lint({
|
|
77
|
+
definitions: {
|
|
78
|
+
color: { type: 'select', value: 'red' },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
assert.strictEqual(result.valid, false);
|
|
82
|
+
expectIssue(result, 'select_missing_options', 'error');
|
|
83
|
+
});
|
|
84
|
+
it('E7: select_options_empty — options is empty array', () => {
|
|
85
|
+
const result = lint({
|
|
86
|
+
definitions: {
|
|
87
|
+
color: { type: 'select', value: 'red', options: [] },
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
assert.strictEqual(result.valid, false);
|
|
91
|
+
expectIssue(result, 'select_options_empty', 'error');
|
|
92
|
+
});
|
|
93
|
+
it('E8: min_exceeds_max', () => {
|
|
94
|
+
const result = lint({
|
|
95
|
+
definitions: {
|
|
96
|
+
price: { type: 'number', value: 50, min: 100, max: 50 },
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
assert.strictEqual(result.valid, false);
|
|
100
|
+
expectIssue(result, 'min_exceeds_max', 'error');
|
|
101
|
+
});
|
|
102
|
+
it('E9: min_length_exceeds_max_length', () => {
|
|
103
|
+
const result = lint({
|
|
104
|
+
definitions: {
|
|
105
|
+
code: { type: 'string', value: 'abc', min_length: 10, max_length: 5 },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
assert.strictEqual(result.valid, false);
|
|
109
|
+
expectIssue(result, 'min_length_exceeds_max_length', 'error');
|
|
110
|
+
});
|
|
111
|
+
it('E10: invalid_regex', () => {
|
|
112
|
+
const result = lint({
|
|
113
|
+
definitions: {
|
|
114
|
+
code: { type: 'string', value: 'abc', pattern: '[unclosed' },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
assert.strictEqual(result.valid, false);
|
|
118
|
+
expectIssue(result, 'invalid_regex', 'error');
|
|
119
|
+
});
|
|
120
|
+
it('E11: arithmetic_type_mismatch — string + number', () => {
|
|
121
|
+
const result = lint({
|
|
122
|
+
definitions: {
|
|
123
|
+
label: { type: 'string', value: 'hello' },
|
|
124
|
+
count: { type: 'number', value: 5 },
|
|
125
|
+
},
|
|
126
|
+
logic_tree: [
|
|
127
|
+
{
|
|
128
|
+
id: 'r1',
|
|
129
|
+
when: { '==': [1, 1] },
|
|
130
|
+
then: { set: { result: { '+': [{ var: 'label' }, { var: 'count' }] } } },
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
assert.strictEqual(result.valid, false);
|
|
135
|
+
expectIssue(result, 'arithmetic_type_mismatch', 'error');
|
|
136
|
+
});
|
|
137
|
+
it('E11b: arithmetic_type_mismatch — boolean * number', () => {
|
|
138
|
+
const result = lint({
|
|
139
|
+
definitions: {
|
|
140
|
+
flag: { type: 'boolean', value: true },
|
|
141
|
+
amount: { type: 'number', value: 10 },
|
|
142
|
+
},
|
|
143
|
+
logic_tree: [
|
|
144
|
+
{
|
|
145
|
+
id: 'r1',
|
|
146
|
+
when: { '==': [1, 1] },
|
|
147
|
+
then: { set: { result: { '*': [{ var: 'flag' }, 2] } } },
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
assert.strictEqual(result.valid, false);
|
|
152
|
+
expectIssue(result, 'arithmetic_type_mismatch', 'error');
|
|
153
|
+
});
|
|
154
|
+
it('E12: derived_circular_dependency — A uses B, B uses A', () => {
|
|
155
|
+
const result = lint({
|
|
156
|
+
definitions: {
|
|
157
|
+
input: { type: 'number', value: 1 },
|
|
158
|
+
},
|
|
159
|
+
state_model: {
|
|
160
|
+
inputs: ['input'],
|
|
161
|
+
derived: {
|
|
162
|
+
a: { eval: { '+': [{ var: 'b' }, 1] } },
|
|
163
|
+
b: { eval: { '+': [{ var: 'a' }, 1] } },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
assert.strictEqual(result.valid, false);
|
|
168
|
+
expectIssue(result, 'derived_circular_dependency', 'error');
|
|
169
|
+
});
|
|
170
|
+
it('E13: derived_missing_eval — empty eval', () => {
|
|
171
|
+
const result = lint({
|
|
172
|
+
definitions: {
|
|
173
|
+
input: { type: 'number', value: 1 },
|
|
174
|
+
},
|
|
175
|
+
state_model: {
|
|
176
|
+
inputs: ['input'],
|
|
177
|
+
derived: {
|
|
178
|
+
broken: { eval: {} },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
assert.strictEqual(result.valid, false);
|
|
183
|
+
expectIssue(result, 'derived_missing_eval', 'error');
|
|
184
|
+
});
|
|
185
|
+
it('E14: attestation_missing_statement', () => {
|
|
186
|
+
const result = lint(base({
|
|
187
|
+
attestations: {
|
|
188
|
+
confirm: { statement: '', required: true },
|
|
189
|
+
},
|
|
190
|
+
}));
|
|
191
|
+
assert.strictEqual(result.valid, false);
|
|
192
|
+
expectIssue(result, 'attestation_missing_statement', 'error');
|
|
193
|
+
});
|
|
194
|
+
it('E15: temporal_branch_missing_version', () => {
|
|
195
|
+
const result = lint(base({
|
|
196
|
+
temporal_map: [
|
|
197
|
+
{ valid_range: ['2025-01-01', '2025-12-31'], logic_version: '', status: 'ACTIVE' },
|
|
198
|
+
],
|
|
199
|
+
}));
|
|
200
|
+
assert.strictEqual(result.valid, false);
|
|
201
|
+
expectIssue(result, 'temporal_branch_missing_version', 'error');
|
|
202
|
+
});
|
|
203
|
+
it('E29: duplicate_id_across_namespaces — definition key and derived field key collide', () => {
|
|
204
|
+
const result = lint({
|
|
205
|
+
definitions: {
|
|
206
|
+
total: { type: 'number', value: 100 },
|
|
207
|
+
},
|
|
208
|
+
state_model: {
|
|
209
|
+
inputs: ['total'],
|
|
210
|
+
derived: {
|
|
211
|
+
total: { eval: { '+': [{ var: 'total' }, 1] } },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
assert.strictEqual(result.valid, false);
|
|
216
|
+
expectIssue(result, 'duplicate_id_across_namespaces', 'error');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
// ===========================================================================
|
|
220
|
+
// Group 2: Warnings (valid: true)
|
|
221
|
+
// ===========================================================================
|
|
222
|
+
describe('Lint Warnings', () => {
|
|
223
|
+
it('W16: undefined_variable — var references ghost field', () => {
|
|
224
|
+
const result = lint(base({
|
|
225
|
+
logic_tree: [
|
|
226
|
+
{ id: 'r1', when: { '==': [{ var: 'ghost' }, 1] }, then: { set: { name: 'x' } } },
|
|
227
|
+
],
|
|
228
|
+
}));
|
|
229
|
+
assert.strictEqual(result.valid, true);
|
|
230
|
+
expectIssue(result, 'undefined_variable', 'warning');
|
|
231
|
+
});
|
|
232
|
+
it('W17: orphaned_logic_version — rule version not in temporal_map', () => {
|
|
233
|
+
const result = lint(base({
|
|
234
|
+
temporal_map: [
|
|
235
|
+
{ valid_range: ['2025-01-01', null], logic_version: 'v1', status: 'ACTIVE' },
|
|
236
|
+
],
|
|
237
|
+
logic_tree: [
|
|
238
|
+
{ id: 'r1', logic_version: 'v2', when: { '==': [1, 1] }, then: { set: { name: 'x' } } },
|
|
239
|
+
],
|
|
240
|
+
}));
|
|
241
|
+
assert.strictEqual(result.valid, true);
|
|
242
|
+
expectIssue(result, 'orphaned_logic_version', 'warning');
|
|
243
|
+
});
|
|
244
|
+
it('W18: ui_modify_undefined_field', () => {
|
|
245
|
+
const result = lint(base({
|
|
246
|
+
logic_tree: [
|
|
247
|
+
{ id: 'r1', when: { '==': [1, 1] }, then: { ui_modify: { ghost: { visible: false } } } },
|
|
248
|
+
],
|
|
249
|
+
}));
|
|
250
|
+
assert.strictEqual(result.valid, true);
|
|
251
|
+
expectIssue(result, 'ui_modify_undefined_field', 'warning');
|
|
252
|
+
});
|
|
253
|
+
it('W19: set_undefined_field', () => {
|
|
254
|
+
const result = lint(base({
|
|
255
|
+
logic_tree: [
|
|
256
|
+
{ id: 'r1', when: { '==': [1, 1] }, then: { set: { ghost: 'val' } } },
|
|
257
|
+
],
|
|
258
|
+
}));
|
|
259
|
+
assert.strictEqual(result.valid, true);
|
|
260
|
+
expectIssue(result, 'set_undefined_field', 'warning');
|
|
261
|
+
});
|
|
262
|
+
it('W20: temporal_branch_overlap', () => {
|
|
263
|
+
const result = lint(base({
|
|
264
|
+
temporal_map: [
|
|
265
|
+
{ valid_range: ['2025-01-01', '2025-06-30'], logic_version: 'v1', status: 'ACTIVE' },
|
|
266
|
+
{ valid_range: ['2025-03-01', '2025-12-31'], logic_version: 'v2', status: 'ACTIVE' },
|
|
267
|
+
],
|
|
268
|
+
}));
|
|
269
|
+
assert.strictEqual(result.valid, true);
|
|
270
|
+
expectIssue(result, 'temporal_branch_overlap', 'warning');
|
|
271
|
+
});
|
|
272
|
+
it('W21: temporal_branch_zero_length', () => {
|
|
273
|
+
const result = lint(base({
|
|
274
|
+
temporal_map: [
|
|
275
|
+
{ valid_range: ['2025-06-01', '2025-06-01'], logic_version: 'v1', status: 'ACTIVE' },
|
|
276
|
+
],
|
|
277
|
+
}));
|
|
278
|
+
assert.strictEqual(result.valid, true);
|
|
279
|
+
expectIssue(result, 'temporal_branch_zero_length', 'warning');
|
|
280
|
+
});
|
|
281
|
+
it('W22: numeric_constraint_on_non_numeric — min on string', () => {
|
|
282
|
+
const result = lint({
|
|
283
|
+
definitions: {
|
|
284
|
+
label: { type: 'string', value: 'hi', min: 5 },
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
assert.strictEqual(result.valid, true);
|
|
288
|
+
expectIssue(result, 'numeric_constraint_on_non_numeric', 'warning');
|
|
289
|
+
});
|
|
290
|
+
it('W23: string_constraint_on_non_string — min_length on number', () => {
|
|
291
|
+
const result = lint({
|
|
292
|
+
definitions: {
|
|
293
|
+
amount: { type: 'number', value: 42, min_length: 3 },
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
assert.strictEqual(result.valid, true);
|
|
297
|
+
expectIssue(result, 'string_constraint_on_non_string', 'warning');
|
|
298
|
+
});
|
|
299
|
+
it('W24: multiple_rules_set_field — two rules set same field', () => {
|
|
300
|
+
const result = lint({
|
|
301
|
+
definitions: {
|
|
302
|
+
price: { type: 'number', value: 10 },
|
|
303
|
+
flag: { type: 'boolean', value: true },
|
|
304
|
+
},
|
|
305
|
+
logic_tree: [
|
|
306
|
+
{ id: 'r1', when: { '==': [{ var: 'flag' }, true] }, then: { set: { price: 20 } } },
|
|
307
|
+
{ id: 'r2', when: { '==': [{ var: 'flag' }, false] }, then: { set: { price: 30 } } },
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
assert.strictEqual(result.valid, true);
|
|
311
|
+
expectIssue(result, 'multiple_rules_set_field', 'warning');
|
|
312
|
+
});
|
|
313
|
+
it('W25: comparison_type_mismatch — string < number', () => {
|
|
314
|
+
const result = lint({
|
|
315
|
+
definitions: {
|
|
316
|
+
label: { type: 'string', value: 'hi' },
|
|
317
|
+
count: { type: 'number', value: 5 },
|
|
318
|
+
},
|
|
319
|
+
logic_tree: [
|
|
320
|
+
{ id: 'r1', when: { '<': [{ var: 'label' }, { var: 'count' }] }, then: { set: { label: 'low' } } },
|
|
321
|
+
],
|
|
322
|
+
});
|
|
323
|
+
assert.strictEqual(result.valid, true);
|
|
324
|
+
expectIssue(result, 'comparison_type_mismatch', 'warning');
|
|
325
|
+
});
|
|
326
|
+
it('W26: set_type_mismatch — arithmetic result assigned to string field', () => {
|
|
327
|
+
const result = lint({
|
|
328
|
+
definitions: {
|
|
329
|
+
label: { type: 'string', value: 'hi' },
|
|
330
|
+
a: { type: 'number', value: 1 },
|
|
331
|
+
b: { type: 'number', value: 2 },
|
|
332
|
+
},
|
|
333
|
+
logic_tree: [
|
|
334
|
+
{ id: 'r1', when: { '==': [1, 1] }, then: { set: { label: { '+': [{ var: 'a' }, { var: 'b' }] } } } },
|
|
335
|
+
],
|
|
336
|
+
});
|
|
337
|
+
assert.strictEqual(result.valid, true);
|
|
338
|
+
expectIssue(result, 'set_type_mismatch', 'warning');
|
|
339
|
+
});
|
|
340
|
+
it('W27: input_references_undefined', () => {
|
|
341
|
+
const result = lint({
|
|
342
|
+
definitions: {
|
|
343
|
+
price: { type: 'number', value: 10 },
|
|
344
|
+
},
|
|
345
|
+
state_model: {
|
|
346
|
+
inputs: ['ghost'],
|
|
347
|
+
derived: {
|
|
348
|
+
doubled: { eval: { '*': [{ var: 'price' }, 2] } },
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
assert.strictEqual(result.valid, true);
|
|
353
|
+
expectIssue(result, 'input_references_undefined', 'warning');
|
|
354
|
+
});
|
|
355
|
+
it('W28: unknown_operator — unrecognized operator', () => {
|
|
356
|
+
const result = lint(base({
|
|
357
|
+
logic_tree: [
|
|
358
|
+
{ id: 'r1', when: { 'modulo': [10, 3] }, then: { set: { name: 'x' } } },
|
|
359
|
+
],
|
|
360
|
+
}));
|
|
361
|
+
assert.strictEqual(result.valid, true);
|
|
362
|
+
expectIssue(result, 'unknown_operator', 'warning');
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
// ===========================================================================
|
|
366
|
+
// Group 3: Valid schemas (no issues)
|
|
367
|
+
// ===========================================================================
|
|
368
|
+
describe('Lint Valid Schemas', () => {
|
|
369
|
+
it('minimal schema — one definition, no rules', () => {
|
|
370
|
+
const result = lint({
|
|
371
|
+
definitions: {
|
|
372
|
+
name: { type: 'string', value: 'test' },
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
assert.strictEqual(result.valid, true);
|
|
376
|
+
assert.strictEqual(result.issues.length, 0);
|
|
377
|
+
});
|
|
378
|
+
it('full schema with derived fields, rules, attestations, temporal map', () => {
|
|
379
|
+
const result = lint({
|
|
380
|
+
protocol: 'Test_v1',
|
|
381
|
+
schema_id: 'test-001',
|
|
382
|
+
definitions: {
|
|
383
|
+
income: { type: 'number', value: 50000 },
|
|
384
|
+
deductions: { type: 'number', value: 10000 },
|
|
385
|
+
status_msg: { type: 'string', value: '' },
|
|
386
|
+
},
|
|
387
|
+
state_model: {
|
|
388
|
+
inputs: ['income', 'deductions'],
|
|
389
|
+
derived: {
|
|
390
|
+
taxable_income: { eval: { '-': [{ var: 'income' }, { var: 'deductions' }] } },
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
logic_tree: [
|
|
394
|
+
{
|
|
395
|
+
id: 'high_income',
|
|
396
|
+
logic_version: 'v1',
|
|
397
|
+
when: { '>': [{ var: 'taxable_income' }, 30000] },
|
|
398
|
+
then: { set: { status_msg: 'high bracket' } },
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
temporal_map: [
|
|
402
|
+
{ valid_range: ['2025-01-01', null], logic_version: 'v1', status: 'ACTIVE' },
|
|
403
|
+
],
|
|
404
|
+
attestations: {
|
|
405
|
+
confirm: { statement: 'I confirm this is correct', required: true },
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
assert.strictEqual(result.valid, true);
|
|
409
|
+
assert.strictEqual(result.issues.length, 0);
|
|
410
|
+
});
|
|
411
|
+
it('number + currency arithmetic — compatible types, no error', () => {
|
|
412
|
+
const result = lint({
|
|
413
|
+
definitions: {
|
|
414
|
+
price: { type: 'currency', value: 100 },
|
|
415
|
+
tax_rate: { type: 'number', value: 0.1 },
|
|
416
|
+
},
|
|
417
|
+
state_model: {
|
|
418
|
+
inputs: ['price', 'tax_rate'],
|
|
419
|
+
derived: {
|
|
420
|
+
tax: { eval: { '*': [{ var: 'price' }, { var: 'tax_rate' }] } },
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
assert.strictEqual(result.valid, true);
|
|
425
|
+
expectNoIssue(result, 'arithmetic_type_mismatch');
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
// ===========================================================================
|
|
429
|
+
// Group 4: Edge cases
|
|
430
|
+
// ===========================================================================
|
|
431
|
+
describe('Lint Edge Cases', () => {
|
|
432
|
+
it('JSON string input parses correctly', () => {
|
|
433
|
+
const json = JSON.stringify({
|
|
434
|
+
definitions: { x: { type: 'number', value: 1 } },
|
|
435
|
+
});
|
|
436
|
+
const result = lint(json);
|
|
437
|
+
assert.strictEqual(result.valid, true);
|
|
438
|
+
assert.strictEqual(result.issues.length, 0);
|
|
439
|
+
});
|
|
440
|
+
it('invalid JSON string → parse_error', () => {
|
|
441
|
+
const result = lint('not json {{{');
|
|
442
|
+
assert.strictEqual(result.valid, false);
|
|
443
|
+
expectIssue(result, 'parse_error', 'error');
|
|
444
|
+
});
|
|
445
|
+
it('nested expression inference: all-numeric chain produces no error', () => {
|
|
446
|
+
const result = lint({
|
|
447
|
+
definitions: {
|
|
448
|
+
a: { type: 'number', value: 1 },
|
|
449
|
+
b: { type: 'number', value: 2 },
|
|
450
|
+
c: { type: 'number', value: 3 },
|
|
451
|
+
},
|
|
452
|
+
state_model: {
|
|
453
|
+
inputs: ['a', 'b', 'c'],
|
|
454
|
+
derived: {
|
|
455
|
+
result: { eval: { '*': [{ '+': [{ var: 'a' }, { var: 'b' }] }, { var: 'c' }] } },
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
assert.strictEqual(result.valid, true);
|
|
460
|
+
expectNoIssue(result, 'arithmetic_type_mismatch');
|
|
461
|
+
});
|
|
462
|
+
it('deep cycle: A→B→C→A', () => {
|
|
463
|
+
const result = lint({
|
|
464
|
+
definitions: {
|
|
465
|
+
input: { type: 'number', value: 1 },
|
|
466
|
+
},
|
|
467
|
+
state_model: {
|
|
468
|
+
inputs: ['input'],
|
|
469
|
+
derived: {
|
|
470
|
+
a: { eval: { '+': [{ var: 'b' }, 1] } },
|
|
471
|
+
b: { eval: { '+': [{ var: 'c' }, 1] } },
|
|
472
|
+
c: { eval: { '+': [{ var: 'a' }, 1] } },
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
assert.strictEqual(result.valid, false);
|
|
477
|
+
expectIssue(result, 'derived_circular_dependency', 'error');
|
|
478
|
+
});
|
|
479
|
+
it('unknown types do not trigger false errors', () => {
|
|
480
|
+
// An if-expression returning unknown type used in arithmetic — no error
|
|
481
|
+
const result = lint({
|
|
482
|
+
definitions: {
|
|
483
|
+
flag: { type: 'boolean', value: true },
|
|
484
|
+
a: { type: 'number', value: 1 },
|
|
485
|
+
},
|
|
486
|
+
logic_tree: [
|
|
487
|
+
{
|
|
488
|
+
id: 'r1',
|
|
489
|
+
when: { '==': [1, 1] },
|
|
490
|
+
then: {
|
|
491
|
+
set: {
|
|
492
|
+
result: {
|
|
493
|
+
'+': [
|
|
494
|
+
{ var: 'a' },
|
|
495
|
+
// if-expression: type inferred from first then-branch (number)
|
|
496
|
+
{ 'if': [{ var: 'flag' }, 10, 20] },
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
});
|
|
504
|
+
// The if returns number (inferred from first then-branch=10), so no error
|
|
505
|
+
expectNoIssue(result, 'arithmetic_type_mismatch');
|
|
506
|
+
});
|
|
507
|
+
it('warnings-only → valid: true', () => {
|
|
508
|
+
const result = lint({
|
|
509
|
+
definitions: {
|
|
510
|
+
label: { type: 'string', value: 'hi', min: 5 },
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
assert.strictEqual(result.valid, true);
|
|
514
|
+
assert.ok(result.issues.length > 0, 'Expected at least one warning');
|
|
515
|
+
assert.ok(result.issues.every(i => i.severity === 'warning'));
|
|
516
|
+
});
|
|
517
|
+
it('derived field not in definitions gets registered via inference', () => {
|
|
518
|
+
const result = lint({
|
|
519
|
+
definitions: {
|
|
520
|
+
a: { type: 'number', value: 10 },
|
|
521
|
+
b: { type: 'number', value: 20 },
|
|
522
|
+
},
|
|
523
|
+
state_model: {
|
|
524
|
+
inputs: ['a', 'b'],
|
|
525
|
+
derived: {
|
|
526
|
+
total: { eval: { '+': [{ var: 'a' }, { var: 'b' }] } },
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
logic_tree: [
|
|
530
|
+
{
|
|
531
|
+
id: 'r1',
|
|
532
|
+
when: { '>': [{ var: 'total' }, 25] },
|
|
533
|
+
then: { set: { a: 0 } },
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
});
|
|
537
|
+
// 'total' is derived → should be known. No undefined_variable warning.
|
|
538
|
+
expectNoIssue(result, 'undefined_variable');
|
|
539
|
+
});
|
|
540
|
+
it('self-referencing derived field is not flagged as cycle', () => {
|
|
541
|
+
// self-references are filtered out in cycle detection (ref !== name)
|
|
542
|
+
const result = lint({
|
|
543
|
+
definitions: {
|
|
544
|
+
input: { type: 'number', value: 1 },
|
|
545
|
+
},
|
|
546
|
+
state_model: {
|
|
547
|
+
inputs: ['input'],
|
|
548
|
+
derived: {
|
|
549
|
+
acc: { eval: { '+': [{ var: 'acc' }, { var: 'input' }] } },
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
expectNoIssue(result, 'derived_circular_dependency');
|
|
554
|
+
});
|
|
555
|
+
it('E29: rule id collides with attestation key', () => {
|
|
556
|
+
const result = lint({
|
|
557
|
+
definitions: {
|
|
558
|
+
name: { type: 'string', value: 'test' },
|
|
559
|
+
},
|
|
560
|
+
logic_tree: [
|
|
561
|
+
{ id: 'confirm', when: { '==': [1, 1] }, then: { set: { name: 'ok' } } },
|
|
562
|
+
],
|
|
563
|
+
attestations: {
|
|
564
|
+
confirm: { statement: 'I confirm', required: true },
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
assert.strictEqual(result.valid, false);
|
|
568
|
+
expectIssue(result, 'duplicate_id_across_namespaces', 'error');
|
|
569
|
+
});
|
|
570
|
+
});
|
package/package.json
CHANGED