@dlovans/tenet-core 0.3.0 → 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/lint.d.ts +20 -0
- package/dist/core/lint.js +622 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/lint.test.d.ts +5 -0
- package/dist/lint.test.js +570 -0
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static schema linter for Tenet schemas.
|
|
3
|
+
* Analyzes schemas without executing them, catching structural errors,
|
|
4
|
+
* type mismatches, undefined references, and circular dependencies.
|
|
5
|
+
*/
|
|
6
|
+
import type { TenetSchema } from './types.js';
|
|
7
|
+
export type LintSeverity = 'error' | 'warning';
|
|
8
|
+
export interface LintIssue {
|
|
9
|
+
severity: LintSeverity;
|
|
10
|
+
code: string;
|
|
11
|
+
message: string;
|
|
12
|
+
path?: string;
|
|
13
|
+
field_id?: string;
|
|
14
|
+
rule_id?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface LintResult {
|
|
17
|
+
valid: boolean;
|
|
18
|
+
issues: LintIssue[];
|
|
19
|
+
}
|
|
20
|
+
export declare function lint(schema: TenetSchema | string): LintResult;
|
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static schema linter for Tenet schemas.
|
|
3
|
+
* Analyzes schemas without executing them, catching structural errors,
|
|
4
|
+
* type mismatches, undefined references, and circular dependencies.
|
|
5
|
+
*/
|
|
6
|
+
// ============================================================
|
|
7
|
+
// Constants
|
|
8
|
+
// ============================================================
|
|
9
|
+
const VALID_DEF_TYPES = new Set([
|
|
10
|
+
'string', 'number', 'boolean', 'select', 'date', 'attestation', 'currency',
|
|
11
|
+
]);
|
|
12
|
+
const SUPPORTED_OPS = new Set([
|
|
13
|
+
'var', '==', '!=', '>', '<', '>=', '<=',
|
|
14
|
+
'and', 'or', 'not', '!', 'if',
|
|
15
|
+
'+', '-', '*', '/',
|
|
16
|
+
'before', 'after',
|
|
17
|
+
'in', 'some', 'all', 'none',
|
|
18
|
+
]);
|
|
19
|
+
const ARITHMETIC_OPS = new Set(['+', '-', '*', '/']);
|
|
20
|
+
const COMPARISON_OPS = new Set(['>', '<', '>=', '<=']);
|
|
21
|
+
// ============================================================
|
|
22
|
+
// Helpers
|
|
23
|
+
// ============================================================
|
|
24
|
+
function addIssue(ctx, severity, code, message, opts) {
|
|
25
|
+
ctx.issues.push({
|
|
26
|
+
severity,
|
|
27
|
+
code,
|
|
28
|
+
message,
|
|
29
|
+
path: opts?.path,
|
|
30
|
+
field_id: opts?.field_id,
|
|
31
|
+
rule_id: opts?.rule_id,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function defTypeToInferred(type) {
|
|
35
|
+
switch (type) {
|
|
36
|
+
case 'number':
|
|
37
|
+
case 'currency':
|
|
38
|
+
return 'number';
|
|
39
|
+
case 'string':
|
|
40
|
+
case 'select':
|
|
41
|
+
return 'string';
|
|
42
|
+
case 'boolean':
|
|
43
|
+
case 'attestation':
|
|
44
|
+
return 'boolean';
|
|
45
|
+
case 'date':
|
|
46
|
+
return 'date';
|
|
47
|
+
default:
|
|
48
|
+
return 'unknown';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ============================================================
|
|
52
|
+
// Expression type inference
|
|
53
|
+
// ============================================================
|
|
54
|
+
function inferExprType(expr, ctx, path) {
|
|
55
|
+
if (expr === null || expr === undefined)
|
|
56
|
+
return 'unknown';
|
|
57
|
+
if (typeof expr === 'number')
|
|
58
|
+
return 'number';
|
|
59
|
+
if (typeof expr === 'string')
|
|
60
|
+
return 'string';
|
|
61
|
+
if (typeof expr === 'boolean')
|
|
62
|
+
return 'boolean';
|
|
63
|
+
if (Array.isArray(expr))
|
|
64
|
+
return 'unknown';
|
|
65
|
+
if (typeof expr !== 'object')
|
|
66
|
+
return 'unknown';
|
|
67
|
+
const obj = expr;
|
|
68
|
+
const keys = Object.keys(obj);
|
|
69
|
+
if (keys.length !== 1)
|
|
70
|
+
return 'unknown';
|
|
71
|
+
const op = keys[0];
|
|
72
|
+
const args = obj[op];
|
|
73
|
+
// Check for unknown operators
|
|
74
|
+
if (!ctx.supportedOps.has(op)) {
|
|
75
|
+
addIssue(ctx, 'warning', 'unknown_operator', `Unknown operator '${op}'`, { path });
|
|
76
|
+
return 'unknown';
|
|
77
|
+
}
|
|
78
|
+
if (op === 'var') {
|
|
79
|
+
const varName = typeof args === 'string' ? args : '';
|
|
80
|
+
const rootName = varName.split('.')[0];
|
|
81
|
+
const fieldType = ctx.knownFields.get(rootName);
|
|
82
|
+
if (fieldType !== undefined) {
|
|
83
|
+
return fieldType;
|
|
84
|
+
}
|
|
85
|
+
if (rootName && !ctx.knownFields.has(rootName)) {
|
|
86
|
+
addIssue(ctx, 'warning', 'undefined_variable', `Variable '${rootName}' is not defined in definitions or derived fields`, {
|
|
87
|
+
path,
|
|
88
|
+
field_id: rootName,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return 'unknown';
|
|
92
|
+
}
|
|
93
|
+
if (ARITHMETIC_OPS.has(op)) {
|
|
94
|
+
const operands = Array.isArray(args) ? args : [args];
|
|
95
|
+
for (let i = 0; i < operands.length; i++) {
|
|
96
|
+
const operandType = inferExprType(operands[i], ctx, `${path}.${op}[${i}]`);
|
|
97
|
+
if (operandType !== 'unknown' && operandType !== 'number') {
|
|
98
|
+
addIssue(ctx, 'error', 'arithmetic_type_mismatch', `Arithmetic operator '${op}' used with ${operandType} operand (expected number)`, {
|
|
99
|
+
path: `${path}.${op}[${i}]`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return 'number';
|
|
104
|
+
}
|
|
105
|
+
if (COMPARISON_OPS.has(op)) {
|
|
106
|
+
const operands = Array.isArray(args) ? args : [args];
|
|
107
|
+
if (operands.length >= 2) {
|
|
108
|
+
const leftType = inferExprType(operands[0], ctx, `${path}.${op}[0]`);
|
|
109
|
+
const rightType = inferExprType(operands[1], ctx, `${path}.${op}[1]`);
|
|
110
|
+
if (leftType !== 'unknown' && rightType !== 'unknown' && leftType !== rightType) {
|
|
111
|
+
addIssue(ctx, 'warning', 'comparison_type_mismatch', `Comparison operator '${op}' between ${leftType} and ${rightType}`, {
|
|
112
|
+
path,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return 'boolean';
|
|
117
|
+
}
|
|
118
|
+
if (op === '==' || op === '!=') {
|
|
119
|
+
// Infer children for side effects (undefined_variable etc) but don't flag type mismatch
|
|
120
|
+
const operands = Array.isArray(args) ? args : [args];
|
|
121
|
+
for (let i = 0; i < operands.length; i++) {
|
|
122
|
+
inferExprType(operands[i], ctx, `${path}.${op}[${i}]`);
|
|
123
|
+
}
|
|
124
|
+
return 'boolean';
|
|
125
|
+
}
|
|
126
|
+
if (op === 'and' || op === 'or') {
|
|
127
|
+
const operands = Array.isArray(args) ? args : [args];
|
|
128
|
+
for (let i = 0; i < operands.length; i++) {
|
|
129
|
+
inferExprType(operands[i], ctx, `${path}.${op}[${i}]`);
|
|
130
|
+
}
|
|
131
|
+
return 'boolean';
|
|
132
|
+
}
|
|
133
|
+
if (op === 'not' || op === '!') {
|
|
134
|
+
inferExprType(args, ctx, `${path}.${op}`);
|
|
135
|
+
return 'boolean';
|
|
136
|
+
}
|
|
137
|
+
if (op === 'if') {
|
|
138
|
+
const branches = Array.isArray(args) ? args : [];
|
|
139
|
+
// Walk all branches for side effects
|
|
140
|
+
for (let i = 0; i < branches.length; i++) {
|
|
141
|
+
inferExprType(branches[i], ctx, `${path}.if[${i}]`);
|
|
142
|
+
}
|
|
143
|
+
// Return type of first then-branch if exists
|
|
144
|
+
if (branches.length >= 2) {
|
|
145
|
+
return inferExprType(branches[1], ctx, `${path}.if[1]`);
|
|
146
|
+
}
|
|
147
|
+
return 'unknown';
|
|
148
|
+
}
|
|
149
|
+
if (op === 'before' || op === 'after') {
|
|
150
|
+
const operands = Array.isArray(args) ? args : [args];
|
|
151
|
+
for (let i = 0; i < operands.length; i++) {
|
|
152
|
+
inferExprType(operands[i], ctx, `${path}.${op}[${i}]`);
|
|
153
|
+
}
|
|
154
|
+
return 'boolean';
|
|
155
|
+
}
|
|
156
|
+
if (op === 'in' || op === 'some' || op === 'all' || op === 'none') {
|
|
157
|
+
const operands = Array.isArray(args) ? args : [args];
|
|
158
|
+
for (let i = 0; i < operands.length; i++) {
|
|
159
|
+
inferExprType(operands[i], ctx, `${path}.${op}[${i}]`);
|
|
160
|
+
}
|
|
161
|
+
return 'boolean';
|
|
162
|
+
}
|
|
163
|
+
return 'unknown';
|
|
164
|
+
}
|
|
165
|
+
// ============================================================
|
|
166
|
+
// Var reference extraction (for cycle detection)
|
|
167
|
+
// ============================================================
|
|
168
|
+
function collectVarRefs(node, refs) {
|
|
169
|
+
if (node === null || node === undefined)
|
|
170
|
+
return;
|
|
171
|
+
if (typeof node !== 'object')
|
|
172
|
+
return;
|
|
173
|
+
if (Array.isArray(node)) {
|
|
174
|
+
for (const item of node) {
|
|
175
|
+
collectVarRefs(item, refs);
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const obj = node;
|
|
180
|
+
const keys = Object.keys(obj);
|
|
181
|
+
if (keys.length === 1 && keys[0] === 'var') {
|
|
182
|
+
const varName = typeof obj['var'] === 'string' ? obj['var'] : '';
|
|
183
|
+
const rootName = varName.split('.')[0];
|
|
184
|
+
if (rootName)
|
|
185
|
+
refs.add(rootName);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
for (const key of keys) {
|
|
189
|
+
collectVarRefs(obj[key], refs);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// ============================================================
|
|
193
|
+
// Check passes
|
|
194
|
+
// ============================================================
|
|
195
|
+
function checkDefinitions(ctx) {
|
|
196
|
+
const { schema } = ctx;
|
|
197
|
+
// E1: no definitions
|
|
198
|
+
if (!schema.definitions || Object.keys(schema.definitions).length === 0) {
|
|
199
|
+
addIssue(ctx, 'error', 'no_definitions', 'Schema has no definitions');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const [id, def] of Object.entries(schema.definitions)) {
|
|
203
|
+
if (!def)
|
|
204
|
+
continue;
|
|
205
|
+
// E2: invalid field type
|
|
206
|
+
if (!VALID_DEF_TYPES.has(def.type)) {
|
|
207
|
+
addIssue(ctx, 'error', 'invalid_field_type', `Field '${id}' has invalid type '${def.type}'`, { field_id: id });
|
|
208
|
+
}
|
|
209
|
+
// E6/E7: select validations
|
|
210
|
+
if (def.type === 'select') {
|
|
211
|
+
if (def.options === undefined) {
|
|
212
|
+
addIssue(ctx, 'error', 'select_missing_options', `Select field '${id}' is missing options`, { field_id: id });
|
|
213
|
+
}
|
|
214
|
+
else if (Array.isArray(def.options) && def.options.length === 0) {
|
|
215
|
+
addIssue(ctx, 'error', 'select_options_empty', `Select field '${id}' has empty options array`, { field_id: id });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// E8: min > max
|
|
219
|
+
if (def.min !== undefined && def.max !== undefined && def.min > def.max) {
|
|
220
|
+
addIssue(ctx, 'error', 'min_exceeds_max', `Field '${id}' has min (${def.min}) greater than max (${def.max})`, { field_id: id });
|
|
221
|
+
}
|
|
222
|
+
// E9: min_length > max_length
|
|
223
|
+
if (def.min_length !== undefined && def.max_length !== undefined && def.min_length > def.max_length) {
|
|
224
|
+
addIssue(ctx, 'error', 'min_length_exceeds_max_length', `Field '${id}' has min_length (${def.min_length}) greater than max_length (${def.max_length})`, { field_id: id });
|
|
225
|
+
}
|
|
226
|
+
// E10: invalid regex
|
|
227
|
+
if (def.pattern !== undefined) {
|
|
228
|
+
try {
|
|
229
|
+
new RegExp(def.pattern);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
addIssue(ctx, 'error', 'invalid_regex', `Field '${id}' has invalid regex pattern '${def.pattern}'`, { field_id: id });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// W22: numeric constraint on non-numeric
|
|
236
|
+
const inferred = defTypeToInferred(def.type);
|
|
237
|
+
if ((def.min !== undefined || def.max !== undefined) && inferred !== 'number' && inferred !== 'unknown') {
|
|
238
|
+
addIssue(ctx, 'warning', 'numeric_constraint_on_non_numeric', `Field '${id}' has min/max constraints but type is '${def.type}'`, { field_id: id });
|
|
239
|
+
}
|
|
240
|
+
// W23: string constraint on non-string
|
|
241
|
+
if ((def.min_length !== undefined || def.max_length !== undefined || def.pattern !== undefined) &&
|
|
242
|
+
inferred !== 'string' && inferred !== 'unknown') {
|
|
243
|
+
addIssue(ctx, 'warning', 'string_constraint_on_non_string', `Field '${id}' has string constraints but type is '${def.type}'`, { field_id: id });
|
|
244
|
+
}
|
|
245
|
+
// Populate knownFields
|
|
246
|
+
ctx.knownFields.set(id, defTypeToInferred(def.type));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function checkRules(ctx) {
|
|
250
|
+
const { schema } = ctx;
|
|
251
|
+
if (!schema.logic_tree || schema.logic_tree.length === 0)
|
|
252
|
+
return;
|
|
253
|
+
const seenIds = new Set();
|
|
254
|
+
for (let i = 0; i < schema.logic_tree.length; i++) {
|
|
255
|
+
const rule = schema.logic_tree[i];
|
|
256
|
+
if (!rule)
|
|
257
|
+
continue;
|
|
258
|
+
const path = `logic_tree[${i}]`;
|
|
259
|
+
// E4: empty rule id
|
|
260
|
+
if (!rule.id || rule.id.trim() === '') {
|
|
261
|
+
addIssue(ctx, 'error', 'empty_rule_id', `Rule at ${path} has empty or missing id`, { path });
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
// E3: duplicate rule id
|
|
265
|
+
if (seenIds.has(rule.id)) {
|
|
266
|
+
addIssue(ctx, 'error', 'duplicate_rule_id', `Duplicate rule id '${rule.id}'`, { path, rule_id: rule.id });
|
|
267
|
+
}
|
|
268
|
+
seenIds.add(rule.id);
|
|
269
|
+
}
|
|
270
|
+
// E5: missing when/then
|
|
271
|
+
if (!rule.when || (typeof rule.when === 'object' && Object.keys(rule.when).length === 0)) {
|
|
272
|
+
addIssue(ctx, 'error', 'rule_missing_when', `Rule '${rule.id || i}' is missing a 'when' condition`, { path, rule_id: rule.id });
|
|
273
|
+
}
|
|
274
|
+
if (!rule.then) {
|
|
275
|
+
addIssue(ctx, 'error', 'rule_missing_then', `Rule '${rule.id || i}' is missing a 'then' action`, { path, rule_id: rule.id });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function checkAttestations(ctx) {
|
|
280
|
+
const { schema } = ctx;
|
|
281
|
+
if (!schema.attestations)
|
|
282
|
+
return;
|
|
283
|
+
for (const [id, att] of Object.entries(schema.attestations)) {
|
|
284
|
+
if (!att)
|
|
285
|
+
continue;
|
|
286
|
+
// E14: missing statement
|
|
287
|
+
if (!att.statement || att.statement.trim() === '') {
|
|
288
|
+
addIssue(ctx, 'error', 'attestation_missing_statement', `Attestation '${id}' is missing a statement`, { field_id: id });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function checkTemporalMap(ctx) {
|
|
293
|
+
const { schema } = ctx;
|
|
294
|
+
if (!schema.temporal_map || schema.temporal_map.length === 0)
|
|
295
|
+
return;
|
|
296
|
+
for (let i = 0; i < schema.temporal_map.length; i++) {
|
|
297
|
+
const branch = schema.temporal_map[i];
|
|
298
|
+
if (!branch)
|
|
299
|
+
continue;
|
|
300
|
+
const path = `temporal_map[${i}]`;
|
|
301
|
+
// E15: missing logic_version
|
|
302
|
+
if (!branch.logic_version || branch.logic_version.trim() === '') {
|
|
303
|
+
addIssue(ctx, 'error', 'temporal_branch_missing_version', `Temporal branch at ${path} is missing logic_version`, { path });
|
|
304
|
+
}
|
|
305
|
+
// W21: zero-length range
|
|
306
|
+
const [start, end] = branch.valid_range;
|
|
307
|
+
if (start && end && start === end) {
|
|
308
|
+
addIssue(ctx, 'warning', 'temporal_branch_zero_length', `Temporal branch at ${path} has zero-length range (start == end)`, { path });
|
|
309
|
+
}
|
|
310
|
+
// W20: overlapping ranges with adjacent branches
|
|
311
|
+
if (i > 0) {
|
|
312
|
+
const prev = schema.temporal_map[i - 1];
|
|
313
|
+
if (prev) {
|
|
314
|
+
const prevEnd = prev.valid_range[1]
|
|
315
|
+
? new Date(prev.valid_range[1]).getTime()
|
|
316
|
+
: Infinity;
|
|
317
|
+
const currStart = start
|
|
318
|
+
? new Date(start).getTime()
|
|
319
|
+
: -Infinity;
|
|
320
|
+
if (currStart <= prevEnd && !isNaN(prevEnd) && !isNaN(currStart)) {
|
|
321
|
+
addIssue(ctx, 'warning', 'temporal_branch_overlap', `Temporal branch ${i} overlaps with branch ${i - 1}`, { path });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// W17: orphaned logic versions (rule versions not in temporal map)
|
|
327
|
+
if (schema.logic_tree) {
|
|
328
|
+
const temporalVersions = new Set(schema.temporal_map
|
|
329
|
+
.filter((b) => !!b)
|
|
330
|
+
.map(b => b.logic_version));
|
|
331
|
+
for (let i = 0; i < schema.logic_tree.length; i++) {
|
|
332
|
+
const rule = schema.logic_tree[i];
|
|
333
|
+
if (!rule || !rule.logic_version)
|
|
334
|
+
continue;
|
|
335
|
+
if (!temporalVersions.has(rule.logic_version)) {
|
|
336
|
+
addIssue(ctx, 'warning', 'orphaned_logic_version', `Rule '${rule.id}' references logic_version '${rule.logic_version}' which is not in temporal_map`, {
|
|
337
|
+
path: `logic_tree[${i}]`,
|
|
338
|
+
rule_id: rule.id,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function checkDerived(ctx) {
|
|
345
|
+
const { schema } = ctx;
|
|
346
|
+
if (!schema.state_model?.derived)
|
|
347
|
+
return;
|
|
348
|
+
const derived = schema.state_model.derived;
|
|
349
|
+
for (const [name, def] of Object.entries(derived)) {
|
|
350
|
+
if (!def)
|
|
351
|
+
continue;
|
|
352
|
+
// E13: missing eval
|
|
353
|
+
if (!def.eval || (typeof def.eval === 'object' && Object.keys(def.eval).length === 0)) {
|
|
354
|
+
addIssue(ctx, 'error', 'derived_missing_eval', `Derived field '${name}' has no eval expression`, { field_id: name });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// W27: inputs reference undefined definitions
|
|
358
|
+
if (schema.state_model.inputs) {
|
|
359
|
+
for (const input of schema.state_model.inputs) {
|
|
360
|
+
if (!ctx.knownFields.has(input)) {
|
|
361
|
+
addIssue(ctx, 'warning', 'input_references_undefined', `state_model.inputs references undefined field '${input}'`, { field_id: input });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// Register derived fields in knownFields (infer type from eval expression)
|
|
366
|
+
for (const [name, def] of Object.entries(derived)) {
|
|
367
|
+
if (!def?.eval)
|
|
368
|
+
continue;
|
|
369
|
+
// If the derived field already exists as a definition, keep that type
|
|
370
|
+
if (!ctx.knownFields.has(name)) {
|
|
371
|
+
const inferredType = inferExprType(def.eval, ctx, `state_model.derived.${name}.eval`);
|
|
372
|
+
ctx.knownFields.set(name, inferredType);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// E12: cycle detection via three-color DFS
|
|
376
|
+
checkDerivedCycles(ctx);
|
|
377
|
+
}
|
|
378
|
+
function checkDerivedCycles(ctx) {
|
|
379
|
+
const { schema } = ctx;
|
|
380
|
+
if (!schema.state_model?.derived)
|
|
381
|
+
return;
|
|
382
|
+
const derived = schema.state_model.derived;
|
|
383
|
+
const derivedNames = new Set(Object.keys(derived));
|
|
384
|
+
// Build adjacency list (only edges to other derived fields)
|
|
385
|
+
const graph = new Map();
|
|
386
|
+
for (const [name, def] of Object.entries(derived)) {
|
|
387
|
+
if (!def?.eval)
|
|
388
|
+
continue;
|
|
389
|
+
const refs = new Set();
|
|
390
|
+
collectVarRefs(def.eval, refs);
|
|
391
|
+
// Only keep edges to other derived fields
|
|
392
|
+
const edges = new Set();
|
|
393
|
+
for (const ref of refs) {
|
|
394
|
+
if (derivedNames.has(ref) && ref !== name) {
|
|
395
|
+
edges.add(ref);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
graph.set(name, edges);
|
|
399
|
+
}
|
|
400
|
+
// Three-color DFS: 0=white, 1=gray, 2=black
|
|
401
|
+
const color = new Map();
|
|
402
|
+
const parent = new Map();
|
|
403
|
+
for (const name of derivedNames) {
|
|
404
|
+
color.set(name, 0);
|
|
405
|
+
}
|
|
406
|
+
function dfs(node) {
|
|
407
|
+
color.set(node, 1); // gray
|
|
408
|
+
const edges = graph.get(node);
|
|
409
|
+
if (edges) {
|
|
410
|
+
for (const neighbor of edges) {
|
|
411
|
+
const neighborColor = color.get(neighbor) ?? 0;
|
|
412
|
+
if (neighborColor === 1) {
|
|
413
|
+
// Found a cycle — reconstruct path
|
|
414
|
+
const cycle = [neighbor, node];
|
|
415
|
+
let cur = node;
|
|
416
|
+
while (cur !== neighbor) {
|
|
417
|
+
const p = parent.get(cur);
|
|
418
|
+
if (!p || p === neighbor)
|
|
419
|
+
break;
|
|
420
|
+
cycle.push(p);
|
|
421
|
+
cur = p;
|
|
422
|
+
}
|
|
423
|
+
cycle.reverse();
|
|
424
|
+
return cycle;
|
|
425
|
+
}
|
|
426
|
+
if (neighborColor === 0) {
|
|
427
|
+
parent.set(neighbor, node);
|
|
428
|
+
const result = dfs(neighbor);
|
|
429
|
+
if (result)
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
color.set(node, 2); // black
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
for (const name of derivedNames) {
|
|
438
|
+
if (color.get(name) === 0) {
|
|
439
|
+
const cycle = dfs(name);
|
|
440
|
+
if (cycle) {
|
|
441
|
+
const cyclePath = cycle.join(' -> ');
|
|
442
|
+
addIssue(ctx, 'error', 'derived_circular_dependency', `Circular dependency in derived fields: ${cyclePath}`, {
|
|
443
|
+
field_id: cycle[0],
|
|
444
|
+
});
|
|
445
|
+
return; // Report first cycle only
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function checkExpressions(ctx) {
|
|
451
|
+
const { schema } = ctx;
|
|
452
|
+
// Track which fields are set by which rules (for W24)
|
|
453
|
+
const fieldsSetBy = new Map();
|
|
454
|
+
if (schema.logic_tree) {
|
|
455
|
+
for (let i = 0; i < schema.logic_tree.length; i++) {
|
|
456
|
+
const rule = schema.logic_tree[i];
|
|
457
|
+
if (!rule)
|
|
458
|
+
continue;
|
|
459
|
+
const rulePath = `logic_tree[${i}]`;
|
|
460
|
+
// Walk the when expression (E11 arithmetic_type_mismatch, W25 comparison, W16 undefined_variable, W28 unknown_operator)
|
|
461
|
+
if (rule.when) {
|
|
462
|
+
inferExprType(rule.when, ctx, `${rulePath}.when`);
|
|
463
|
+
}
|
|
464
|
+
// Walk the then action
|
|
465
|
+
if (rule.then) {
|
|
466
|
+
checkAction(ctx, rule.then, rulePath, rule.id, fieldsSetBy);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// W24: multiple rules set the same field
|
|
471
|
+
for (const [field, ruleIds] of fieldsSetBy) {
|
|
472
|
+
if (ruleIds.length > 1) {
|
|
473
|
+
addIssue(ctx, 'warning', 'multiple_rules_set_field', `Field '${field}' is set by multiple rules: ${ruleIds.join(', ')}`, {
|
|
474
|
+
field_id: field,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function checkAction(ctx, action, rulePath, ruleId, fieldsSetBy) {
|
|
480
|
+
if (action.set) {
|
|
481
|
+
for (const [field, expr] of Object.entries(action.set)) {
|
|
482
|
+
// W19: set targets a field not in definitions or derived
|
|
483
|
+
if (!ctx.knownFields.has(field)) {
|
|
484
|
+
addIssue(ctx, 'warning', 'set_undefined_field', `Rule '${ruleId}' sets undefined field '${field}'`, {
|
|
485
|
+
path: `${rulePath}.then.set.${field}`,
|
|
486
|
+
field_id: field,
|
|
487
|
+
rule_id: ruleId,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
// Track for W24
|
|
491
|
+
const existing = fieldsSetBy.get(field);
|
|
492
|
+
if (existing) {
|
|
493
|
+
existing.push(ruleId);
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
fieldsSetBy.set(field, [ruleId]);
|
|
497
|
+
}
|
|
498
|
+
// W26: set type mismatch — infer expression type and compare to field type
|
|
499
|
+
const exprType = inferExprType(expr, ctx, `${rulePath}.then.set.${field}`);
|
|
500
|
+
const fieldType = ctx.knownFields.get(field);
|
|
501
|
+
if (fieldType && fieldType !== 'unknown' && exprType !== 'unknown' && exprType !== fieldType) {
|
|
502
|
+
addIssue(ctx, 'warning', 'set_type_mismatch', `Rule '${ruleId}' sets '${field}' (${fieldType}) to a ${exprType} expression`, {
|
|
503
|
+
path: `${rulePath}.then.set.${field}`,
|
|
504
|
+
field_id: field,
|
|
505
|
+
rule_id: ruleId,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (action.ui_modify) {
|
|
511
|
+
for (const field of Object.keys(action.ui_modify)) {
|
|
512
|
+
// W18: ui_modify targets undefined field
|
|
513
|
+
if (!ctx.knownFields.has(field)) {
|
|
514
|
+
addIssue(ctx, 'warning', 'ui_modify_undefined_field', `Rule '${ruleId}' modifies UI of undefined field '${field}'`, {
|
|
515
|
+
path: `${rulePath}.then.ui_modify.${field}`,
|
|
516
|
+
field_id: field,
|
|
517
|
+
rule_id: ruleId,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function checkFieldConflicts(ctx) {
|
|
524
|
+
// W24 is handled in checkExpressions
|
|
525
|
+
}
|
|
526
|
+
function checkDuplicateIds(ctx) {
|
|
527
|
+
const { schema } = ctx;
|
|
528
|
+
// Collect IDs across namespaces
|
|
529
|
+
const seen = new Map();
|
|
530
|
+
function track(id, namespace) {
|
|
531
|
+
const existing = seen.get(id);
|
|
532
|
+
if (existing) {
|
|
533
|
+
existing.push(namespace);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
seen.set(id, [namespace]);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Definition keys
|
|
540
|
+
if (schema.definitions) {
|
|
541
|
+
for (const id of Object.keys(schema.definitions)) {
|
|
542
|
+
track(id, 'definition');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// Rule IDs
|
|
546
|
+
if (schema.logic_tree) {
|
|
547
|
+
for (const rule of schema.logic_tree) {
|
|
548
|
+
if (rule?.id) {
|
|
549
|
+
track(rule.id, 'rule');
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Attestation keys
|
|
554
|
+
if (schema.attestations) {
|
|
555
|
+
for (const id of Object.keys(schema.attestations)) {
|
|
556
|
+
track(id, 'attestation');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// Derived field keys
|
|
560
|
+
if (schema.state_model?.derived) {
|
|
561
|
+
for (const id of Object.keys(schema.state_model.derived)) {
|
|
562
|
+
track(id, 'derived');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Temporal branch logic_versions
|
|
566
|
+
if (schema.temporal_map) {
|
|
567
|
+
for (const branch of schema.temporal_map) {
|
|
568
|
+
if (branch?.logic_version) {
|
|
569
|
+
track(branch.logic_version, 'temporal_version');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// E29: flag any ID appearing in more than one namespace
|
|
574
|
+
for (const [id, namespaces] of seen) {
|
|
575
|
+
const unique = [...new Set(namespaces)];
|
|
576
|
+
if (unique.length > 1) {
|
|
577
|
+
addIssue(ctx, 'error', 'duplicate_id_across_namespaces', `ID '${id}' appears in multiple namespaces: ${unique.join(', ')}`, {
|
|
578
|
+
field_id: id,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// ============================================================
|
|
584
|
+
// Public API
|
|
585
|
+
// ============================================================
|
|
586
|
+
export function lint(schema) {
|
|
587
|
+
// Parse
|
|
588
|
+
let parsed;
|
|
589
|
+
try {
|
|
590
|
+
parsed = typeof schema === 'string' ? JSON.parse(schema) : schema;
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
return {
|
|
594
|
+
valid: false,
|
|
595
|
+
issues: [{
|
|
596
|
+
severity: 'error',
|
|
597
|
+
code: 'parse_error',
|
|
598
|
+
message: `Failed to parse schema: ${e instanceof Error ? e.message : String(e)}`,
|
|
599
|
+
}],
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
const ctx = {
|
|
603
|
+
schema: parsed,
|
|
604
|
+
issues: [],
|
|
605
|
+
knownFields: new Map(),
|
|
606
|
+
supportedOps: new Set(SUPPORTED_OPS),
|
|
607
|
+
};
|
|
608
|
+
// Run check pipeline in order
|
|
609
|
+
checkDefinitions(ctx);
|
|
610
|
+
checkRules(ctx);
|
|
611
|
+
checkAttestations(ctx);
|
|
612
|
+
checkTemporalMap(ctx);
|
|
613
|
+
checkDerived(ctx); // Must run before checkExpressions — updates knownFields for derived
|
|
614
|
+
checkExpressions(ctx);
|
|
615
|
+
checkFieldConflicts(ctx);
|
|
616
|
+
checkDuplicateIds(ctx);
|
|
617
|
+
const hasErrors = ctx.issues.some(i => i.severity === 'error');
|
|
618
|
+
return {
|
|
619
|
+
valid: !hasErrors,
|
|
620
|
+
issues: ctx.issues,
|
|
621
|
+
};
|
|
622
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* Works in both browser and Node.js environments with no WASM dependencies.
|
|
6
6
|
*/
|
|
7
7
|
export type { TenetSchema, TenetResult, TenetVerifyResult, VerifyIssue, VerifyIssueCode, Definition, Rule, Action, TemporalBranch, StateModel, DerivedDef, ValidationError, Evidence, Attestation, ErrorKind, } from './core/types.js';
|
|
8
|
+
export type { LintResult, LintIssue, LintSeverity } from './core/lint.js';
|
|
8
9
|
import type { TenetSchema, TenetResult, TenetVerifyResult } from './core/types.js';
|
|
10
|
+
import type { LintResult } from './core/lint.js';
|
|
9
11
|
/**
|
|
10
12
|
* Initialize the Tenet VM.
|
|
11
13
|
* This is a no-op in the pure TypeScript implementation (kept for backwards compatibility).
|
|
@@ -30,6 +32,14 @@ export declare function run(schema: TenetSchema | string, date?: Date | string):
|
|
|
30
32
|
* @returns Whether the transformation is valid
|
|
31
33
|
*/
|
|
32
34
|
export declare function verify(newSchema: TenetSchema | string, oldSchema: TenetSchema | string): TenetVerifyResult;
|
|
35
|
+
/**
|
|
36
|
+
* Lint a schema for structural errors and type mismatches.
|
|
37
|
+
* Returns issues without executing the schema — pure static analysis.
|
|
38
|
+
*
|
|
39
|
+
* @param schema - The schema object or JSON string
|
|
40
|
+
* @returns Lint result with validity flag and issue list
|
|
41
|
+
*/
|
|
42
|
+
export declare function lint(schema: TenetSchema | string): LintResult;
|
|
33
43
|
/**
|
|
34
44
|
* Check if the VM is ready.
|
|
35
45
|
* Always returns true in the pure TypeScript implementation.
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Works in both browser and Node.js environments with no WASM dependencies.
|
|
6
6
|
*/
|
|
7
7
|
import { run as coreRun, verify as coreVerify } from './core/engine.js';
|
|
8
|
+
import { lint as coreLint } from './core/lint.js';
|
|
8
9
|
/**
|
|
9
10
|
* Initialize the Tenet VM.
|
|
10
11
|
* This is a no-op in the pure TypeScript implementation (kept for backwards compatibility).
|
|
@@ -36,6 +37,16 @@ export function run(schema, date = new Date()) {
|
|
|
36
37
|
export function verify(newSchema, oldSchema) {
|
|
37
38
|
return coreVerify(newSchema, oldSchema);
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Lint a schema for structural errors and type mismatches.
|
|
42
|
+
* Returns issues without executing the schema — pure static analysis.
|
|
43
|
+
*
|
|
44
|
+
* @param schema - The schema object or JSON string
|
|
45
|
+
* @returns Lint result with validity flag and issue list
|
|
46
|
+
*/
|
|
47
|
+
export function lint(schema) {
|
|
48
|
+
return coreLint(schema);
|
|
49
|
+
}
|
|
39
50
|
/**
|
|
40
51
|
* Check if the VM is ready.
|
|
41
52
|
* Always returns true in the pure TypeScript implementation.
|
|
@@ -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