@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,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/core/operators.js
CHANGED
|
@@ -127,15 +127,21 @@ function getVar(path, state, resolve) {
|
|
|
127
127
|
const rootVar = parts[0];
|
|
128
128
|
// Check if variable is defined (only for root-level vars, not nested access)
|
|
129
129
|
if (!isVariableDefined(rootVar, state) && state.currentElement === undefined) {
|
|
130
|
-
addError(state, '', '', `Undefined variable '${rootVar}' in logic expression`);
|
|
130
|
+
addError(state, '', '', 'runtime_warning', `Undefined variable '${rootVar}' in logic expression`);
|
|
131
131
|
return null;
|
|
132
132
|
}
|
|
133
133
|
// First, check derived state (derived values take precedence)
|
|
134
134
|
if (state.schema.state_model?.derived && resolve) {
|
|
135
135
|
const derived = state.schema.state_model.derived[rootVar];
|
|
136
136
|
if (derived?.eval) {
|
|
137
|
-
//
|
|
137
|
+
// Cycle detection for derived fields
|
|
138
|
+
if (state.derivedInProgress.has(rootVar)) {
|
|
139
|
+
addError(state, '', '', 'cycle_detected', `Circular dependency detected in derived field '${rootVar}'`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
state.derivedInProgress.add(rootVar);
|
|
138
143
|
const result = resolve(derived.eval, state);
|
|
144
|
+
state.derivedInProgress.delete(rootVar);
|
|
139
145
|
if (parts.length === 1) {
|
|
140
146
|
return result;
|
|
141
147
|
}
|
|
@@ -442,7 +448,7 @@ const operators = {
|
|
|
442
448
|
export function applyOperator(op, args, state, resolve) {
|
|
443
449
|
const fn = operators[op];
|
|
444
450
|
if (!fn) {
|
|
445
|
-
addError(state, '', '', `Unknown operator '${op}' in logic expression`);
|
|
451
|
+
addError(state, '', '', 'runtime_warning', `Unknown operator '${op}' in logic expression`);
|
|
446
452
|
return null;
|
|
447
453
|
}
|
|
448
454
|
return fn(args, state, resolve);
|
package/dist/core/temporal.js
CHANGED
|
@@ -21,7 +21,7 @@ function validateTemporalMap(state) {
|
|
|
21
21
|
const end = branch.valid_range[1];
|
|
22
22
|
// Check for same start/end date (invalid zero-length range)
|
|
23
23
|
if (start && end && start === end) {
|
|
24
|
-
addError(state, '', '', `Temporal branch ${i} has same start and end date '${start}' (invalid range)`);
|
|
24
|
+
addError(state, '', '', 'runtime_warning', `Temporal branch ${i} has same start and end date '${start}' (invalid range)`);
|
|
25
25
|
}
|
|
26
26
|
// Check for overlapping with previous branch
|
|
27
27
|
if (i > 0) {
|
|
@@ -34,7 +34,7 @@ function validateTemporalMap(state) {
|
|
|
34
34
|
? new Date(start).getTime()
|
|
35
35
|
: -Infinity;
|
|
36
36
|
if (currStart <= prevEnd) {
|
|
37
|
-
addError(state, '', '', `Temporal branch ${i} overlaps with branch ${i - 1} (ranges must not overlap)`);
|
|
37
|
+
addError(state, '', '', 'runtime_warning', `Temporal branch ${i} overlaps with branch ${i - 1} (ranges must not overlap)`);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|