@orcalang/orca-lang 0.1.21 → 0.1.27

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.
Files changed (43) hide show
  1. package/dist/compiler/dt-compiler.d.ts +1 -0
  2. package/dist/compiler/dt-compiler.d.ts.map +1 -1
  3. package/dist/compiler/dt-compiler.js +160 -3
  4. package/dist/compiler/dt-compiler.js.map +1 -1
  5. package/dist/compiler/xstate.d.ts.map +1 -1
  6. package/dist/compiler/xstate.js +13 -1
  7. package/dist/compiler/xstate.js.map +1 -1
  8. package/dist/health-check.js +75 -0
  9. package/dist/health-check.js.map +1 -1
  10. package/dist/parser/dt-ast.d.ts +11 -1
  11. package/dist/parser/dt-ast.d.ts.map +1 -1
  12. package/dist/parser/dt-parser.d.ts.map +1 -1
  13. package/dist/parser/dt-parser.js +40 -8
  14. package/dist/parser/dt-parser.js.map +1 -1
  15. package/dist/parser/markdown-parser.d.ts.map +1 -1
  16. package/dist/parser/markdown-parser.js +6 -0
  17. package/dist/parser/markdown-parser.js.map +1 -1
  18. package/dist/skills.d.ts +2 -2
  19. package/dist/skills.d.ts.map +1 -1
  20. package/dist/skills.js +200 -6
  21. package/dist/skills.js.map +1 -1
  22. package/dist/tools.js +4 -4
  23. package/dist/tools.js.map +1 -1
  24. package/dist/verifier/determinism.js +5 -5
  25. package/dist/verifier/determinism.js.map +1 -1
  26. package/dist/verifier/dt-verifier.d.ts.map +1 -1
  27. package/dist/verifier/dt-verifier.js +259 -31
  28. package/dist/verifier/dt-verifier.js.map +1 -1
  29. package/dist/verifier/structural.d.ts.map +1 -1
  30. package/dist/verifier/structural.js +11 -5
  31. package/dist/verifier/structural.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/compiler/dt-compiler.ts +160 -3
  34. package/src/compiler/xstate.ts +15 -1
  35. package/src/health-check.ts +79 -0
  36. package/src/parser/dt-ast.ts +4 -2
  37. package/src/parser/dt-parser.ts +46 -8
  38. package/src/parser/markdown-parser.ts +6 -0
  39. package/src/skills.ts +201 -7
  40. package/src/tools.ts +4 -4
  41. package/src/verifier/determinism.ts +5 -5
  42. package/src/verifier/dt-verifier.ts +272 -29
  43. package/src/verifier/structural.ts +12 -5
@@ -14,7 +14,7 @@ function getConditionValues(condition: ConditionDef): string[] {
14
14
  return condition.values;
15
15
  }
16
16
 
