@orcalang/orca-lang 0.1.19 → 0.1.24

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 (53) hide show
  1. package/dist/compiler/dt-compiler.d.ts +4 -0
  2. package/dist/compiler/dt-compiler.d.ts.map +1 -1
  3. package/dist/compiler/dt-compiler.js +354 -4
  4. package/dist/compiler/dt-compiler.js.map +1 -1
  5. package/dist/health-check.js +75 -0
  6. package/dist/health-check.js.map +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +5 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/parser/ast-to-markdown.d.ts.map +1 -1
  11. package/dist/parser/ast-to-markdown.js +3 -1
  12. package/dist/parser/ast-to-markdown.js.map +1 -1
  13. package/dist/parser/ast.d.ts +1 -0
  14. package/dist/parser/ast.d.ts.map +1 -1
  15. package/dist/parser/dt-ast.d.ts +11 -1
  16. package/dist/parser/dt-ast.d.ts.map +1 -1
  17. package/dist/parser/dt-parser.d.ts.map +1 -1
  18. package/dist/parser/dt-parser.js +40 -8
  19. package/dist/parser/dt-parser.js.map +1 -1
  20. package/dist/parser/markdown-parser.d.ts.map +1 -1
  21. package/dist/parser/markdown-parser.js +14 -4
  22. package/dist/parser/markdown-parser.js.map +1 -1
  23. package/dist/skills.d.ts +3 -2
  24. package/dist/skills.d.ts.map +1 -1
  25. package/dist/skills.js +486 -28
  26. package/dist/skills.js.map +1 -1
  27. package/dist/tools.js +4 -4
  28. package/dist/tools.js.map +1 -1
  29. package/dist/verifier/dt-verifier.d.ts +28 -1
  30. package/dist/verifier/dt-verifier.d.ts.map +1 -1
  31. package/dist/verifier/dt-verifier.js +591 -32
  32. package/dist/verifier/dt-verifier.js.map +1 -1
  33. package/dist/verifier/properties.d.ts +4 -0
  34. package/dist/verifier/properties.d.ts.map +1 -1
  35. package/dist/verifier/properties.js +56 -20
  36. package/dist/verifier/properties.js.map +1 -1
  37. package/dist/verifier/structural.d.ts.map +1 -1
  38. package/dist/verifier/structural.js +6 -1
  39. package/dist/verifier/structural.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/compiler/dt-compiler.ts +374 -4
  42. package/src/health-check.ts +79 -0
  43. package/src/index.ts +5 -1
  44. package/src/parser/ast-to-markdown.ts +2 -1
  45. package/src/parser/ast.ts +1 -0
  46. package/src/parser/dt-ast.ts +4 -2
  47. package/src/parser/dt-parser.ts +46 -8
  48. package/src/parser/markdown-parser.ts +11 -3
  49. package/src/skills.ts +520 -30
  50. package/src/tools.ts +4 -4
  51. package/src/verifier/dt-verifier.ts +639 -30
  52. package/src/verifier/properties.ts +78 -23
  53. package/src/verifier/structural.ts +5 -1
@@ -1,5 +1,6 @@
1
1
  // Decision Table Verifier
2
- // Checks: completeness, consistency, redundancy, and structural integrity
2
+ // Checks: completeness, consistency, redundancy, structural integrity, co-location alignment,
3
+ // and machine integration (coverage gap + dead guard detection).
3
4
  // Helper to get all values for a condition
