@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.
@@ -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
+ }
@@ -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
- // Evaluate the derived expression on-demand
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);
@@ -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
  }