17
- // Check if a cell matches a given value
17
+ // Check if a cell matches a given value (string or numeric)
18
18
  function cellMatches(cell: CellValue, value: string): boolean {
19
19
  switch (cell.kind) {
20
20
  case 'any':
@@ -25,6 +25,24 @@ function cellMatches(cell: CellValue, value: string): boolean {
25
25
  return cell.value !== value;
26
26
  case 'set':
27
27
  return cell.values.includes(value);
28
+ case 'compare': {
29
+ const num = parseFloat(value);
30
+ if (isNaN(num)) return false;
31
+ switch (cell.op) {
32
+ case '>': return num > cell.value;
33
+ case '>=': return num >= cell.value;
34
+ case '<': return num < cell.value;
35
+ case '<=': return num <= cell.value;
36
+ }
37
+ return false;
38
+ }
39
+ case 'range': {
40
+ const num = parseFloat(value);
41
+ if (isNaN(num)) return false;
42
+ const aboveLow = cell.lowInc ? num >= cell.low : num > cell.low;
43
+ const belowHigh = cell.highInc ? num <= cell.high : num < cell.high;
44
+ return aboveLow && belowHigh;
45
+ }
28
46
  }
29
47
  }
30
48
 
@@ -103,6 +121,44 @@ function cellsIntersect(cell1: CellValue, cell2: CellValue): boolean {
103
121
  return cell1.values.some(v => cell2.values.includes(v));
104
122
  }
105
123
 
124
+ // --- Numeric cell intersection helpers ---
125
+
126
+ // Convert a cell to a numeric interval [low, high] (inclusive flags)
127
+ // Returns null for non-numeric kinds.
128
+ function toInterval(cell: CellValue): { low: number; high: number; lowInc: boolean; highInc: boolean } | null {
129
+ if (cell.kind === 'range') {
130
+ return { low: cell.low, high: cell.high, lowInc: cell.lowInc, highInc: cell.highInc };
131
+ }
132
+ if (cell.kind === 'compare') {
133
+ switch (cell.op) {
134
+ case '>=': return { low: cell.value, high: Infinity, lowInc: true, highInc: false };
135
+ case '>': return { low: cell.value, high: Infinity, lowInc: false, highInc: false };
136
+ case '<=': return { low: -Infinity, high: cell.value, lowInc: false, highInc: true };
137
+ case '<': return { low: -Infinity, high: cell.value, lowInc: false, highInc: false };
138
+ }
139
+ }
140
+ if (cell.kind === 'exact') {
141
+ const n = parseFloat(cell.value);
142
+ if (!isNaN(n)) return { low: n, high: n, lowInc: true, highInc: true };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function intervalsOverlap(
148
+ a: { low: number; high: number; lowInc: boolean; highInc: boolean },
149
+ b: { low: number; high: number; lowInc: boolean; highInc: boolean }
150
+ ): boolean {
151
+ // a.high < b.low or a.high == b.low and at least one exclusive → no overlap
152
+ if (a.high < b.low || b.high < a.low) return false;
153
+ if (a.high === b.low && !(a.highInc && b.lowInc)) return false;
154
+ if (b.high === a.low && !(b.highInc && a.lowInc)) return false;
155
+ return true;
156
+ }
157
+
158
+ const iv1 = toInterval(cell1);
159
+ const iv2 = toInterval(cell2);
160
+ if (iv1 && iv2) return intervalsOverlap(iv1, iv2);
161
+
106
162
  return true;
107
163
  }
108
164
 
@@ -345,7 +401,6 @@ function checkStructural(dt: DecisionTableDef): VerificationError[] {
345
401
 
346
402
  function cellsEqual(cell1: CellValue, cell2: CellValue): boolean {
347
403
  if (cell1.kind !== cell2.kind) return false;
348
- // Now cell1 and cell2 have the same kind, narrow both
349
404
  const kind = cell1.kind;
350
405
  if (kind === 'any') return true;
351
406
  if (kind === 'exact') {
@@ -359,6 +414,16 @@ function cellsEqual(cell1: CellValue, cell2: CellValue): boolean {
359
414
  const s2 = cell2 as { kind: 'set'; values: string[] };
360
415
  return s1.values.length === s2.values.length && s1.values.every(v => s2.values.includes(v));
361
416
  }
417
+ if (kind === 'compare') {
418
+ const c1 = cell1 as { kind: 'compare'; op: string; value: number };
419
+ const c2 = cell2 as { kind: 'compare'; op: string; value: number };
420
+ return c1.op === c2.op && c1.value === c2.value;
421
+ }
422
+ if (kind === 'range') {
423
+ const r1 = cell1 as { kind: 'range'; low: number; high: number; lowInc: boolean; highInc: boolean };
424
+ const r2 = cell2 as { kind: 'range'; low: number; high: number; lowInc: boolean; highInc: boolean };
425
+ return r1.low === r2.low && r1.high === r2.high && r1.lowInc === r2.lowInc && r1.highInc === r2.highInc;
426
+ }
362
427
  return true;
363
428
  }
364
429
 
@@ -366,26 +431,174 @@ function cellsEqual(cell1: CellValue, cell2: CellValue): boolean {
366
431
  // Completeness Check
367
432
  // ============================================================
368
433
 
434
+ // --- Interval-based completeness for numeric conditions ---
435
+
436
+ interface Interval {
437
+ low: number;
438
+ high: number;
439
+ lowInc: boolean;
440
+ highInc: boolean;
441
+ }
442
+
443
+ /** Convert a CellValue to a list of intervals it covers within the given domain. */
444
+ function cellToIntervals(cell: CellValue, domain: Interval): Interval[] {
445
+ switch (cell.kind) {
446
+ case 'any':
447
+ return [domain];
448
+ case 'compare':
449
+ switch (cell.op) {
450
+ case '>=': return [{ low: Math.max(cell.value, domain.low), high: domain.high, lowInc: cell.value >= domain.low, highInc: domain.highInc }];
451
+ case '>': return [{ low: Math.max(cell.value, domain.low), high: domain.high, lowInc: false, highInc: domain.highInc }];
452
+ case '<=': return [{ low: domain.low, high: Math.min(cell.value, domain.high), lowInc: domain.lowInc, highInc: cell.value <= domain.high }];
453
+ case '<': return [{ low: domain.low, high: Math.min(cell.value, domain.high), lowInc: domain.lowInc, highInc: false }];
454
+ }
455
+ return [];
456
+ case 'range':
457
+ return [{
458
+ low: Math.max(cell.low, domain.low),
459
+ high: Math.min(cell.high, domain.high),
460
+ lowInc: cell.low > domain.low ? cell.lowInc : domain.lowInc,
461
+ highInc: cell.high < domain.high ? cell.highInc : domain.highInc,
462
+ }];
463
+ case 'exact': {
464
+ const v = parseFloat(cell.value);
465
+ if (isNaN(v)) return [];
466
+ if (v >= domain.low && v <= domain.high) return [{ low: v, high: v, lowInc: true, highInc: true }];
467
+ return [];
468
+ }
469
+ default:
470
+ return [];
471
+ }
472
+ }
473
+
474
+ /** Check if a sorted list of non-overlapping intervals covers the entire domain without gaps.
475
+ * For integer types, adjacent integers (e.g., [a,N] and [N+1,b]) are treated as contiguous. */
476
+ function checkIntervalCoverage(intervals: Interval[], domain: Interval, isInteger: boolean): Interval[] {
477
+ if (intervals.length === 0) return [domain];
478
+
479
+ // Sort by low bound, then by inclusive (inclusive first)
480
+ const sorted = [...intervals].sort((a, b) => {
481
+ if (a.low !== b.low) return a.low - b.low;
482
+ if (a.lowInc && !b.lowInc) return -1;
483
+ if (!a.lowInc && b.lowInc) return 1;
484
+ return 0;
485
+ });
486
+
487
+ // Merge overlapping/adjacent intervals
488
+ const merged: Interval[] = [sorted[0]];
489
+ for (let i = 1; i < sorted.length; i++) {
490
+ const prev = merged[merged.length - 1];
491
+ const curr = sorted[i];
492
+ // Adjacent or overlapping: prev.high >= curr.low (or == with at least one inclusive)
493
+ // For integers: [a, N] and [N+1, b] are adjacent
494
+ const adjacent = prev.high > curr.low
495
+ || (prev.high === curr.low && (prev.highInc || curr.lowInc))
496
+ || (isInteger && prev.highInc && curr.lowInc && curr.low === prev.high + 1);
497
+ if (adjacent) {
498
+ if (curr.high > prev.high) {
499
+ prev.high = curr.high;
500
+ prev.highInc = curr.highInc;
501
+ } else if (curr.high === prev.high) {
502
+ prev.highInc = prev.highInc || curr.highInc;
503
+ }
504
+ } else {
505
+ merged.push({ ...curr });
506
+ }
507
+ }
508
+
509
+ // Check for gaps against domain
510
+ const gaps: Interval[] = [];
511
+ let cursor = domain.low;
512
+ let cursorInc = domain.lowInc;
513
+
514
+ for (const iv of merged) {
515
+ // Is there a gap between cursor and this interval's start?
516
+ if (iv.low > cursor || (iv.low === cursor && !iv.lowInc && cursorInc)) {
517
+ // Gap starts just after the cursor (cursor itself is covered if cursorInc)
518
+ gaps.push({ low: cursor, high: iv.low, lowInc: !cursorInc, highInc: !iv.lowInc });
519
+ }
520
+ if (iv.high > cursor || (iv.high === cursor && iv.highInc && !cursorInc)) {
521
+ cursor = iv.high;
522
+ cursorInc = iv.highInc;
523
+ }
524
+ }
525
+
526
+ if (cursor < domain.high || (cursor === domain.high && !cursorInc && domain.highInc)) {
527
+ gaps.push({ low: cursor, high: domain.high, lowInc: !cursorInc, highInc: domain.highInc });
528
+ }
529
+
530
+ return gaps;
531
+ }
532
+
533
+ /** Check completeness of numeric conditions on a single numeric axis.
534
+ * For each numeric condition, project the rules onto that axis (treating all
535
+ * other conditions as wildcards) and verify there are no gaps in the domain. */
536
+ function checkNumericCompleteness(dt: DecisionTableDef): VerificationError[] {
537
+ const errors: VerificationError[] = [];
538
+
539
+ for (const cond of dt.conditions) {
540
+ if (cond.type !== 'int_range' && cond.type !== 'decimal_range') continue;
541
+ if (!cond.range) {
542
+ errors.push({
543
+ code: 'DT_COMPLETENESS_SKIPPED',
544
+ message: `Completeness check skipped for '${cond.name}': no domain range declared (use Values column e.g. "0..1000")`,
545
+ severity: 'warning',
546
+ location: { decisionTable: dt.name, condition: cond.name },
547
+ suggestion: `Add a domain range to the conditions table, e.g. "0..1000" in the Values column`,
548
+ });
549
+ continue;
550
+ }
551
+
552
+ const domain: Interval = { low: cond.range.min, high: cond.range.max, lowInc: true, highInc: true };
553
+
554
+ // Collect all intervals from rules for this condition
555
+ const allIntervals: Interval[] = [];
556
+ for (const rule of dt.rules) {
557
+ const cell = rule.conditions.get(cond.name);
558
+ if (!cell) {
559
+ // No condition means wildcard — covers entire domain
560
+ allIntervals.push(domain);
561
+ } else {
562
+ allIntervals.push(...cellToIntervals(cell, domain));
563
+ }
564
+ }
565
+
566
+ const gaps = checkIntervalCoverage(allIntervals, domain, cond.type === 'int_range');
567
+ for (const gap of gaps) {
568
+ const lowBound = gap.lowInc ? '[' : '(';
569
+ const highBound = gap.highInc ? ']' : ')';
570
+ errors.push({
571
+ code: 'DT_INCOMPLETE',
572
+ message: `Numeric condition '${cond.name}' has uncovered range ${lowBound}${gap.low}, ${gap.high}${highBound}`,
573
+ severity: 'error',
574
+ location: { decisionTable: dt.name, condition: cond.name },
575
+ suggestion: `Add a rule covering the range ${lowBound}${gap.low}, ${gap.high}${highBound}`,
576
+ });
577
+ }
578
+ }
579
+
580
+ return errors;
581
+ }
582
+
369
583
  function checkCompleteness(dt: DecisionTableDef): VerificationError[] {
370
584
  const errors: VerificationError[] = [];
371
585
 
372
586
  if (dt.conditions.length === 0) return errors; // Already reported in structural
373
587
 
374
- // int_range conditions cannot be exhaustively enumerated skip completeness check
375
- if (dt.conditions.some(c => c.type === 'int_range')) {
376
- errors.push({
377
- code: 'DT_COMPLETENESS_SKIPPED',
378
- message: 'Completeness check skipped: int_range conditions cannot be exhaustively enumerated',
379
- severity: 'warning',
380
- location: { decisionTable: dt.name },
381
- suggestion: 'Manually verify that all numeric ranges are covered without gaps',
382
- });
383
- return errors;
588
+ const hasNumericConditions = dt.conditions.some(c => c.type === 'int_range' || c.type === 'decimal_range');
589
+ const enumBoolConditions = dt.conditions.filter(c => c.type !== 'int_range' && c.type !== 'decimal_range');
590
+
591
+ // Interval-based completeness for numeric conditions (per-axis projection)
592
+ if (hasNumericConditions) {
593
+ errors.push(...checkNumericCompleteness(dt));
384
594
  }
385
595
 
386
- // Calculate total combinations for enum/bool conditions
596
+ // Skip cartesian enumeration if there are no enum/bool conditions
597
+ if (enumBoolConditions.length === 0) return errors;
598
+
599
+ // Calculate total combinations for enum/bool conditions only
387
600
  let totalCombinations = 1;
388
- for (const cond of dt.conditions) {
601
+ for (const cond of enumBoolConditions) {
389
602
  const values = getConditionValues(cond);
390
603
  if (values.length === 0) {
391
604
  totalCombinations = Infinity;
@@ -398,7 +611,7 @@ function checkCompleteness(dt: DecisionTableDef): VerificationError[] {
398
611
  if (totalCombinations > 4096) {
399
612
  errors.push({
400
613
  code: 'DT_COMPLETENESS_SKIPPED',
401
- message: `Completeness check skipped: ${totalCombinations} combinations exceed limit of 4096`,
614
+ message: `Completeness check skipped: ${totalCombinations} enum/bool combinations exceed limit of 4096`,
402
615
  severity: 'warning',
403
616
  location: { decisionTable: dt.name },
404
617
  suggestion: 'Consider simplifying conditions or using wildcards to reduce combination count',
@@ -406,22 +619,52 @@ function checkCompleteness(dt: DecisionTableDef): VerificationError[] {
406
619
  return errors;
407
620
  }
408
621
 
409
- // Generate all combinations and check coverage
410
- const combinations = generateCombinations(dt.conditions);
411
- const actionNames = dt.actions.map(a => a.name);
622
+ // For mixed tables (numeric + enum/bool), enumerate only the enum/bool axes
623
+ // and check that for each enum/bool combination, at least one rule could fire
624
+ // (numeric conditions are checked separately above via interval analysis)
625
+ if (hasNumericConditions) {
626
+ // Enumerate enum/bool combinations, check that at least one rule has matching enum/bool
627
+ // cells (ignoring numeric conditions which are covered by interval checks)
628
+ const enumCombinations = generateCombinations(enumBoolConditions);
629
+ for (const combo of enumCombinations) {
630
+ const anyRuleMatchesEnums = dt.rules.some(rule => {
631
+ for (const cond of enumBoolConditions) {
632
+ const cell = rule.conditions.get(cond.name);
633
+ if (!cell) continue;
634
+ const expectedValue = combo.get(cond.name);
635
+ if (!cellMatches(cell, expectedValue || '')) return false;
636
+ }
637
+ return true;
638
+ });
639
+
640
+ if (!anyRuleMatchesEnums) {
641
+ const comboDesc = [...combo.entries()].map(([k, v]) => `${k}=${v}`).join(', ');
642
+ errors.push({
643
+ code: 'DT_INCOMPLETE',
644
+ message: `Missing coverage for enum/bool combination: ${comboDesc}`,
645
+ severity: 'error',
646
+ location: { decisionTable: dt.name },
647
+ suggestion: `Add a rule to cover this condition combination`,
648
+ });
649
+ }
650
+ }
651
+ } else {
652
+ // Pure enum/bool table — original cartesian completeness check
653
+ const combinations = generateCombinations(dt.conditions);
412
654
 
413
- for (const combo of combinations) {
414
- const matchingRules = findMatchingRules(dt, combo);
655
+ for (const combo of combinations) {
656
+ const matchingRules = findMatchingRules(dt, combo);
415
657
 
416
- if (matchingRules.length === 0) {
417
- const comboDesc = [...combo.entries()].map(([k, v]) => `${k}=${v}`).join(', ');
418
- errors.push({
419
- code: 'DT_INCOMPLETE',
420
- message: `Missing coverage for: ${comboDesc}`,
421
- severity: 'error',
422
- location: { decisionTable: dt.name },
423
- suggestion: `Add a rule to cover this condition combination`,
424
- });
658
+ if (matchingRules.length === 0) {
659
+ const comboDesc = [...combo.entries()].map(([k, v]) => `${k}=${v}`).join(', ');
660
+ errors.push({
661
+ code: 'DT_INCOMPLETE',
662
+ message: `Missing coverage for: ${comboDesc}`,
663
+ severity: 'error',
664
+ location: { decisionTable: dt.name },
665
+ suggestion: `Add a rule to cover this condition combination`,
666
+ });
667
+ }
425
668
  }
426
669
  }
427
670
 
@@ -302,6 +302,13 @@ export function checkReachability(analysis: MachineAnalysis): VerificationError[
302
302
  if (visited.has(name)) continue;
303
303
  visited.add(name);
304
304
 
305
+ // When a compound/parallel state is visited, its children are reachable too
306
+ for (const [flatName] of stateMap) {
307
+ if (flatName.startsWith(name + '.') && !visited.has(flatName)) {
308
+ queue.push(flatName);
309
+ }
310
+ }
311
+
305
312
  const info = stateMap.get(name);
306
313
  if (!info) continue;
307
314
 
@@ -312,14 +319,14 @@ export function checkReachability(analysis: MachineAnalysis): VerificationError[
312
319
  }
313
320
  }
314
321
 
315
- for (const state of analysis.machine.states) {
316
- if (!visited.has(state.name)) {
322
+ for (const [name] of stateMap) {
323
+ if (!visited.has(name)) {
317
324
  errors.push({
318
325
  code: 'UNREACHABLE_STATE',
319
- message: `State '${state.name}' is unreachable from initial state '${initialState.name}'`,
326
+ message: `State '${name}' is unreachable from initial state '${initialState.name}'`,
320
327
  severity: 'error',
321
- location: { state: state.name },
322
- suggestion: `Add a transition that reaches '${state.name}'`,
328
+ location: { state: name },
329
+ suggestion: `Add a transition that reaches '${name}'`,
323
330
  });
324
331
  }
325
332
  }