@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.
@@ -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,5 @@
1
+ /**
2
+ * Tests for the lint() static schema analyzer.
3
+ * Organized into: Errors, Warnings, Valid schemas, Edge cases.
4
+ */
5
+ export {};
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dlovans/tenet-core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Declarative logic VM for JSON schemas - reactive validation, temporal routing, and computed state",
6
6
  "main": "dist/index.js",