4
5
  function getConditionValues(condition) {
5
6
  if (condition.type === 'bool') {
@@ -7,7 +8,7 @@ function getConditionValues(condition) {
7
8
  }
8
9
  return condition.values;
9
10
  }
10
- // Check if a cell matches a given value
11
+ // Check if a cell matches a given value (string or numeric)
11
12
  function cellMatches(cell, value) {
12
13
  switch (cell.kind) {
13
14
  case 'any':
@@ -18,6 +19,26 @@ function cellMatches(cell, value) {
18
19
  return cell.value !== value;
19
20
  case 'set':
20
21
  return cell.values.includes(value);
22
+ case 'compare': {
23
+ const num = parseFloat(value);
24
+ if (isNaN(num))
25
+ return false;
26
+ switch (cell.op) {
27
+ case '>': return num > cell.value;
28
+ case '>=': return num >= cell.value;
29
+ case '<': return num < cell.value;
30
+ case '<=': return num <= cell.value;
31
+ }
32
+ return false;
33
+ }
34
+ case 'range': {
35
+ const num = parseFloat(value);
36
+ if (isNaN(num))
37
+ return false;
38
+ const aboveLow = cell.lowInc ? num >= cell.low : num > cell.low;
39
+ const belowHigh = cell.highInc ? num <= cell.high : num < cell.high;
40
+ return aboveLow && belowHigh;
41
+ }
21
42
  }
22
43
  }
23
44
  // Check if a rule's conditions cover a specific input combination
@@ -88,6 +109,42 @@ function cellsIntersect(cell1, cell2) {
88
109
  if (cell1.kind === 'set' && cell2.kind === 'set') {
89
110
  return cell1.values.some(v => cell2.values.includes(v));
90
111
  }
112
+ // --- Numeric cell intersection helpers ---
113
+ // Convert a cell to a numeric interval [low, high] (inclusive flags)
114
+ // Returns null for non-numeric kinds.
115
+ function toInterval(cell) {
116
+ if (cell.kind === 'range') {
117
+ return { low: cell.low, high: cell.high, lowInc: cell.lowInc, highInc: cell.highInc };
118
+ }
119
+ if (cell.kind === 'compare') {
120
+ switch (cell.op) {
121
+ case '>=': return { low: cell.value, high: Infinity, lowInc: true, highInc: false };
122
+ case '>': return { low: cell.value, high: Infinity, lowInc: false, highInc: false };
123
+ case '<=': return { low: -Infinity, high: cell.value, lowInc: false, highInc: true };
124
+ case '<': return { low: -Infinity, high: cell.value, lowInc: false, highInc: false };
125
+ }
126
+ }
127
+ if (cell.kind === 'exact') {
128
+ const n = parseFloat(cell.value);
129
+ if (!isNaN(n))
130
+ return { low: n, high: n, lowInc: true, highInc: true };
131
+ }
132
+ return null;
133
+ }
134
+ function intervalsOverlap(a, b) {
135
+ // a.high < b.low or a.high == b.low and at least one exclusive → no overlap
136
+ if (a.high < b.low || b.high < a.low)
137
+ return false;
138
+ if (a.high === b.low && !(a.highInc && b.lowInc))
139
+ return false;
140
+ if (b.high === a.low && !(b.highInc && a.lowInc))
141
+ return false;
142
+ return true;
143
+ }
144
+ const iv1 = toInterval(cell1);
145
+ const iv2 = toInterval(cell2);
146
+ if (iv1 && iv2)
147
+ return intervalsOverlap(iv1, iv2);
91
148
  return true;
92
149
  }
93
150
  // Generate all possible combinations of condition values
@@ -312,7 +369,6 @@ function checkStructural(dt) {
312
369
  function cellsEqual(cell1, cell2) {
313
370
  if (cell1.kind !== cell2.kind)
314
371
  return false;
315
- // Now cell1 and cell2 have the same kind, narrow both
316
372
  const kind = cell1.kind;
317
373
  if (kind === 'any')
318
374
  return true;
@@ -327,29 +383,170 @@ function cellsEqual(cell1, cell2) {
327
383
  const s2 = cell2;
328
384
  return s1.values.length === s2.values.length && s1.values.every(v => s2.values.includes(v));
329
385
  }
386
+ if (kind === 'compare') {
387
+ const c1 = cell1;
388
+ const c2 = cell2;
389
+ return c1.op === c2.op && c1.value === c2.value;
390
+ }
391
+ if (kind === 'range') {
392
+ const r1 = cell1;
393
+ const r2 = cell2;
394
+ return r1.low === r2.low && r1.high === r2.high && r1.lowInc === r2.lowInc && r1.highInc === r2.highInc;
395
+ }
330
396
  return true;
331
397
  }
332
- // ============================================================
333
- // Completeness Check
334
- // ============================================================
398
+ /** Convert a CellValue to a list of intervals it covers within the given domain. */
399
+ function cellToIntervals(cell, domain) {
400
+ switch (cell.kind) {
401
+ case 'any':
402
+ return [domain];
403
+ case 'compare':
404
+ switch (cell.op) {
405
+ case '>=': return [{ low: Math.max(cell.value, domain.low), high: domain.high, lowInc: cell.value >= domain.low, highInc: domain.highInc }];
406
+ case '>': return [{ low: Math.max(cell.value, domain.low), high: domain.high, lowInc: false, highInc: domain.highInc }];
407
+ case '<=': return [{ low: domain.low, high: Math.min(cell.value, domain.high), lowInc: domain.lowInc, highInc: cell.value <= domain.high }];
408
+ case '<': return [{ low: domain.low, high: Math.min(cell.value, domain.high), lowInc: domain.lowInc, highInc: false }];
409
+ }
410
+ return [];
411
+ case 'range':
412
+ return [{
413
+ low: Math.max(cell.low, domain.low),
414
+ high: Math.min(cell.high, domain.high),
415
+ lowInc: cell.low > domain.low ? cell.lowInc : domain.lowInc,
416
+ highInc: cell.high < domain.high ? cell.highInc : domain.highInc,
417
+ }];
418
+ case 'exact': {
419
+ const v = parseFloat(cell.value);
420
+ if (isNaN(v))
421
+ return [];
422
+ if (v >= domain.low && v <= domain.high)
423
+ return [{ low: v, high: v, lowInc: true, highInc: true }];
424
+ return [];
425
+ }
426
+ default:
427
+ return [];
428
+ }
429
+ }
430
+ /** Check if a sorted list of non-overlapping intervals covers the entire domain without gaps.
431
+ * For integer types, adjacent integers (e.g., [a,N] and [N+1,b]) are treated as contiguous. */
432
+ function checkIntervalCoverage(intervals, domain, isInteger) {
433
+ if (intervals.length === 0)
434
+ return [domain];
435
+ // Sort by low bound, then by inclusive (inclusive first)
436
+ const sorted = [...intervals].sort((a, b) => {
437
+ if (a.low !== b.low)
438
+ return a.low - b.low;
439
+ if (a.lowInc && !b.lowInc)
440
+ return -1;
441
+ if (!a.lowInc && b.lowInc)
442
+ return 1;
443
+ return 0;
444
+ });
445
+ // Merge overlapping/adjacent intervals
446
+ const merged = [sorted[0]];
447
+ for (let i = 1; i < sorted.length; i++) {
448
+ const prev = merged[merged.length - 1];
449
+ const curr = sorted[i];
450
+ // Adjacent or overlapping: prev.high >= curr.low (or == with at least one inclusive)
451
+ // For integers: [a, N] and [N+1, b] are adjacent
452
+ const adjacent = prev.high > curr.low
453
+ || (prev.high === curr.low && (prev.highInc || curr.lowInc))
454
+ || (isInteger && prev.highInc && curr.lowInc && curr.low === prev.high + 1);
455
+ if (adjacent) {
456
+ if (curr.high > prev.high) {
457
+ prev.high = curr.high;
458
+ prev.highInc = curr.highInc;
459
+ }
460
+ else if (curr.high === prev.high) {
461
+ prev.highInc = prev.highInc || curr.highInc;
462
+ }
463
+ }
464
+ else {
465
+ merged.push({ ...curr });
466
+ }
467
+ }
468
+ // Check for gaps against domain
469
+ const gaps = [];
470
+ let cursor = domain.low;
471
+ let cursorInc = domain.lowInc;
472
+ for (const iv of merged) {
473
+ // Is there a gap between cursor and this interval's start?
474
+ if (iv.low > cursor || (iv.low === cursor && !iv.lowInc && cursorInc)) {
475
+ // Gap starts just after the cursor (cursor itself is covered if cursorInc)
476
+ gaps.push({ low: cursor, high: iv.low, lowInc: !cursorInc, highInc: !iv.lowInc });
477
+ }
478
+ if (iv.high > cursor || (iv.high === cursor && iv.highInc && !cursorInc)) {
479
+ cursor = iv.high;
480
+ cursorInc = iv.highInc;
481
+ }
482
+ }
483
+ if (cursor < domain.high || (cursor === domain.high && !cursorInc && domain.highInc)) {
484
+ gaps.push({ low: cursor, high: domain.high, lowInc: !cursorInc, highInc: domain.highInc });
485
+ }
486
+ return gaps;
487
+ }
488
+ /** Check completeness of numeric conditions on a single numeric axis.
489
+ * For each numeric condition, project the rules onto that axis (treating all
490
+ * other conditions as wildcards) and verify there are no gaps in the domain. */
491
+ function checkNumericCompleteness(dt) {
492
+ const errors = [];
493
+ for (const cond of dt.conditions) {
494
+ if (cond.type !== 'int_range' && cond.type !== 'decimal_range')
495
+ continue;
496
+ if (!cond.range) {
497
+ errors.push({
498
+ code: 'DT_COMPLETENESS_SKIPPED',
499
+ message: `Completeness check skipped for '${cond.name}': no domain range declared (use Values column e.g. "0..1000")`,
500
+ severity: 'warning',
501
+ location: { decisionTable: dt.name, condition: cond.name },
502
+ suggestion: `Add a domain range to the conditions table, e.g. "0..1000" in the Values column`,
503
+ });
504
+ continue;
505
+ }
506
+ const domain = { low: cond.range.min, high: cond.range.max, lowInc: true, highInc: true };
507
+ // Collect all intervals from rules for this condition
508
+ const allIntervals = [];
509
+ for (const rule of dt.rules) {
510
+ const cell = rule.conditions.get(cond.name);
511
+ if (!cell) {
512
+ // No condition means wildcard — covers entire domain
513
+ allIntervals.push(domain);
514
+ }
515
+ else {
516
+ allIntervals.push(...cellToIntervals(cell, domain));
517
+ }
518
+ }
519
+ const gaps = checkIntervalCoverage(allIntervals, domain, cond.type === 'int_range');
520
+ for (const gap of gaps) {
521
+ const lowBound = gap.lowInc ? '[' : '(';
522
+ const highBound = gap.highInc ? ']' : ')';
523
+ errors.push({
524
+ code: 'DT_INCOMPLETE',
525
+ message: `Numeric condition '${cond.name}' has uncovered range ${lowBound}${gap.low}, ${gap.high}${highBound}`,
526
+ severity: 'error',
527
+ location: { decisionTable: dt.name, condition: cond.name },
528
+ suggestion: `Add a rule covering the range ${lowBound}${gap.low}, ${gap.high}${highBound}`,
529
+ });
530
+ }
531
+ }
532
+ return errors;
533
+ }
335
534
  function checkCompleteness(dt) {
336
535
  const errors = [];
337
536
  if (dt.conditions.length === 0)
338
537
  return errors; // Already reported in structural
339
- // int_range conditions cannot be exhaustively enumerated skip completeness check
340
- if (dt.conditions.some(c => c.type === 'int_range')) {
341
- errors.push({
342
- code: 'DT_COMPLETENESS_SKIPPED',
343
- message: 'Completeness check skipped: int_range conditions cannot be exhaustively enumerated',
344
- severity: 'warning',
345
- location: { decisionTable: dt.name },
346
- suggestion: 'Manually verify that all numeric ranges are covered without gaps',
347
- });
348
- return errors;
538
+ const hasNumericConditions = dt.conditions.some(c => c.type === 'int_range' || c.type === 'decimal_range');
539
+ const enumBoolConditions = dt.conditions.filter(c => c.type !== 'int_range' && c.type !== 'decimal_range');
540
+ // Interval-based completeness for numeric conditions (per-axis projection)
541
+ if (hasNumericConditions) {
542
+ errors.push(...checkNumericCompleteness(dt));
349
543
  }
350
- // Calculate total combinations for enum/bool conditions
544
+ // Skip cartesian enumeration if there are no enum/bool conditions
545
+ if (enumBoolConditions.length === 0)
546
+ return errors;
547
+ // Calculate total combinations for enum/bool conditions only
351
548
  let totalCombinations = 1;
352
- for (const cond of dt.conditions) {
549
+ for (const cond of enumBoolConditions) {
353
550
  const values = getConditionValues(cond);
354
551
  if (values.length === 0) {
355
552
  totalCombinations = Infinity;
@@ -362,27 +559,59 @@ function checkCompleteness(dt) {
362
559
  if (totalCombinations > 4096) {
363
560
  errors.push({
364
561
  code: 'DT_COMPLETENESS_SKIPPED',
365
- message: `Completeness check skipped: ${totalCombinations} combinations exceed limit of 4096`,
562
+ message: `Completeness check skipped: ${totalCombinations} enum/bool combinations exceed limit of 4096`,
366
563
  severity: 'warning',
367
564
  location: { decisionTable: dt.name },
368
565
  suggestion: 'Consider simplifying conditions or using wildcards to reduce combination count',
369
566
  });
370
567
  return errors;
371
568
  }
372
- // Generate all combinations and check coverage
373
- const combinations = generateCombinations(dt.conditions);
374
- const actionNames = dt.actions.map(a => a.name);
375
- for (const combo of combinations) {
376
- const matchingRules = findMatchingRules(dt, combo);
377
- if (matchingRules.length === 0) {
378
- const comboDesc = [...combo.entries()].map(([k, v]) => `${k}=${v}`).join(', ');
379
- errors.push({
380
- code: 'DT_INCOMPLETE',
381
- message: `Missing coverage for: ${comboDesc}`,
382
- severity: 'error',
383
- location: { decisionTable: dt.name },
384
- suggestion: `Add a rule to cover this condition combination`,
569
+ // For mixed tables (numeric + enum/bool), enumerate only the enum/bool axes
570
+ // and check that for each enum/bool combination, at least one rule could fire
571
+ // (numeric conditions are checked separately above via interval analysis)
572
+ if (hasNumericConditions) {
573
+ // Enumerate enum/bool combinations, check that at least one rule has matching enum/bool
574
+ // cells (ignoring numeric conditions which are covered by interval checks)
575
+ const enumCombinations = generateCombinations(enumBoolConditions);
576
+ for (const combo of enumCombinations) {
577
+ const anyRuleMatchesEnums = dt.rules.some(rule => {
578
+ for (const cond of enumBoolConditions) {
579
+ const cell = rule.conditions.get(cond.name);
580
+ if (!cell)
581
+ continue;
582
+ const expectedValue = combo.get(cond.name);
583
+ if (!cellMatches(cell, expectedValue || ''))
584
+ return false;
585
+ }
586
+ return true;
385
587
  });
588
+ if (!anyRuleMatchesEnums) {
589
+ const comboDesc = [...combo.entries()].map(([k, v]) => `${k}=${v}`).join(', ');
590
+ errors.push({
591
+ code: 'DT_INCOMPLETE',
592
+ message: `Missing coverage for enum/bool combination: ${comboDesc}`,
593
+ severity: 'error',
594
+ location: { decisionTable: dt.name },
595
+ suggestion: `Add a rule to cover this condition combination`,
596
+ });
597
+ }
598
+ }
599
+ }
600
+ else {
601
+ // Pure enum/bool table — original cartesian completeness check
602
+ const combinations = generateCombinations(dt.conditions);
603
+ for (const combo of combinations) {
604
+ const matchingRules = findMatchingRules(dt, combo);
605
+ if (matchingRules.length === 0) {
606
+ const comboDesc = [...combo.entries()].map(([k, v]) => `${k}=${v}`).join(', ');
607
+ errors.push({
608
+ code: 'DT_INCOMPLETE',
609
+ message: `Missing coverage for: ${comboDesc}`,
610
+ severity: 'error',
611
+ location: { decisionTable: dt.name },
612
+ suggestion: `Add a rule to cover this condition combination`,
613
+ });
614
+ }
386
615
  }
387
616
  }
388
617
  return errors;
@@ -496,4 +725,334 @@ export function verifyDecisionTables(dts) {
496
725
  errors: allErrors,
497
726
  };
498
727
  }
728
+ // ============================================================
729
+ // Co-location Alignment Check
730
+ // ============================================================
731
+ /**
732
+ * Check that every condition name and output name in a co-located decision table
733
+ * exists as a context field in the machine. When a DT and machine are in the same
734
+ * file, this contract allows action generation to produce fully-wired code.
735
+ */
736
+ export function checkDTContextAlignment(dt, machine) {
737
+ const errors = [];
738
+ const contextNames = new Set(machine.context.map(f => f.name));
739
+ for (const cond of dt.conditions) {
740
+ if (!contextNames.has(cond.name)) {
741
+ errors.push({
742
+ code: 'DT_CONTEXT_MISMATCH',
743
+ message: `Decision table '${dt.name}' condition '${cond.name}' has no matching context field in machine '${machine.name}'`,
744
+ severity: 'error',
745
+ location: { decisionTable: dt.name, condition: cond.name },
746
+ suggestion: `Add '${cond.name}' to the ## context section, or rename the condition to match an existing context field`,
747
+ });
748
+ }
749
+ }
750
+ for (const action of dt.actions) {
751
+ if (!contextNames.has(action.name)) {
752
+ errors.push({
753
+ code: 'DT_CONTEXT_MISMATCH',
754
+ message: `Decision table '${dt.name}' output '${action.name}' has no matching context field in machine '${machine.name}'`,
755
+ severity: 'error',
756
+ location: { decisionTable: dt.name, action: action.name },
757
+ suggestion: `Add '${action.name}' to the ## context section, or rename the output to match an existing context field`,
758
+ });
759
+ }
760
+ }
761
+ return errors;
762
+ }
763
+ /**
764
+ * For a file with exactly one machine and one or more decision tables, verify
765
+ * that every DT condition and output name matches a machine context field.
766
+ * Multi-machine files are skipped (ambiguous ownership).
767
+ */
768
+ export function checkFileContextAlignment(file) {
769
+ if (file.machines.length !== 1 || file.decisionTables.length === 0) {
770
+ return [];
771
+ }
772
+ const machine = file.machines[0];
773
+ const errors = [];
774
+ for (const dt of file.decisionTables) {
775
+ errors.push(...checkDTContextAlignment(dt, machine));
776
+ }
777
+ return errors;
778
+ }
779
+ // ============================================================
780
+ // Machine Integration Checks
781
+ // ============================================================
782
+ /**
783
+ * Get the enumerable values for a machine context field.
784
+ * Returns null for types that cannot be exhaustively enumerated (string, int, decimal).
785
+ * For enum fields, values are stored as a comma-separated defaultValue string.
786
+ */
787
+ function getMachineFieldValues(field) {
788
+ if (field.type.kind === 'bool')
789
+ return ['true', 'false'];
790
+ if (field.type.kind === 'custom' && field.type.name === 'enum') {
791
+ if (!field.defaultValue)
792
+ return null;
793
+ const vals = field.defaultValue.split(',').map(v => v.trim()).filter(Boolean);
794
+ return vals.length > 0 ? vals : null;
795
+ }
796
+ return null;
797
+ }
798
+ /**
799
+ * Generate all combinations of condition values using machine context values as the
800
+ * input domain. Returns null if any condition cannot be enumerated or if the
801
+ * total combinations exceed the safety limit.
802
+ */
803
+ function generateMachineContextCombinations(dt, contextMap) {
804
+ const domainPerCondition = [];
805
+ for (const cond of dt.conditions) {
806
+ const field = contextMap.get(cond.name);
807
+ if (!field)
808
+ return null; // Alignment not met — caller should have checked
809
+ const vals = getMachineFieldValues(field);
810
+ if (!vals)
811
+ return null; // Non-enumerable type
812
+ domainPerCondition.push(vals);
813
+ }
814
+ // Safety limit
815
+ let total = 1;
816
+ for (const vals of domainPerCondition)
817
+ total *= vals.length;
818
+ if (total > 4096)
819
+ return null;
820
+ // Cartesian product
821
+ const combos = [];
822
+ function cartesian(idx, current) {
823
+ if (idx === dt.conditions.length) {
824
+ combos.push(new Map(current));
825
+ return;
826
+ }
827
+ const condName = dt.conditions[idx].name;
828
+ for (const val of domainPerCondition[idx]) {
829
+ current.set(condName, val);
830
+ cartesian(idx + 1, current);
831
+ current.delete(condName);
832
+ }
833
+ }
834
+ cartesian(0, new Map());
835
+ return combos;
836
+ }
837
+ /**
838
+ * DT_COVERAGE_GAP: Decision table must cover all input combinations the machine
839
+ * context can actually produce. Uses machine enum/bool values as the authoritative
840
+ * domain — stricter than DT_INCOMPLETE which only checks DT-declared values.
841
+ */
842
+ function checkDTCoverageGap(dt, contextMap) {
843
+ const errors = [];
844
+ const combos = generateMachineContextCombinations(dt, contextMap);
845
+ if (!combos)
846
+ return errors; // Non-enumerable conditions or too many combinations
847
+ for (const combo of combos) {
848
+ const matched = dt.rules.some(rule => ruleMatchesInput(rule, dt.conditions, combo));
849
+ if (!matched) {
850
+ const comboDesc = [...combo.entries()].map(([k, v]) => `${k}=${v}`).join(', ');
851
+ errors.push({
852
+ code: 'DT_COVERAGE_GAP',
853
+ message: `Decision table '${dt.name}' has no rule for machine context combination: ${comboDesc}`,
854
+ severity: 'error',
855
+ location: { decisionTable: dt.name },
856
+ suggestion: `Add a rule covering this combination, or add a catch-all row using '-' wildcards`,
857
+ });
858
+ }
859
+ }
860
+ return errors;
861
+ }
862
+ /**
863
+ * Recursively collect all equality comparisons from a guard expression.
864
+ * Returns tuples of (fieldName, op, comparedValue) for any `ctx.X op Y` node.
865
+ */
866
+ function collectFieldComparisons(expr) {
867
+ if (expr.kind === 'compare') {
868
+ // Only handle ctx.fieldName comparisons
869
+ if (expr.left.path.length === 2 && expr.left.path[0] === 'ctx') {
870
+ return [{ field: expr.left.path[1], op: expr.op, value: String(expr.right.value) }];
871
+ }
872
+ return [];
873
+ }
874
+ if (expr.kind === 'not')
875
+ return collectFieldComparisons(expr.expr);
876
+ if (expr.kind === 'and' || expr.kind === 'or') {
877
+ return [...collectFieldComparisons(expr.left), ...collectFieldComparisons(expr.right)];
878
+ }
879
+ return [];
880
+ }
881
+ /**
882
+ * Compute the set of guard names that test a DT output field against a value
883
+ * the DT never produces. These guards are always false after the DT action fires.
884
+ */
885
+ function computeDeadGuardNames(dt, machine) {
886
+ const outputDomain = new Map();
887
+ for (const action of dt.actions) {
888
+ outputDomain.set(action.name, new Set());
889
+ }
890
+ for (const rule of dt.rules) {
891
+ for (const [name, value] of rule.actions) {
892
+ outputDomain.get(name)?.add(value);
893
+ }
894
+ }
895
+ const outputFields = new Set(dt.actions.map(a => a.name));
896
+ const dead = new Set();
897
+ for (const guardDef of machine.guards) {
898
+ const comparisons = collectFieldComparisons(guardDef.expression);
899
+ for (const { field, op, value } of comparisons) {
900
+ if (!outputFields.has(field))
901
+ continue;
902
+ if (op !== 'eq')
903
+ continue;
904
+ const possible = outputDomain.get(field);
905
+ if (!possible.has(value)) {
906
+ dead.add(guardDef.name);
907
+ }
908
+ }
909
+ }
910
+ return dead;
911
+ }
912
+ /**
913
+ * DT_GUARD_DEAD: A guard that compares a DT output field against a value the DT
914
+ * never produces is a dead guard — it can never be true immediately after the
915
+ * DT action fires. Reported as a warning since another action might set the field.
916
+ */
917
+ function checkDTGuardDead(dt, machine) {
918
+ const errors = [];
919
+ // Build output domain: field → set of values the DT can produce
920
+ const outputDomain = new Map();
921
+ for (const action of dt.actions) {
922
+ outputDomain.set(action.name, new Set());
923
+ }
924
+ for (const rule of dt.rules) {
925
+ for (const [name, value] of rule.actions) {
926
+ outputDomain.get(name)?.add(value);
927
+ }
928
+ }
929
+ const outputFields = new Set(dt.actions.map(a => a.name));
930
+ for (const guardDef of machine.guards) {
931
+ const comparisons = collectFieldComparisons(guardDef.expression);
932
+ for (const { field, op, value } of comparisons) {
933
+ if (!outputFields.has(field))
934
+ continue; // Not a DT output field
935
+ if (op !== 'eq')
936
+ continue; // Only equality checks are conclusive
937
+ const possible = outputDomain.get(field);
938
+ if (!possible.has(value)) {
939
+ const possibleList = [...possible].join(', ') || '(none)';
940
+ errors.push({
941
+ code: 'DT_GUARD_DEAD',
942
+ message: `Guard '${guardDef.name}' tests '${field} == ${value}' but '${dt.name}' never outputs '${value}' for '${field}' (possible: ${possibleList})`,
943
+ severity: 'warning',
944
+ location: { decisionTable: dt.name, condition: field },
945
+ suggestion: `Update '${dt.name}' to produce '${value}' for '${field}', or remove this guard`,
946
+ });
947
+ }
948
+ }
949
+ }
950
+ return errors;
951
+ }
952
+ /**
953
+ * BFS from initial state, optionally skipping transitions guarded by dead guards.
954
+ * A non-negated transition guarded by a name in `deadGuards` is skipped (never fires).
955
+ * A negated dead guard (!dead) is NOT skipped — negation of a dead guard is always true.
956
+ */
957
+ function bfsReachableWithDeadGuards(machine, deadGuards) {
958
+ const initial = machine.states.find(s => s.isInitial);
959
+ if (!initial)
960
+ return new Set();
961
+ const visited = new Set();
962
+ const queue = [initial.name];
963
+ while (queue.length > 0) {
964
+ const state = queue.shift();
965
+ if (visited.has(state))
966
+ continue;
967
+ visited.add(state);
968
+ for (const t of machine.transitions) {
969
+ if (t.source !== state)
970
+ continue;
971
+ // A non-negated dead guard means the transition can never fire
972
+ if (t.guard && !t.guard.negated && deadGuards.has(t.guard.name))
973
+ continue;
974
+ if (!visited.has(t.target))
975
+ queue.push(t.target);
976
+ }
977
+ }
978
+ return visited;
979
+ }
980
+ /**
981
+ * DT_UNREACHABLE_STATE: A state that is graph-reachable but only accessible via
982
+ * transitions guarded by dead guards — it can never be entered given DT outputs.
983
+ * Reported as a warning (structural reachability is preserved; the constraint is semantic).
984
+ */
985
+ function checkDTDeadGuardReachability(dt, machine) {
986
+ const deadGuards = computeDeadGuardNames(dt, machine);
987
+ if (deadGuards.size === 0)
988
+ return [];
989
+ const plainReachable = bfsReachableWithDeadGuards(machine, new Set());
990
+ const dtReachable = bfsReachableWithDeadGuards(machine, deadGuards);
991
+ const errors = [];
992
+ const deadList = [...deadGuards].join(', ');
993
+ for (const state of machine.states) {
994
+ if (plainReachable.has(state.name) && !dtReachable.has(state.name)) {
995
+ errors.push({
996
+ code: 'DT_UNREACHABLE_STATE',
997
+ message: `State '${state.name}' is unreachable given '${dt.name}' output constraints — all entry paths are gated by dead guards (${deadList})`,
998
+ severity: 'warning',
999
+ location: { state: state.name, decisionTable: dt.name },
1000
+ suggestion: `Update '${dt.name}' to produce values that satisfy the guards leading to '${state.name}', or revise the guard expressions`,
1001
+ });
1002
+ }
1003
+ }
1004
+ return errors;
1005
+ }
1006
+ /**
1007
+ * Check DT integration with the machine: coverage gap, dead guards, and
1008
+ * DT-constrained reachability. Only runs when exactly one machine is present
1009
+ * and the DT is fully aligned. Multi-machine files are skipped (ambiguous ownership).
1010
+ */
1011
+ export function checkDTMachineIntegration(file) {
1012
+ if (file.machines.length !== 1 || file.decisionTables.length === 0) {
1013
+ return [];
1014
+ }
1015
+ const machine = file.machines[0];
1016
+ const contextMap = new Map(machine.context.map(f => [f.name, f]));
1017
+ const errors = [];
1018
+ for (const dt of file.decisionTables) {
1019
+ // Only verify DTs that are fully aligned with machine context
1020
+ const allAligned = [...dt.conditions, ...dt.actions].every(item => contextMap.has(item.name));
1021
+ if (!allAligned)
1022
+ continue;
1023
+ errors.push(...checkDTCoverageGap(dt, contextMap));
1024
+ errors.push(...checkDTGuardDead(dt, machine));
1025
+ errors.push(...checkDTDeadGuardReachability(dt, machine));
1026
+ }
1027
+ return errors;
1028
+ }
1029
+ /**
1030
+ * Compute the merged output domain across all aligned DTs in a single-machine file.
1031
+ * Returns a map from DT output field name → set of values the DT(s) can produce.
1032
+ * Used by the properties checker to prune guard-protected transitions that are
1033
+ * semantically impossible given DT output constraints.
1034
+ * Returns undefined if no aligned DTs are found.
1035
+ */
1036
+ export function computeAlignedDTOutputDomain(file) {
1037
+ if (file.machines.length !== 1 || file.decisionTables.length === 0)
1038
+ return undefined;
1039
+ const machine = file.machines[0];
1040
+ const contextMap = new Map(machine.context.map(f => [f.name, f]));
1041
+ const domain = new Map();
1042
+ for (const dt of file.decisionTables) {
1043
+ const allAligned = [...dt.conditions, ...dt.actions].every(item => contextMap.has(item.name));
1044
+ if (!allAligned)
1045
+ continue;
1046
+ for (const actionDef of dt.actions) {
1047
+ if (!domain.has(actionDef.name))
1048
+ domain.set(actionDef.name, new Set());
1049
+ for (const rule of dt.rules) {
1050
+ const val = rule.actions.get(actionDef.name);
1051
+ if (val !== undefined)
1052
+ domain.get(actionDef.name).add(val);
1053
+ }
1054
+ }
1055
+ }
1056
+ return domain.size > 0 ? domain : undefined;
1057
+ }
499
1058
  //# sourceMappingURL=dt-verifier.js.map