@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.
- package/dist/compiler/dt-compiler.d.ts +4 -0
- package/dist/compiler/dt-compiler.d.ts.map +1 -1
- package/dist/compiler/dt-compiler.js +354 -4
- package/dist/compiler/dt-compiler.js.map +1 -1
- package/dist/health-check.js +75 -0
- package/dist/health-check.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/parser/ast-to-markdown.d.ts.map +1 -1
- package/dist/parser/ast-to-markdown.js +3 -1
- package/dist/parser/ast-to-markdown.js.map +1 -1
- package/dist/parser/ast.d.ts +1 -0
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/dt-ast.d.ts +11 -1
- package/dist/parser/dt-ast.d.ts.map +1 -1
- package/dist/parser/dt-parser.d.ts.map +1 -1
- package/dist/parser/dt-parser.js +40 -8
- package/dist/parser/dt-parser.js.map +1 -1
- package/dist/parser/markdown-parser.d.ts.map +1 -1
- package/dist/parser/markdown-parser.js +14 -4
- package/dist/parser/markdown-parser.js.map +1 -1
- package/dist/skills.d.ts +3 -2
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +486 -28
- package/dist/skills.js.map +1 -1
- package/dist/tools.js +4 -4
- package/dist/tools.js.map +1 -1
- package/dist/verifier/dt-verifier.d.ts +28 -1
- package/dist/verifier/dt-verifier.d.ts.map +1 -1
- package/dist/verifier/dt-verifier.js +591 -32
- package/dist/verifier/dt-verifier.js.map +1 -1
- package/dist/verifier/properties.d.ts +4 -0
- package/dist/verifier/properties.d.ts.map +1 -1
- package/dist/verifier/properties.js +56 -20
- package/dist/verifier/properties.js.map +1 -1
- package/dist/verifier/structural.d.ts.map +1 -1
- package/dist/verifier/structural.js +6 -1
- package/dist/verifier/structural.js.map +1 -1
- package/package.json +1 -1
- package/src/compiler/dt-compiler.ts +374 -4
- package/src/health-check.ts +79 -0
- package/src/index.ts +5 -1
- package/src/parser/ast-to-markdown.ts +2 -1
- package/src/parser/ast.ts +1 -0
- package/src/parser/dt-ast.ts +4 -2
- package/src/parser/dt-parser.ts +46 -8
- package/src/parser/markdown-parser.ts +11 -3
- package/src/skills.ts +520 -30
- package/src/tools.ts +4 -4
- package/src/verifier/dt-verifier.ts +639 -30
- package/src/verifier/properties.ts +78 -23
- package/src/verifier/structural.ts +5 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Decision Table Verifier
|
|
2
|
-
// Checks: completeness, consistency, redundancy,
|
|
2
|
+
// Checks: completeness, consistency, redundancy, structural integrity, co-location alignment,
|
|
3
|
+
// and machine integration (coverage gap + dead guard detection).
|
|
3
4
|
|
|
4
5
|
import { DecisionTableDef, ConditionDef, CellValue, Rule } from '../parser/dt-ast.js';
|
|
6
|
+
import { MachineDef, OrcaFile, ContextField, GuardExpression, ComparisonOp } from '../parser/ast.js';
|
|
5
7
|
import { VerificationError, VerificationResult, Severity } from './types.js';
|
|
6
8
|
|
|
7
9
|
// Helper to get all values for a condition
|
|
@@ -12,7 +14,7 @@ function getConditionValues(condition: ConditionDef): string[] {
|
|
|
12
14
|
return condition.values;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
// Check if a cell matches a given value
|
|
17
|
+
// Check if a cell matches a given value (string or numeric)
|
|
16
18
|
function cellMatches(cell: CellValue, value: string): boolean {
|
|
17
19
|
switch (cell.kind) {
|
|
18
20
|
case 'any':
|
|
@@ -23,6 +25,24 @@ function cellMatches(cell: CellValue, value: string): boolean {
|
|
|
23
25
|
return cell.value !== value;
|
|
24
26
|
case 'set':
|
|
25
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
|
+
}
|
|
26
46
|
}
|
|
27
47
|
}
|
|
28
48
|
|
|
@@ -101,6 +121,44 @@ function cellsIntersect(cell1: CellValue, cell2: CellValue): boolean {
|
|
|
101
121
|
return cell1.values.some(v => cell2.values.includes(v));
|
|
102
122
|
}
|
|
103
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
|
+
|
|
104
162
|
return true;
|
|
105
163
|
}
|
|
106
164
|
|
|
@@ -343,7 +401,6 @@ function checkStructural(dt: DecisionTableDef): VerificationError[] {
|
|
|
343
401
|
|
|
344
402
|
function cellsEqual(cell1: CellValue, cell2: CellValue): boolean {
|
|
345
403
|
if (cell1.kind !== cell2.kind) return false;
|
|
346
|
-
// Now cell1 and cell2 have the same kind, narrow both
|
|
347
404
|
const kind = cell1.kind;
|
|
348
405
|
if (kind === 'any') return true;
|
|
349
406
|
if (kind === 'exact') {
|
|
@@ -357,6 +414,16 @@ function cellsEqual(cell1: CellValue, cell2: CellValue): boolean {
|
|
|
357
414
|
const s2 = cell2 as { kind: 'set'; values: string[] };
|
|
358
415
|
return s1.values.length === s2.values.length && s1.values.every(v => s2.values.includes(v));
|
|
359
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
|
+
}
|
|
360
427
|
return true;
|
|
361
428
|
}
|
|
362
429
|
|
|
@@ -364,26 +431,174 @@ function cellsEqual(cell1: CellValue, cell2: CellValue): boolean {
|
|
|
364
431
|
// Completeness Check
|
|
365
432
|
// ============================================================
|
|
366
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
|
+
|
|
367
583
|
function checkCompleteness(dt: DecisionTableDef): VerificationError[] {
|
|
368
584
|
const errors: VerificationError[] = [];
|
|
369
585
|
|
|
370
586
|
if (dt.conditions.length === 0) return errors; // Already reported in structural
|
|
371
587
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
location: { decisionTable: dt.name },
|
|
379
|
-
suggestion: 'Manually verify that all numeric ranges are covered without gaps',
|
|
380
|
-
});
|
|
381
|
-
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));
|
|
382
594
|
}
|
|
383
595
|
|
|
384
|
-
//
|
|
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
|
|
385
600
|
let totalCombinations = 1;
|
|
386
|
-
for (const cond of
|
|
601
|
+
for (const cond of enumBoolConditions) {
|
|
387
602
|
const values = getConditionValues(cond);
|
|
388
603
|
if (values.length === 0) {
|
|
389
604
|
totalCombinations = Infinity;
|
|
@@ -396,7 +611,7 @@ function checkCompleteness(dt: DecisionTableDef): VerificationError[] {
|
|
|
396
611
|
if (totalCombinations > 4096) {
|
|
397
612
|
errors.push({
|
|
398
613
|
code: 'DT_COMPLETENESS_SKIPPED',
|
|
399
|
-
message: `Completeness check skipped: ${totalCombinations} combinations exceed limit of 4096`,
|
|
614
|
+
message: `Completeness check skipped: ${totalCombinations} enum/bool combinations exceed limit of 4096`,
|
|
400
615
|
severity: 'warning',
|
|
401
616
|
location: { decisionTable: dt.name },
|
|
402
617
|
suggestion: 'Consider simplifying conditions or using wildcards to reduce combination count',
|
|
@@ -404,22 +619,52 @@ function checkCompleteness(dt: DecisionTableDef): VerificationError[] {
|
|
|
404
619
|
return errors;
|
|
405
620
|
}
|
|
406
621
|
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
});
|
|
410
639
|
|
|
411
|
-
|
|
412
|
-
|
|
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);
|
|
413
654
|
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
655
|
+
for (const combo of combinations) {
|
|
656
|
+
const matchingRules = findMatchingRules(dt, combo);
|
|
657
|
+
|
|
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
|
+
}
|
|
423
668
|
}
|
|
424
669
|
}
|
|
425
670
|
|
|
@@ -560,3 +805,367 @@ export function verifyDecisionTables(dts: DecisionTableDef[]): VerificationResul
|
|
|
560
805
|
errors: allErrors,
|
|
561
806
|
};
|
|
562
807
|
}
|
|
808
|
+
|
|
809
|
+
// ============================================================
|
|
810
|
+
// Co-location Alignment Check
|
|
811
|
+
// ============================================================
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Check that every condition name and output name in a co-located decision table
|
|
815
|
+
* exists as a context field in the machine. When a DT and machine are in the same
|
|
816
|
+
* file, this contract allows action generation to produce fully-wired code.
|
|
817
|
+
*/
|
|
818
|
+
export function checkDTContextAlignment(
|
|
819
|
+
dt: DecisionTableDef,
|
|
820
|
+
machine: MachineDef
|
|
821
|
+
): VerificationError[] {
|
|
822
|
+
const errors: VerificationError[] = [];
|
|
823
|
+
const contextNames = new Set(machine.context.map(f => f.name));
|
|
824
|
+
|
|
825
|
+
for (const cond of dt.conditions) {
|
|
826
|
+
if (!contextNames.has(cond.name)) {
|
|
827
|
+
errors.push({
|
|
828
|
+
code: 'DT_CONTEXT_MISMATCH',
|
|
829
|
+
message: `Decision table '${dt.name}' condition '${cond.name}' has no matching context field in machine '${machine.name}'`,
|
|
830
|
+
severity: 'error',
|
|
831
|
+
location: { decisionTable: dt.name, condition: cond.name },
|
|
832
|
+
suggestion: `Add '${cond.name}' to the ## context section, or rename the condition to match an existing context field`,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
for (const action of dt.actions) {
|
|
838
|
+
if (!contextNames.has(action.name)) {
|
|
839
|
+
errors.push({
|
|
840
|
+
code: 'DT_CONTEXT_MISMATCH',
|
|
841
|
+
message: `Decision table '${dt.name}' output '${action.name}' has no matching context field in machine '${machine.name}'`,
|
|
842
|
+
severity: 'error',
|
|
843
|
+
location: { decisionTable: dt.name, action: action.name },
|
|
844
|
+
suggestion: `Add '${action.name}' to the ## context section, or rename the output to match an existing context field`,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return errors;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* For a file with exactly one machine and one or more decision tables, verify
|
|
854
|
+
* that every DT condition and output name matches a machine context field.
|
|
855
|
+
* Multi-machine files are skipped (ambiguous ownership).
|
|
856
|
+
*/
|
|
857
|
+
export function checkFileContextAlignment(file: OrcaFile): VerificationError[] {
|
|
858
|
+
if (file.machines.length !== 1 || file.decisionTables.length === 0) {
|
|
859
|
+
return [];
|
|
860
|
+
}
|
|
861
|
+
const machine = file.machines[0];
|
|
862
|
+
const errors: VerificationError[] = [];
|
|
863
|
+
for (const dt of file.decisionTables) {
|
|
864
|
+
errors.push(...checkDTContextAlignment(dt, machine));
|
|
865
|
+
}
|
|
866
|
+
return errors;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ============================================================
|
|
870
|
+
// Machine Integration Checks
|
|
871
|
+
// ============================================================
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Get the enumerable values for a machine context field.
|
|
875
|
+
* Returns null for types that cannot be exhaustively enumerated (string, int, decimal).
|
|
876
|
+
* For enum fields, values are stored as a comma-separated defaultValue string.
|
|
877
|
+
*/
|
|
878
|
+
function getMachineFieldValues(field: ContextField): string[] | null {
|
|
879
|
+
if (field.type.kind === 'bool') return ['true', 'false'];
|
|
880
|
+
if (field.type.kind === 'custom' && field.type.name === 'enum') {
|
|
881
|
+
if (!field.defaultValue) return null;
|
|
882
|
+
const vals = field.defaultValue.split(',').map(v => v.trim()).filter(Boolean);
|
|
883
|
+
return vals.length > 0 ? vals : null;
|
|
884
|
+
}
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Generate all combinations of condition values using machine context values as the
|
|
890
|
+
* input domain. Returns null if any condition cannot be enumerated or if the
|
|
891
|
+
* total combinations exceed the safety limit.
|
|
892
|
+
*/
|
|
893
|
+
function generateMachineContextCombinations(
|
|
894
|
+
dt: DecisionTableDef,
|
|
895
|
+
contextMap: Map<string, ContextField>
|
|
896
|
+
): Map<string, string>[] | null {
|
|
897
|
+
const domainPerCondition: string[][] = [];
|
|
898
|
+
|
|
899
|
+
for (const cond of dt.conditions) {
|
|
900
|
+
const field = contextMap.get(cond.name);
|
|
901
|
+
if (!field) return null; // Alignment not met — caller should have checked
|
|
902
|
+
const vals = getMachineFieldValues(field);
|
|
903
|
+
if (!vals) return null; // Non-enumerable type
|
|
904
|
+
domainPerCondition.push(vals);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Safety limit
|
|
908
|
+
let total = 1;
|
|
909
|
+
for (const vals of domainPerCondition) total *= vals.length;
|
|
910
|
+
if (total > 4096) return null;
|
|
911
|
+
|
|
912
|
+
// Cartesian product
|
|
913
|
+
const combos: Map<string, string>[] = [];
|
|
914
|
+
function cartesian(idx: number, current: Map<string, string>): void {
|
|
915
|
+
if (idx === dt.conditions.length) {
|
|
916
|
+
combos.push(new Map(current));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const condName = dt.conditions[idx].name;
|
|
920
|
+
for (const val of domainPerCondition[idx]) {
|
|
921
|
+
current.set(condName, val);
|
|
922
|
+
cartesian(idx + 1, current);
|
|
923
|
+
current.delete(condName);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
cartesian(0, new Map());
|
|
927
|
+
return combos;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* DT_COVERAGE_GAP: Decision table must cover all input combinations the machine
|
|
932
|
+
* context can actually produce. Uses machine enum/bool values as the authoritative
|
|
933
|
+
* domain — stricter than DT_INCOMPLETE which only checks DT-declared values.
|
|
934
|
+
*/
|
|
935
|
+
function checkDTCoverageGap(
|
|
936
|
+
dt: DecisionTableDef,
|
|
937
|
+
contextMap: Map<string, ContextField>
|
|
938
|
+
): VerificationError[] {
|
|
939
|
+
const errors: VerificationError[] = [];
|
|
940
|
+
|
|
941
|
+
const combos = generateMachineContextCombinations(dt, contextMap);
|
|
942
|
+
if (!combos) return errors; // Non-enumerable conditions or too many combinations
|
|
943
|
+
|
|
944
|
+
for (const combo of combos) {
|
|
945
|
+
const matched = dt.rules.some(rule => ruleMatchesInput(rule, dt.conditions, combo));
|
|
946
|
+
if (!matched) {
|
|
947
|
+
const comboDesc = [...combo.entries()].map(([k, v]) => `${k}=${v}`).join(', ');
|
|
948
|
+
errors.push({
|
|
949
|
+
code: 'DT_COVERAGE_GAP',
|
|
950
|
+
message: `Decision table '${dt.name}' has no rule for machine context combination: ${comboDesc}`,
|
|
951
|
+
severity: 'error',
|
|
952
|
+
location: { decisionTable: dt.name },
|
|
953
|
+
suggestion: `Add a rule covering this combination, or add a catch-all row using '-' wildcards`,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return errors;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Recursively collect all equality comparisons from a guard expression.
|
|
963
|
+
* Returns tuples of (fieldName, op, comparedValue) for any `ctx.X op Y` node.
|
|
964
|
+
*/
|
|
965
|
+
function collectFieldComparisons(
|
|
966
|
+
expr: GuardExpression
|
|
967
|
+
): Array<{ field: string; op: ComparisonOp; value: string }> {
|
|
968
|
+
if (expr.kind === 'compare') {
|
|
969
|
+
// Only handle ctx.fieldName comparisons
|
|
970
|
+
if (expr.left.path.length === 2 && expr.left.path[0] === 'ctx') {
|
|
971
|
+
return [{ field: expr.left.path[1], op: expr.op, value: String(expr.right.value) }];
|
|
972
|
+
}
|
|
973
|
+
return [];
|
|
974
|
+
}
|
|
975
|
+
if (expr.kind === 'not') return collectFieldComparisons(expr.expr);
|
|
976
|
+
if (expr.kind === 'and' || expr.kind === 'or') {
|
|
977
|
+
return [...collectFieldComparisons(expr.left), ...collectFieldComparisons(expr.right)];
|
|
978
|
+
}
|
|
979
|
+
return [];
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Compute the set of guard names that test a DT output field against a value
|
|
984
|
+
* the DT never produces. These guards are always false after the DT action fires.
|
|
985
|
+
*/
|
|
986
|
+
function computeDeadGuardNames(dt: DecisionTableDef, machine: MachineDef): Set<string> {
|
|
987
|
+
const outputDomain = new Map<string, Set<string>>();
|
|
988
|
+
for (const action of dt.actions) {
|
|
989
|
+
outputDomain.set(action.name, new Set<string>());
|
|
990
|
+
}
|
|
991
|
+
for (const rule of dt.rules) {
|
|
992
|
+
for (const [name, value] of rule.actions) {
|
|
993
|
+
outputDomain.get(name)?.add(value);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const outputFields = new Set(dt.actions.map(a => a.name));
|
|
998
|
+
const dead = new Set<string>();
|
|
999
|
+
|
|
1000
|
+
for (const guardDef of machine.guards) {
|
|
1001
|
+
const comparisons = collectFieldComparisons(guardDef.expression);
|
|
1002
|
+
for (const { field, op, value } of comparisons) {
|
|
1003
|
+
if (!outputFields.has(field)) continue;
|
|
1004
|
+
if (op !== 'eq') continue;
|
|
1005
|
+
const possible = outputDomain.get(field)!;
|
|
1006
|
+
if (!possible.has(value)) {
|
|
1007
|
+
dead.add(guardDef.name);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return dead;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* DT_GUARD_DEAD: A guard that compares a DT output field against a value the DT
|
|
1017
|
+
* never produces is a dead guard — it can never be true immediately after the
|
|
1018
|
+
* DT action fires. Reported as a warning since another action might set the field.
|
|
1019
|
+
*/
|
|
1020
|
+
function checkDTGuardDead(dt: DecisionTableDef, machine: MachineDef): VerificationError[] {
|
|
1021
|
+
const errors: VerificationError[] = [];
|
|
1022
|
+
|
|
1023
|
+
// Build output domain: field → set of values the DT can produce
|
|
1024
|
+
const outputDomain = new Map<string, Set<string>>();
|
|
1025
|
+
for (const action of dt.actions) {
|
|
1026
|
+
outputDomain.set(action.name, new Set<string>());
|
|
1027
|
+
}
|
|
1028
|
+
for (const rule of dt.rules) {
|
|
1029
|
+
for (const [name, value] of rule.actions) {
|
|
1030
|
+
outputDomain.get(name)?.add(value);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const outputFields = new Set(dt.actions.map(a => a.name));
|
|
1035
|
+
|
|
1036
|
+
for (const guardDef of machine.guards) {
|
|
1037
|
+
const comparisons = collectFieldComparisons(guardDef.expression);
|
|
1038
|
+
for (const { field, op, value } of comparisons) {
|
|
1039
|
+
if (!outputFields.has(field)) continue; // Not a DT output field
|
|
1040
|
+
if (op !== 'eq') continue; // Only equality checks are conclusive
|
|
1041
|
+
|
|
1042
|
+
const possible = outputDomain.get(field)!;
|
|
1043
|
+
if (!possible.has(value)) {
|
|
1044
|
+
const possibleList = [...possible].join(', ') || '(none)';
|
|
1045
|
+
errors.push({
|
|
1046
|
+
code: 'DT_GUARD_DEAD',
|
|
1047
|
+
message: `Guard '${guardDef.name}' tests '${field} == ${value}' but '${dt.name}' never outputs '${value}' for '${field}' (possible: ${possibleList})`,
|
|
1048
|
+
severity: 'warning',
|
|
1049
|
+
location: { decisionTable: dt.name, condition: field },
|
|
1050
|
+
suggestion: `Update '${dt.name}' to produce '${value}' for '${field}', or remove this guard`,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return errors;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* BFS from initial state, optionally skipping transitions guarded by dead guards.
|
|
1061
|
+
* A non-negated transition guarded by a name in `deadGuards` is skipped (never fires).
|
|
1062
|
+
* A negated dead guard (!dead) is NOT skipped — negation of a dead guard is always true.
|
|
1063
|
+
*/
|
|
1064
|
+
function bfsReachableWithDeadGuards(machine: MachineDef, deadGuards: Set<string>): Set<string> {
|
|
1065
|
+
const initial = machine.states.find(s => s.isInitial);
|
|
1066
|
+
if (!initial) return new Set();
|
|
1067
|
+
|
|
1068
|
+
const visited = new Set<string>();
|
|
1069
|
+
const queue = [initial.name];
|
|
1070
|
+
|
|
1071
|
+
while (queue.length > 0) {
|
|
1072
|
+
const state = queue.shift()!;
|
|
1073
|
+
if (visited.has(state)) continue;
|
|
1074
|
+
visited.add(state);
|
|
1075
|
+
|
|
1076
|
+
for (const t of machine.transitions) {
|
|
1077
|
+
if (t.source !== state) continue;
|
|
1078
|
+
// A non-negated dead guard means the transition can never fire
|
|
1079
|
+
if (t.guard && !t.guard.negated && deadGuards.has(t.guard.name)) continue;
|
|
1080
|
+
if (!visited.has(t.target)) queue.push(t.target);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return visited;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* DT_UNREACHABLE_STATE: A state that is graph-reachable but only accessible via
|
|
1089
|
+
* transitions guarded by dead guards — it can never be entered given DT outputs.
|
|
1090
|
+
* Reported as a warning (structural reachability is preserved; the constraint is semantic).
|
|
1091
|
+
*/
|
|
1092
|
+
function checkDTDeadGuardReachability(dt: DecisionTableDef, machine: MachineDef): VerificationError[] {
|
|
1093
|
+
const deadGuards = computeDeadGuardNames(dt, machine);
|
|
1094
|
+
if (deadGuards.size === 0) return [];
|
|
1095
|
+
|
|
1096
|
+
const plainReachable = bfsReachableWithDeadGuards(machine, new Set());
|
|
1097
|
+
const dtReachable = bfsReachableWithDeadGuards(machine, deadGuards);
|
|
1098
|
+
|
|
1099
|
+
const errors: VerificationError[] = [];
|
|
1100
|
+
const deadList = [...deadGuards].join(', ');
|
|
1101
|
+
|
|
1102
|
+
for (const state of machine.states) {
|
|
1103
|
+
if (plainReachable.has(state.name) && !dtReachable.has(state.name)) {
|
|
1104
|
+
errors.push({
|
|
1105
|
+
code: 'DT_UNREACHABLE_STATE',
|
|
1106
|
+
message: `State '${state.name}' is unreachable given '${dt.name}' output constraints — all entry paths are gated by dead guards (${deadList})`,
|
|
1107
|
+
severity: 'warning',
|
|
1108
|
+
location: { state: state.name, decisionTable: dt.name },
|
|
1109
|
+
suggestion: `Update '${dt.name}' to produce values that satisfy the guards leading to '${state.name}', or revise the guard expressions`,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return errors;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Check DT integration with the machine: coverage gap, dead guards, and
|
|
1119
|
+
* DT-constrained reachability. Only runs when exactly one machine is present
|
|
1120
|
+
* and the DT is fully aligned. Multi-machine files are skipped (ambiguous ownership).
|
|
1121
|
+
*/
|
|
1122
|
+
export function checkDTMachineIntegration(file: OrcaFile): VerificationError[] {
|
|
1123
|
+
if (file.machines.length !== 1 || file.decisionTables.length === 0) {
|
|
1124
|
+
return [];
|
|
1125
|
+
}
|
|
1126
|
+
const machine = file.machines[0];
|
|
1127
|
+
const contextMap = new Map(machine.context.map(f => [f.name, f]));
|
|
1128
|
+
const errors: VerificationError[] = [];
|
|
1129
|
+
|
|
1130
|
+
for (const dt of file.decisionTables) {
|
|
1131
|
+
// Only verify DTs that are fully aligned with machine context
|
|
1132
|
+
const allAligned = [...dt.conditions, ...dt.actions].every(item => contextMap.has(item.name));
|
|
1133
|
+
if (!allAligned) continue;
|
|
1134
|
+
|
|
1135
|
+
errors.push(...checkDTCoverageGap(dt, contextMap));
|
|
1136
|
+
errors.push(...checkDTGuardDead(dt, machine));
|
|
1137
|
+
errors.push(...checkDTDeadGuardReachability(dt, machine));
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
return errors;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Compute the merged output domain across all aligned DTs in a single-machine file.
|
|
1145
|
+
* Returns a map from DT output field name → set of values the DT(s) can produce.
|
|
1146
|
+
* Used by the properties checker to prune guard-protected transitions that are
|
|
1147
|
+
* semantically impossible given DT output constraints.
|
|
1148
|
+
* Returns undefined if no aligned DTs are found.
|
|
1149
|
+
*/
|
|
1150
|
+
export function computeAlignedDTOutputDomain(file: OrcaFile): Map<string, Set<string>> | undefined {
|
|
1151
|
+
if (file.machines.length !== 1 || file.decisionTables.length === 0) return undefined;
|
|
1152
|
+
const machine = file.machines[0];
|
|
1153
|
+
const contextMap = new Map(machine.context.map(f => [f.name, f]));
|
|
1154
|
+
|
|
1155
|
+
const domain = new Map<string, Set<string>>();
|
|
1156
|
+
|
|
1157
|
+
for (const dt of file.decisionTables) {
|
|
1158
|
+
const allAligned = [...dt.conditions, ...dt.actions].every(item => contextMap.has(item.name));
|
|
1159
|
+
if (!allAligned) continue;
|
|
1160
|
+
|
|
1161
|
+
for (const actionDef of dt.actions) {
|
|
1162
|
+
if (!domain.has(actionDef.name)) domain.set(actionDef.name, new Set());
|
|
1163
|
+
for (const rule of dt.rules) {
|
|
1164
|
+
const val = rule.actions.get(actionDef.name);
|
|
1165
|
+
if (val !== undefined) domain.get(actionDef.name)!.add(val);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return domain.size > 0 ? domain : undefined;
|
|
1171
|
+
}
|