@orcalang/orca-lang 0.1.19 → 0.1.21
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 +3 -0
- package/dist/compiler/dt-compiler.d.ts.map +1 -1
- package/dist/compiler/dt-compiler.js +205 -1
- package/dist/compiler/dt-compiler.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/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 +1 -0
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +287 -23
- package/dist/skills.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 +332 -1
- 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 +223 -1
- 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/markdown-parser.ts +11 -3
- package/src/skills.ts +319 -23
- package/src/verifier/dt-verifier.ts +367 -1
- 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
|
|
@@ -560,3 +562,367 @@ export function verifyDecisionTables(dts: DecisionTableDef[]): VerificationResul
|
|
|
560
562
|
errors: allErrors,
|
|
561
563
|
};
|
|
562
564
|
}
|
|
565
|
+
|
|
566
|
+
// ============================================================
|
|
567
|
+
// Co-location Alignment Check
|
|
568
|
+
// ============================================================
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Check that every condition name and output name in a co-located decision table
|
|
572
|
+
* exists as a context field in the machine. When a DT and machine are in the same
|
|
573
|
+
* file, this contract allows action generation to produce fully-wired code.
|
|
574
|
+
*/
|
|
575
|
+
export function checkDTContextAlignment(
|
|
576
|
+
dt: DecisionTableDef,
|
|
577
|
+
machine: MachineDef
|
|
578
|
+
): VerificationError[] {
|
|
579
|
+
const errors: VerificationError[] = [];
|
|
580
|
+
const contextNames = new Set(machine.context.map(f => f.name));
|
|
581
|
+
|
|
582
|
+
for (const cond of dt.conditions) {
|
|
583
|
+
if (!contextNames.has(cond.name)) {
|
|
584
|
+
errors.push({
|
|
585
|
+
code: 'DT_CONTEXT_MISMATCH',
|
|
586
|
+
message: `Decision table '${dt.name}' condition '${cond.name}' has no matching context field in machine '${machine.name}'`,
|
|
587
|
+
severity: 'error',
|
|
588
|
+
location: { decisionTable: dt.name, condition: cond.name },
|
|
589
|
+
suggestion: `Add '${cond.name}' to the ## context section, or rename the condition to match an existing context field`,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
for (const action of dt.actions) {
|
|
595
|
+
if (!contextNames.has(action.name)) {
|
|
596
|
+
errors.push({
|
|
597
|
+
code: 'DT_CONTEXT_MISMATCH',
|
|
598
|
+
message: `Decision table '${dt.name}' output '${action.name}' has no matching context field in machine '${machine.name}'`,
|
|
599
|
+
severity: 'error',
|
|
600
|
+
location: { decisionTable: dt.name, action: action.name },
|
|
601
|
+
suggestion: `Add '${action.name}' to the ## context section, or rename the output to match an existing context field`,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return errors;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* For a file with exactly one machine and one or more decision tables, verify
|
|
611
|
+
* that every DT condition and output name matches a machine context field.
|
|
612
|
+
* Multi-machine files are skipped (ambiguous ownership).
|
|
613
|
+
*/
|
|
614
|
+
export function checkFileContextAlignment(file: OrcaFile): VerificationError[] {
|
|
615
|
+
if (file.machines.length !== 1 || file.decisionTables.length === 0) {
|
|
616
|
+
return [];
|
|
617
|
+
}
|
|
618
|
+
const machine = file.machines[0];
|
|
619
|
+
const errors: VerificationError[] = [];
|
|
620
|
+
for (const dt of file.decisionTables) {
|
|
621
|
+
errors.push(...checkDTContextAlignment(dt, machine));
|
|
622
|
+
}
|
|
623
|
+
return errors;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ============================================================
|
|
627
|
+
// Machine Integration Checks
|
|
628
|
+
// ============================================================
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Get the enumerable values for a machine context field.
|
|
632
|
+
* Returns null for types that cannot be exhaustively enumerated (string, int, decimal).
|
|
633
|
+
* For enum fields, values are stored as a comma-separated defaultValue string.
|
|
634
|
+
*/
|
|
635
|
+
function getMachineFieldValues(field: ContextField): string[] | null {
|
|
636
|
+
if (field.type.kind === 'bool') return ['true', 'false'];
|
|
637
|
+
if (field.type.kind === 'custom' && field.type.name === 'enum') {
|
|
638
|
+
if (!field.defaultValue) return null;
|
|
639
|
+
const vals = field.defaultValue.split(',').map(v => v.trim()).filter(Boolean);
|
|
640
|
+
return vals.length > 0 ? vals : null;
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Generate all combinations of condition values using machine context values as the
|
|
647
|
+
* input domain. Returns null if any condition cannot be enumerated or if the
|
|
648
|
+
* total combinations exceed the safety limit.
|
|
649
|
+
*/
|
|
650
|
+
function generateMachineContextCombinations(
|
|
651
|
+
dt: DecisionTableDef,
|
|
652
|
+
contextMap: Map<string, ContextField>
|
|
653
|
+
): Map<string, string>[] | null {
|
|
654
|
+
const domainPerCondition: string[][] = [];
|
|
655
|
+
|
|
656
|
+
for (const cond of dt.conditions) {
|
|
657
|
+
const field = contextMap.get(cond.name);
|
|
658
|
+
if (!field) return null; // Alignment not met — caller should have checked
|
|
659
|
+
const vals = getMachineFieldValues(field);
|
|
660
|
+
if (!vals) return null; // Non-enumerable type
|
|
661
|
+
domainPerCondition.push(vals);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Safety limit
|
|
665
|
+
let total = 1;
|
|
666
|
+
for (const vals of domainPerCondition) total *= vals.length;
|
|
667
|
+
if (total > 4096) return null;
|
|
668
|
+
|
|
669
|
+
// Cartesian product
|
|
670
|
+
const combos: Map<string, string>[] = [];
|
|
671
|
+
function cartesian(idx: number, current: Map<string, string>): void {
|
|
672
|
+
if (idx === dt.conditions.length) {
|
|
673
|
+
combos.push(new Map(current));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const condName = dt.conditions[idx].name;
|
|
677
|
+
for (const val of domainPerCondition[idx]) {
|
|
678
|
+
current.set(condName, val);
|
|
679
|
+
cartesian(idx + 1, current);
|
|
680
|
+
current.delete(condName);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
cartesian(0, new Map());
|
|
684
|
+
return combos;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* DT_COVERAGE_GAP: Decision table must cover all input combinations the machine
|
|
689
|
+
* context can actually produce. Uses machine enum/bool values as the authoritative
|
|
690
|
+
* domain — stricter than DT_INCOMPLETE which only checks DT-declared values.
|
|
691
|
+
*/
|
|
692
|
+
function checkDTCoverageGap(
|
|
693
|
+
dt: DecisionTableDef,
|
|
694
|
+
contextMap: Map<string, ContextField>
|
|
695
|
+
): VerificationError[] {
|
|
696
|
+
const errors: VerificationError[] = [];
|
|
697
|
+
|
|
698
|
+
const combos = generateMachineContextCombinations(dt, contextMap);
|
|
699
|
+
if (!combos) return errors; // Non-enumerable conditions or too many combinations
|
|
700
|
+
|
|
701
|
+
for (const combo of combos) {
|
|
702
|
+
const matched = dt.rules.some(rule => ruleMatchesInput(rule, dt.conditions, combo));
|
|
703
|
+
if (!matched) {
|
|
704
|
+
const comboDesc = [...combo.entries()].map(([k, v]) => `${k}=${v}`).join(', ');
|
|
705
|
+
errors.push({
|
|
706
|
+
code: 'DT_COVERAGE_GAP',
|
|
707
|
+
message: `Decision table '${dt.name}' has no rule for machine context combination: ${comboDesc}`,
|
|
708
|
+
severity: 'error',
|
|
709
|
+
location: { decisionTable: dt.name },
|
|
710
|
+
suggestion: `Add a rule covering this combination, or add a catch-all row using '-' wildcards`,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return errors;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Recursively collect all equality comparisons from a guard expression.
|
|
720
|
+
* Returns tuples of (fieldName, op, comparedValue) for any `ctx.X op Y` node.
|
|
721
|
+
*/
|
|
722
|
+
function collectFieldComparisons(
|
|
723
|
+
expr: GuardExpression
|
|
724
|
+
): Array<{ field: string; op: ComparisonOp; value: string }> {
|
|
725
|
+
if (expr.kind === 'compare') {
|
|
726
|
+
// Only handle ctx.fieldName comparisons
|
|
727
|
+
if (expr.left.path.length === 2 && expr.left.path[0] === 'ctx') {
|
|
728
|
+
return [{ field: expr.left.path[1], op: expr.op, value: String(expr.right.value) }];
|
|
729
|
+
}
|
|
730
|
+
return [];
|
|
731
|
+
}
|
|
732
|
+
if (expr.kind === 'not') return collectFieldComparisons(expr.expr);
|
|
733
|
+
if (expr.kind === 'and' || expr.kind === 'or') {
|
|
734
|
+
return [...collectFieldComparisons(expr.left), ...collectFieldComparisons(expr.right)];
|
|
735
|
+
}
|
|
736
|
+
return [];
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Compute the set of guard names that test a DT output field against a value
|
|
741
|
+
* the DT never produces. These guards are always false after the DT action fires.
|
|
742
|
+
*/
|
|
743
|
+
function computeDeadGuardNames(dt: DecisionTableDef, machine: MachineDef): Set<string> {
|
|
744
|
+
const outputDomain = new Map<string, Set<string>>();
|
|
745
|
+
for (const action of dt.actions) {
|
|
746
|
+
outputDomain.set(action.name, new Set<string>());
|
|
747
|
+
}
|
|
748
|
+
for (const rule of dt.rules) {
|
|
749
|
+
for (const [name, value] of rule.actions) {
|
|
750
|
+
outputDomain.get(name)?.add(value);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const outputFields = new Set(dt.actions.map(a => a.name));
|
|
755
|
+
const dead = new Set<string>();
|
|
756
|
+
|
|
757
|
+
for (const guardDef of machine.guards) {
|
|
758
|
+
const comparisons = collectFieldComparisons(guardDef.expression);
|
|
759
|
+
for (const { field, op, value } of comparisons) {
|
|
760
|
+
if (!outputFields.has(field)) continue;
|
|
761
|
+
if (op !== 'eq') continue;
|
|
762
|
+
const possible = outputDomain.get(field)!;
|
|
763
|
+
if (!possible.has(value)) {
|
|
764
|
+
dead.add(guardDef.name);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return dead;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* DT_GUARD_DEAD: A guard that compares a DT output field against a value the DT
|
|
774
|
+
* never produces is a dead guard — it can never be true immediately after the
|
|
775
|
+
* DT action fires. Reported as a warning since another action might set the field.
|
|
776
|
+
*/
|
|
777
|
+
function checkDTGuardDead(dt: DecisionTableDef, machine: MachineDef): VerificationError[] {
|
|
778
|
+
const errors: VerificationError[] = [];
|
|
779
|
+
|
|
780
|
+
// Build output domain: field → set of values the DT can produce
|
|
781
|
+
const outputDomain = new Map<string, Set<string>>();
|
|
782
|
+
for (const action of dt.actions) {
|
|
783
|
+
outputDomain.set(action.name, new Set<string>());
|
|
784
|
+
}
|
|
785
|
+
for (const rule of dt.rules) {
|
|
786
|
+
for (const [name, value] of rule.actions) {
|
|
787
|
+
outputDomain.get(name)?.add(value);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const outputFields = new Set(dt.actions.map(a => a.name));
|
|
792
|
+
|
|
793
|
+
for (const guardDef of machine.guards) {
|
|
794
|
+
const comparisons = collectFieldComparisons(guardDef.expression);
|
|
795
|
+
for (const { field, op, value } of comparisons) {
|
|
796
|
+
if (!outputFields.has(field)) continue; // Not a DT output field
|
|
797
|
+
if (op !== 'eq') continue; // Only equality checks are conclusive
|
|
798
|
+
|
|
799
|
+
const possible = outputDomain.get(field)!;
|
|
800
|
+
if (!possible.has(value)) {
|
|
801
|
+
const possibleList = [...possible].join(', ') || '(none)';
|
|
802
|
+
errors.push({
|
|
803
|
+
code: 'DT_GUARD_DEAD',
|
|
804
|
+
message: `Guard '${guardDef.name}' tests '${field} == ${value}' but '${dt.name}' never outputs '${value}' for '${field}' (possible: ${possibleList})`,
|
|
805
|
+
severity: 'warning',
|
|
806
|
+
location: { decisionTable: dt.name, condition: field },
|
|
807
|
+
suggestion: `Update '${dt.name}' to produce '${value}' for '${field}', or remove this guard`,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return errors;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* BFS from initial state, optionally skipping transitions guarded by dead guards.
|
|
818
|
+
* A non-negated transition guarded by a name in `deadGuards` is skipped (never fires).
|
|
819
|
+
* A negated dead guard (!dead) is NOT skipped — negation of a dead guard is always true.
|
|
820
|
+
*/
|
|
821
|
+
function bfsReachableWithDeadGuards(machine: MachineDef, deadGuards: Set<string>): Set<string> {
|
|
822
|
+
const initial = machine.states.find(s => s.isInitial);
|
|
823
|
+
if (!initial) return new Set();
|
|
824
|
+
|
|
825
|
+
const visited = new Set<string>();
|
|
826
|
+
const queue = [initial.name];
|
|
827
|
+
|
|
828
|
+
while (queue.length > 0) {
|
|
829
|
+
const state = queue.shift()!;
|
|
830
|
+
if (visited.has(state)) continue;
|
|
831
|
+
visited.add(state);
|
|
832
|
+
|
|
833
|
+
for (const t of machine.transitions) {
|
|
834
|
+
if (t.source !== state) continue;
|
|
835
|
+
// A non-negated dead guard means the transition can never fire
|
|
836
|
+
if (t.guard && !t.guard.negated && deadGuards.has(t.guard.name)) continue;
|
|
837
|
+
if (!visited.has(t.target)) queue.push(t.target);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return visited;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* DT_UNREACHABLE_STATE: A state that is graph-reachable but only accessible via
|
|
846
|
+
* transitions guarded by dead guards — it can never be entered given DT outputs.
|
|
847
|
+
* Reported as a warning (structural reachability is preserved; the constraint is semantic).
|
|
848
|
+
*/
|
|
849
|
+
function checkDTDeadGuardReachability(dt: DecisionTableDef, machine: MachineDef): VerificationError[] {
|
|
850
|
+
const deadGuards = computeDeadGuardNames(dt, machine);
|
|
851
|
+
if (deadGuards.size === 0) return [];
|
|
852
|
+
|
|
853
|
+
const plainReachable = bfsReachableWithDeadGuards(machine, new Set());
|
|
854
|
+
const dtReachable = bfsReachableWithDeadGuards(machine, deadGuards);
|
|
855
|
+
|
|
856
|
+
const errors: VerificationError[] = [];
|
|
857
|
+
const deadList = [...deadGuards].join(', ');
|
|
858
|
+
|
|
859
|
+
for (const state of machine.states) {
|
|
860
|
+
if (plainReachable.has(state.name) && !dtReachable.has(state.name)) {
|
|
861
|
+
errors.push({
|
|
862
|
+
code: 'DT_UNREACHABLE_STATE',
|
|
863
|
+
message: `State '${state.name}' is unreachable given '${dt.name}' output constraints — all entry paths are gated by dead guards (${deadList})`,
|
|
864
|
+
severity: 'warning',
|
|
865
|
+
location: { state: state.name, decisionTable: dt.name },
|
|
866
|
+
suggestion: `Update '${dt.name}' to produce values that satisfy the guards leading to '${state.name}', or revise the guard expressions`,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return errors;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Check DT integration with the machine: coverage gap, dead guards, and
|
|
876
|
+
* DT-constrained reachability. Only runs when exactly one machine is present
|
|
877
|
+
* and the DT is fully aligned. Multi-machine files are skipped (ambiguous ownership).
|
|
878
|
+
*/
|
|
879
|
+
export function checkDTMachineIntegration(file: OrcaFile): VerificationError[] {
|
|
880
|
+
if (file.machines.length !== 1 || file.decisionTables.length === 0) {
|
|
881
|
+
return [];
|
|
882
|
+
}
|
|
883
|
+
const machine = file.machines[0];
|
|
884
|
+
const contextMap = new Map(machine.context.map(f => [f.name, f]));
|
|
885
|
+
const errors: VerificationError[] = [];
|
|
886
|
+
|
|
887
|
+
for (const dt of file.decisionTables) {
|
|
888
|
+
// Only verify DTs that are fully aligned with machine context
|
|
889
|
+
const allAligned = [...dt.conditions, ...dt.actions].every(item => contextMap.has(item.name));
|
|
890
|
+
if (!allAligned) continue;
|
|
891
|
+
|
|
892
|
+
errors.push(...checkDTCoverageGap(dt, contextMap));
|
|
893
|
+
errors.push(...checkDTGuardDead(dt, machine));
|
|
894
|
+
errors.push(...checkDTDeadGuardReachability(dt, machine));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return errors;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Compute the merged output domain across all aligned DTs in a single-machine file.
|
|
902
|
+
* Returns a map from DT output field name → set of values the DT(s) can produce.
|
|
903
|
+
* Used by the properties checker to prune guard-protected transitions that are
|
|
904
|
+
* semantically impossible given DT output constraints.
|
|
905
|
+
* Returns undefined if no aligned DTs are found.
|
|
906
|
+
*/
|
|
907
|
+
export function computeAlignedDTOutputDomain(file: OrcaFile): Map<string, Set<string>> | undefined {
|
|
908
|
+
if (file.machines.length !== 1 || file.decisionTables.length === 0) return undefined;
|
|
909
|
+
const machine = file.machines[0];
|
|
910
|
+
const contextMap = new Map(machine.context.map(f => [f.name, f]));
|
|
911
|
+
|
|
912
|
+
const domain = new Map<string, Set<string>>();
|
|
913
|
+
|
|
914
|
+
for (const dt of file.decisionTables) {
|
|
915
|
+
const allAligned = [...dt.conditions, ...dt.actions].every(item => contextMap.has(item.name));
|
|
916
|
+
if (!allAligned) continue;
|
|
917
|
+
|
|
918
|
+
for (const actionDef of dt.actions) {
|
|
919
|
+
if (!domain.has(actionDef.name)) domain.set(actionDef.name, new Set());
|
|
920
|
+
for (const rule of dt.rules) {
|
|
921
|
+
const val = rule.actions.get(actionDef.name);
|
|
922
|
+
if (val !== undefined) domain.get(actionDef.name)!.add(val);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
return domain.size > 0 ? domain : undefined;
|
|
928
|
+
}
|
|
@@ -1,29 +1,66 @@
|
|
|
1
|
-
import { MachineDef, Property, ReachabilityProperty, PassesThroughProperty, RespondsProperty, InvariantProperty, GuardRef, GuardDef } from '../parser/ast.js';
|
|
1
|
+
import { MachineDef, Property, ReachabilityProperty, PassesThroughProperty, RespondsProperty, InvariantProperty, GuardRef, GuardDef, GuardExpression } from '../parser/ast.js';
|
|
2
2
|
import { VerificationResult, VerificationError, MachineAnalysis, StateInfo } from './types.js';
|
|
3
3
|
import { analyzeMachine, flattenStates, FlattenedState } from './structural.js';
|
|
4
4
|
import { resolveGuardExpression, isExpressionStaticallyFalse } from './determinism.js';
|
|
5
5
|
|
|
6
6
|
const DEFAULT_MAX_STATES = 64;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Check if a guard expression is statically false given DT output domain constraints.
|
|
10
|
+
* A comparison `ctx.field == value` is false if the DT never outputs `value` for `field`.
|
|
11
|
+
* Handles AND (false if either branch is domain-blocked) and OR (false only if both are).
|
|
12
|
+
*/
|
|
13
|
+
function isExpressionBlockedByDomain(
|
|
14
|
+
expr: GuardExpression,
|
|
15
|
+
domain: Map<string, Set<string>>
|
|
16
|
+
): boolean {
|
|
17
|
+
if (expr.kind === 'compare' && expr.op === 'eq') {
|
|
18
|
+
if (expr.left.path.length === 2 && expr.left.path[0] === 'ctx') {
|
|
19
|
+
const field = expr.left.path[1];
|
|
20
|
+
const value = String(expr.right.value);
|
|
21
|
+
const possible = domain.get(field);
|
|
22
|
+
if (possible !== undefined && !possible.has(value)) return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (expr.kind === 'and') {
|
|
26
|
+
return isExpressionBlockedByDomain(expr.left, domain) ||
|
|
27
|
+
isExpressionBlockedByDomain(expr.right, domain);
|
|
28
|
+
}
|
|
29
|
+
if (expr.kind === 'or') {
|
|
30
|
+
return isExpressionBlockedByDomain(expr.left, domain) &&
|
|
31
|
+
isExpressionBlockedByDomain(expr.right, domain);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
8
36
|
/**
|
|
9
37
|
* Check if a transition's guard is statically false (can never fire).
|
|
10
38
|
* Used in guard-aware BFS to prune impossible transitions.
|
|
39
|
+
* When dtOutputDomain is provided, also prunes guards that compare DT output
|
|
40
|
+
* fields against values the DT never produces.
|
|
11
41
|
*/
|
|
12
42
|
function isTransitionStaticallyBlocked(
|
|
13
43
|
transition: { guard?: GuardRef },
|
|
14
|
-
guardDefs: Map<string, GuardDef
|
|
44
|
+
guardDefs: Map<string, GuardDef>,
|
|
45
|
+
dtOutputDomain?: Map<string, Set<string>>
|
|
15
46
|
): boolean {
|
|
16
47
|
if (!transition.guard) return false; // No guard = always possible
|
|
17
48
|
|
|
18
49
|
const resolved = resolveGuardExpression(transition.guard, guardDefs);
|
|
19
50
|
if (!resolved) return false; // Can't resolve = assume possible
|
|
20
51
|
|
|
21
|
-
|
|
52
|
+
if (isExpressionStaticallyFalse(resolved)) return true;
|
|
53
|
+
|
|
54
|
+
if (dtOutputDomain && isExpressionBlockedByDomain(resolved, dtOutputDomain)) return true;
|
|
55
|
+
|
|
56
|
+
return false;
|
|
22
57
|
}
|
|
23
58
|
|
|
24
59
|
/**
|
|
25
60
|
* BFS from source state, returning reachable set and parent map for counterexample traces.
|
|
26
61
|
* Supports guard-aware mode: skips transitions with statically false guards.
|
|
62
|
+
* When dtOutputDomain is provided, also skips transitions whose guards are impossible
|
|
63
|
+
* given the DT output domain constraints.
|
|
27
64
|
*/
|
|
28
65
|
function bfs(
|
|
29
66
|
stateMap: Map<string, StateInfo>,
|
|
@@ -31,7 +68,8 @@ function bfs(
|
|
|
31
68
|
options?: {
|
|
32
69
|
excludeState?: string;
|
|
33
70
|
maxDepth?: number;
|
|
34
|
-
guardDefs?: Map<string, GuardDef>;
|
|
71
|
+
guardDefs?: Map<string, GuardDef>;
|
|
72
|
+
dtOutputDomain?: Map<string, Set<string>>;
|
|
35
73
|
}
|
|
36
74
|
): { reachable: Set<string>; parent: Map<string, { state: string; event: string; guard?: string }> } {
|
|
37
75
|
const reachable = new Set<string>();
|
|
@@ -56,8 +94,8 @@ function bfs(
|
|
|
56
94
|
// Skip excluded state
|
|
57
95
|
if (options?.excludeState && target === options.excludeState) continue;
|
|
58
96
|
|
|
59
|
-
// Guard-aware: skip transitions with statically false guards
|
|
60
|
-
if (options?.guardDefs && isTransitionStaticallyBlocked(t, options.guardDefs)) continue;
|
|
97
|
+
// Guard-aware: skip transitions with statically false guards (including DT domain constraints)
|
|
98
|
+
if (options?.guardDefs && isTransitionStaticallyBlocked(t, options.guardDefs, options.dtOutputDomain)) continue;
|
|
61
99
|
|
|
62
100
|
if (!reachable.has(target)) {
|
|
63
101
|
reachable.add(target);
|
|
@@ -157,7 +195,8 @@ function checkReachable(
|
|
|
157
195
|
prop: ReachabilityProperty,
|
|
158
196
|
analysis: MachineAnalysis,
|
|
159
197
|
flattenedStates: FlattenedState[],
|
|
160
|
-
guardDefs: Map<string, GuardDef
|
|
198
|
+
guardDefs: Map<string, GuardDef>,
|
|
199
|
+
dtOutputDomain?: Map<string, Set<string>>
|
|
161
200
|
): VerificationError[] {
|
|
162
201
|
const errors: VerificationError[] = [];
|
|
163
202
|
|
|
@@ -166,7 +205,7 @@ function checkReachable(
|
|
|
166
205
|
const toRes = resolveStateName(prop.to, flattenedStates);
|
|
167
206
|
if (toRes.error) return [toRes.error];
|
|
168
207
|
|
|
169
|
-
const { reachable } = bfs(analysis.stateMap, fromRes.resolved, { guardDefs });
|
|
208
|
+
const { reachable } = bfs(analysis.stateMap, fromRes.resolved, { guardDefs, dtOutputDomain });
|
|
170
209
|
|
|
171
210
|
if (!reachable.has(toRes.resolved)) {
|
|
172
211
|
errors.push({
|
|
@@ -185,7 +224,8 @@ function checkUnreachable(
|
|
|
185
224
|
prop: ReachabilityProperty,
|
|
186
225
|
analysis: MachineAnalysis,
|
|
187
226
|
flattenedStates: FlattenedState[],
|
|
188
|
-
guardDefs: Map<string, GuardDef
|
|
227
|
+
guardDefs: Map<string, GuardDef>,
|
|
228
|
+
dtOutputDomain?: Map<string, Set<string>>
|
|
189
229
|
): VerificationError[] {
|
|
190
230
|
const errors: VerificationError[] = [];
|
|
191
231
|
|
|
@@ -194,7 +234,7 @@ function checkUnreachable(
|
|
|
194
234
|
const toRes = resolveStateName(prop.to, flattenedStates);
|
|
195
235
|
if (toRes.error) return [toRes.error];
|
|
196
236
|
|
|
197
|
-
const { reachable, parent } = bfs(analysis.stateMap, fromRes.resolved, { guardDefs });
|
|
237
|
+
const { reachable, parent } = bfs(analysis.stateMap, fromRes.resolved, { guardDefs, dtOutputDomain });
|
|
198
238
|
|
|
199
239
|
if (reachable.has(toRes.resolved)) {
|
|
200
240
|
const path = reconstructPath(parent, fromRes.resolved, toRes.resolved);
|
|
@@ -218,7 +258,8 @@ function checkPassesThrough(
|
|
|
218
258
|
prop: PassesThroughProperty,
|
|
219
259
|
analysis: MachineAnalysis,
|
|
220
260
|
flattenedStates: FlattenedState[],
|
|
221
|
-
guardDefs: Map<string, GuardDef
|
|
261
|
+
guardDefs: Map<string, GuardDef>,
|
|
262
|
+
dtOutputDomain?: Map<string, Set<string>>
|
|
222
263
|
): VerificationError[] {
|
|
223
264
|
const errors: VerificationError[] = [];
|
|
224
265
|
|
|
@@ -230,7 +271,7 @@ function checkPassesThrough(
|
|
|
230
271
|
if (throughRes.error) return [throughRes.error];
|
|
231
272
|
|
|
232
273
|
// First check: is target reachable from source at all?
|
|
233
|
-
const { reachable: fullReachable } = bfs(analysis.stateMap, fromRes.resolved, { guardDefs });
|
|
274
|
+
const { reachable: fullReachable } = bfs(analysis.stateMap, fromRes.resolved, { guardDefs, dtOutputDomain });
|
|
234
275
|
if (!fullReachable.has(toRes.resolved)) {
|
|
235
276
|
errors.push({
|
|
236
277
|
code: 'PROPERTY_PATH_FAIL',
|
|
@@ -246,6 +287,7 @@ function checkPassesThrough(
|
|
|
246
287
|
const { reachable: withoutThrough, parent } = bfs(analysis.stateMap, fromRes.resolved, {
|
|
247
288
|
excludeState: throughRes.resolved,
|
|
248
289
|
guardDefs,
|
|
290
|
+
dtOutputDomain,
|
|
249
291
|
});
|
|
250
292
|
|
|
251
293
|
if (withoutThrough.has(toRes.resolved)) {
|
|
@@ -265,14 +307,15 @@ function checkPassesThrough(
|
|
|
265
307
|
function checkLive(
|
|
266
308
|
analysis: MachineAnalysis,
|
|
267
309
|
flattenedStates: FlattenedState[],
|
|
268
|
-
guardDefs: Map<string, GuardDef
|
|
310
|
+
guardDefs: Map<string, GuardDef>,
|
|
311
|
+
dtOutputDomain?: Map<string, Set<string>>
|
|
269
312
|
): VerificationError[] {
|
|
270
313
|
const errors: VerificationError[] = [];
|
|
271
314
|
|
|
272
315
|
if (!analysis.initialState) return errors;
|
|
273
316
|
|
|
274
317
|
// Find all reachable states from initial
|
|
275
|
-
const { reachable: reachableFromInitial } = bfs(analysis.stateMap, analysis.initialState.name, { guardDefs });
|
|
318
|
+
const { reachable: reachableFromInitial } = bfs(analysis.stateMap, analysis.initialState.name, { guardDefs, dtOutputDomain });
|
|
276
319
|
|
|
277
320
|
// Find all final state names
|
|
278
321
|
const finalStateNames = new Set(analysis.finalStates.map(s => s.name));
|
|
@@ -285,7 +328,7 @@ function checkLive(
|
|
|
285
328
|
const fs = flattenedStates.find(f => f.name === stateName);
|
|
286
329
|
if (fs && (fs.isCompound || fs.isRegion)) continue;
|
|
287
330
|
|
|
288
|
-
const { reachable: reachableFromState } = bfs(analysis.stateMap, stateName, { guardDefs });
|
|
331
|
+
const { reachable: reachableFromState } = bfs(analysis.stateMap, stateName, { guardDefs, dtOutputDomain });
|
|
289
332
|
|
|
290
333
|
let canReachFinal = false;
|
|
291
334
|
for (const finalName of finalStateNames) {
|
|
@@ -313,7 +356,8 @@ function checkResponds(
|
|
|
313
356
|
prop: RespondsProperty,
|
|
314
357
|
analysis: MachineAnalysis,
|
|
315
358
|
flattenedStates: FlattenedState[],
|
|
316
|
-
guardDefs: Map<string, GuardDef
|
|
359
|
+
guardDefs: Map<string, GuardDef>,
|
|
360
|
+
dtOutputDomain?: Map<string, Set<string>>
|
|
317
361
|
): VerificationError[] {
|
|
318
362
|
const errors: VerificationError[] = [];
|
|
319
363
|
|
|
@@ -325,11 +369,12 @@ function checkResponds(
|
|
|
325
369
|
const { reachable } = bfs(analysis.stateMap, fromRes.resolved, {
|
|
326
370
|
maxDepth: prop.within,
|
|
327
371
|
guardDefs,
|
|
372
|
+
dtOutputDomain,
|
|
328
373
|
});
|
|
329
374
|
|
|
330
375
|
if (!reachable.has(toRes.resolved)) {
|
|
331
376
|
// Check if it's reachable at all (just beyond the bound)
|
|
332
|
-
const { reachable: unbounded } = bfs(analysis.stateMap, fromRes.resolved, { guardDefs });
|
|
377
|
+
const { reachable: unbounded } = bfs(analysis.stateMap, fromRes.resolved, { guardDefs, dtOutputDomain });
|
|
333
378
|
const reachableButBeyondBound = unbounded.has(toRes.resolved);
|
|
334
379
|
|
|
335
380
|
const suffix = reachableButBeyondBound
|
|
@@ -453,7 +498,16 @@ function checkMachineSize(
|
|
|
453
498
|
|
|
454
499
|
// --- Main entry point ---
|
|
455
500
|
|
|
456
|
-
export function checkProperties(
|
|
501
|
+
export function checkProperties(
|
|
502
|
+
machine: MachineDef,
|
|
503
|
+
options?: {
|
|
504
|
+
maxStates?: number;
|
|
505
|
+
/** DT output domain from co-located aligned decision tables. When provided,
|
|
506
|
+
* the guard-aware BFS will prune transitions whose guards compare DT output
|
|
507
|
+
* fields against values the DT never produces, giving more precise results. */
|
|
508
|
+
dtOutputDomain?: Map<string, Set<string>>;
|
|
509
|
+
}
|
|
510
|
+
): VerificationResult {
|
|
457
511
|
const maxStates = options?.maxStates ?? DEFAULT_MAX_STATES;
|
|
458
512
|
const flattenedStates = flattenStates(machine.states);
|
|
459
513
|
const errors: VerificationError[] = [];
|
|
@@ -470,6 +524,7 @@ export function checkProperties(machine: MachineDef, options?: { maxStates?: num
|
|
|
470
524
|
}
|
|
471
525
|
|
|
472
526
|
const analysis = analyzeMachine(machine);
|
|
527
|
+
const dtOutputDomain = options?.dtOutputDomain;
|
|
473
528
|
|
|
474
529
|
// Build guard definition map for guard-aware BFS
|
|
475
530
|
const guardDefs = new Map<string, GuardDef>();
|
|
@@ -480,19 +535,19 @@ export function checkProperties(machine: MachineDef, options?: { maxStates?: num
|
|
|
480
535
|
for (const prop of machine.properties) {
|
|
481
536
|
switch (prop.kind) {
|
|
482
537
|
case 'reachable':
|
|
483
|
-
errors.push(...checkReachable(prop, analysis, flattenedStates, guardDefs));
|
|
538
|
+
errors.push(...checkReachable(prop, analysis, flattenedStates, guardDefs, dtOutputDomain));
|
|
484
539
|
break;
|
|
485
540
|
case 'unreachable':
|
|
486
|
-
errors.push(...checkUnreachable(prop, analysis, flattenedStates, guardDefs));
|
|
541
|
+
errors.push(...checkUnreachable(prop, analysis, flattenedStates, guardDefs, dtOutputDomain));
|
|
487
542
|
break;
|
|
488
543
|
case 'passes_through':
|
|
489
|
-
errors.push(...checkPassesThrough(prop, analysis, flattenedStates, guardDefs));
|
|
544
|
+
errors.push(...checkPassesThrough(prop, analysis, flattenedStates, guardDefs, dtOutputDomain));
|
|
490
545
|
break;
|
|
491
546
|
case 'live':
|
|
492
|
-
errors.push(...checkLive(analysis, flattenedStates, guardDefs));
|
|
547
|
+
errors.push(...checkLive(analysis, flattenedStates, guardDefs, dtOutputDomain));
|
|
493
548
|
break;
|
|
494
549
|
case 'responds':
|
|
495
|
-
errors.push(...checkResponds(prop, analysis, flattenedStates, guardDefs));
|
|
550
|
+
errors.push(...checkResponds(prop, analysis, flattenedStates, guardDefs, dtOutputDomain));
|
|
496
551
|
break;
|
|
497
552
|
case 'invariant':
|
|
498
553
|
errors.push(...checkInvariant(prop, machine, flattenedStates));
|
|
@@ -183,7 +183,11 @@ export function analyzeMachine(machine: MachineDef): MachineAnalysis {
|
|
|
183
183
|
if (fs) {
|
|
184
184
|
// Find the original state definition
|
|
185
185
|
const originalState = findOriginalState(machine.states, fs.simpleName, fs.parentName);
|
|
186
|
-
if (originalState?.
|
|
186
|
+
if (originalState?.ignoredAll) {
|
|
187
|
+
for (const event of machine.events) {
|
|
188
|
+
info.eventsIgnored.add(event.name);
|
|
189
|
+
}
|
|
190
|
+
} else if (originalState?.ignoredEvents) {
|
|
187
191
|
for (const event of originalState.ignoredEvents) {
|
|
188
192
|
info.eventsIgnored.add(event);
|
|
189
193
|
}
|