specfact-cli 0.6.3__py3-none-any.whl
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.
- specfact_cli/__init__.py +14 -0
- specfact_cli/agents/__init__.py +24 -0
- specfact_cli/agents/analyze_agent.py +391 -0
- specfact_cli/agents/base.py +95 -0
- specfact_cli/agents/plan_agent.py +202 -0
- specfact_cli/agents/registry.py +176 -0
- specfact_cli/agents/sync_agent.py +133 -0
- specfact_cli/analyzers/__init__.py +12 -0
- specfact_cli/analyzers/ambiguity_scanner.py +592 -0
- specfact_cli/analyzers/code_analyzer.py +1228 -0
- specfact_cli/analyzers/contract_extractor.py +419 -0
- specfact_cli/analyzers/control_flow_analyzer.py +281 -0
- specfact_cli/analyzers/requirement_extractor.py +337 -0
- specfact_cli/analyzers/test_pattern_extractor.py +330 -0
- specfact_cli/cli.py +264 -0
- specfact_cli/commands/__init__.py +7 -0
- specfact_cli/commands/constitution.py +261 -0
- specfact_cli/commands/enforce.py +96 -0
- specfact_cli/commands/import_cmd.py +694 -0
- specfact_cli/commands/init.py +143 -0
- specfact_cli/commands/plan.py +2398 -0
- specfact_cli/commands/repro.py +214 -0
- specfact_cli/commands/sync.py +744 -0
- specfact_cli/common/__init__.py +25 -0
- specfact_cli/common/logger_setup.py +654 -0
- specfact_cli/common/logging_utils.py +41 -0
- specfact_cli/common/text_utils.py +52 -0
- specfact_cli/common/utils.py +48 -0
- specfact_cli/comparators/__init__.py +11 -0
- specfact_cli/comparators/plan_comparator.py +391 -0
- specfact_cli/enrichers/constitution_enricher.py +765 -0
- specfact_cli/enrichers/plan_enricher.py +268 -0
- specfact_cli/generators/__init__.py +14 -0
- specfact_cli/generators/plan_generator.py +105 -0
- specfact_cli/generators/protocol_generator.py +115 -0
- specfact_cli/generators/report_generator.py +200 -0
- specfact_cli/generators/workflow_generator.py +120 -0
- specfact_cli/importers/__init__.py +7 -0
- specfact_cli/importers/speckit_converter.py +1051 -0
- specfact_cli/importers/speckit_scanner.py +776 -0
- specfact_cli/models/__init__.py +33 -0
- specfact_cli/models/deviation.py +105 -0
- specfact_cli/models/enforcement.py +150 -0
- specfact_cli/models/plan.py +139 -0
- specfact_cli/models/protocol.py +28 -0
- specfact_cli/modes/__init__.py +19 -0
- specfact_cli/modes/detector.py +126 -0
- specfact_cli/modes/router.py +153 -0
- specfact_cli/resources/mappings/node-async.yaml +49 -0
- specfact_cli/resources/mappings/python-async.yaml +47 -0
- specfact_cli/resources/mappings/speckit-default.yaml +82 -0
- specfact_cli/resources/prompts/specfact-enforce.md +185 -0
- specfact_cli/resources/prompts/specfact-import-from-code.md +597 -0
- specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
- specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
- specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
- specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
- specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
- specfact_cli/resources/prompts/specfact-plan-review.md +869 -0
- specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
- specfact_cli/resources/prompts/specfact-plan-update-feature.md +234 -0
- specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
- specfact_cli/resources/prompts/specfact-repro.md +268 -0
- specfact_cli/resources/prompts/specfact-sync.md +457 -0
- specfact_cli/resources/schemas/deviation.schema.json +61 -0
- specfact_cli/resources/schemas/plan.schema.json +204 -0
- specfact_cli/resources/schemas/protocol.schema.json +53 -0
- specfact_cli/resources/semgrep/async.yml +285 -0
- specfact_cli/resources/templates/github-action.yml.j2 +140 -0
- specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
- specfact_cli/resources/templates/pr-template.md.j2 +58 -0
- specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
- specfact_cli/resources/templates/telemetry.yaml.example +35 -0
- specfact_cli/sync/__init__.py +21 -0
- specfact_cli/sync/repository_sync.py +279 -0
- specfact_cli/sync/speckit_sync.py +388 -0
- specfact_cli/sync/watcher.py +268 -0
- specfact_cli/telemetry.py +440 -0
- specfact_cli/utils/__init__.py +58 -0
- specfact_cli/utils/console.py +70 -0
- specfact_cli/utils/enrichment_parser.py +445 -0
- specfact_cli/utils/feature_keys.py +212 -0
- specfact_cli/utils/git.py +241 -0
- specfact_cli/utils/github_annotations.py +399 -0
- specfact_cli/utils/ide_setup.py +389 -0
- specfact_cli/utils/prompts.py +180 -0
- specfact_cli/utils/structure.py +674 -0
- specfact_cli/utils/yaml_utils.py +200 -0
- specfact_cli/validators/__init__.py +20 -0
- specfact_cli/validators/fsm.py +262 -0
- specfact_cli/validators/repro_checker.py +780 -0
- specfact_cli/validators/schema.py +196 -0
- specfact_cli-0.6.3.dist-info/METADATA +456 -0
- specfact_cli-0.6.3.dist-info/RECORD +97 -0
- specfact_cli-0.6.3.dist-info/WHEEL +4 -0
- specfact_cli-0.6.3.dist-info/entry_points.txt +2 -0
- specfact_cli-0.6.3.dist-info/licenses/LICENSE.md +202 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YAML utilities.
|
|
3
|
+
|
|
4
|
+
This module provides helpers for YAML parsing and serialization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from beartype import beartype
|
|
13
|
+
from icontract import ensure, require
|
|
14
|
+
from ruamel.yaml import YAML
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class YAMLUtils:
|
|
18
|
+
"""Helper class for YAML operations."""
|
|
19
|
+
|
|
20
|
+
@beartype
|
|
21
|
+
@require(lambda indent_mapping: indent_mapping > 0, "Indent mapping must be positive")
|
|
22
|
+
@require(lambda indent_sequence: indent_sequence > 0, "Indent sequence must be positive")
|
|
23
|
+
def __init__(self, preserve_quotes: bool = True, indent_mapping: int = 2, indent_sequence: int = 2) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Initialize YAML utilities.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
preserve_quotes: Whether to preserve quotes in strings
|
|
29
|
+
indent_mapping: Indentation for mappings (must be > 0)
|
|
30
|
+
indent_sequence: Indentation for sequences (must be > 0)
|
|
31
|
+
"""
|
|
32
|
+
self.yaml = YAML()
|
|
33
|
+
self.yaml.preserve_quotes = preserve_quotes
|
|
34
|
+
self.yaml.indent(mapping=indent_mapping, sequence=indent_sequence)
|
|
35
|
+
self.yaml.default_flow_style = False
|
|
36
|
+
|
|
37
|
+
@beartype
|
|
38
|
+
@require(lambda file_path: isinstance(file_path, (Path, str)), "File path must be Path or str")
|
|
39
|
+
@ensure(lambda result: result is not None, "Must return parsed content")
|
|
40
|
+
def load(self, file_path: Path | str) -> Any:
|
|
41
|
+
"""
|
|
42
|
+
Load YAML from file.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
file_path: Path to YAML file (must exist)
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Parsed YAML content
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
FileNotFoundError: If file doesn't exist
|
|
52
|
+
"""
|
|
53
|
+
file_path = Path(file_path)
|
|
54
|
+
|
|
55
|
+
if not file_path.exists():
|
|
56
|
+
raise FileNotFoundError(f"YAML file not found: {file_path}")
|
|
57
|
+
|
|
58
|
+
with open(file_path, encoding="utf-8") as f:
|
|
59
|
+
return self.yaml.load(f)
|
|
60
|
+
|
|
61
|
+
@beartype
|
|
62
|
+
@require(lambda yaml_string: isinstance(yaml_string, str), "YAML string must be str")
|
|
63
|
+
@ensure(lambda result: result is not None, "Must return parsed content")
|
|
64
|
+
def load_string(self, yaml_string: str) -> Any:
|
|
65
|
+
"""
|
|
66
|
+
Load YAML from string.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
yaml_string: YAML content as string
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Parsed YAML content
|
|
73
|
+
"""
|
|
74
|
+
return self.yaml.load(yaml_string)
|
|
75
|
+
|
|
76
|
+
@beartype
|
|
77
|
+
@require(lambda file_path: isinstance(file_path, (Path, str)), "File path must be Path or str")
|
|
78
|
+
def dump(self, data: Any, file_path: Path | str) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Dump data to YAML file.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
data: Data to serialize
|
|
84
|
+
file_path: Output file path
|
|
85
|
+
"""
|
|
86
|
+
file_path = Path(file_path)
|
|
87
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
90
|
+
self.yaml.dump(data, f)
|
|
91
|
+
|
|
92
|
+
@beartype
|
|
93
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
94
|
+
def dump_string(self, data: Any) -> str:
|
|
95
|
+
"""
|
|
96
|
+
Dump data to YAML string.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
data: Data to serialize
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
YAML string
|
|
103
|
+
"""
|
|
104
|
+
from io import StringIO
|
|
105
|
+
|
|
106
|
+
stream = StringIO()
|
|
107
|
+
self.yaml.dump(data, stream)
|
|
108
|
+
return stream.getvalue()
|
|
109
|
+
|
|
110
|
+
@beartype
|
|
111
|
+
@require(lambda base: isinstance(base, dict), "Base must be dictionary")
|
|
112
|
+
@require(lambda overlay: isinstance(overlay, dict), "Overlay must be dictionary")
|
|
113
|
+
@ensure(lambda result: isinstance(result, dict), "Must return dictionary")
|
|
114
|
+
def merge_yaml(self, base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
|
115
|
+
"""
|
|
116
|
+
Deep merge two YAML dictionaries.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
base: Base dictionary
|
|
120
|
+
overlay: Overlay dictionary (takes precedence)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Merged dictionary
|
|
124
|
+
"""
|
|
125
|
+
result = base.copy()
|
|
126
|
+
|
|
127
|
+
for key, value in overlay.items():
|
|
128
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
129
|
+
result[key] = self.merge_yaml(result[key], value)
|
|
130
|
+
else:
|
|
131
|
+
result[key] = value
|
|
132
|
+
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Convenience functions for quick operations
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@beartype
|
|
140
|
+
@require(lambda file_path: isinstance(file_path, (Path, str)), "File path must be Path or str")
|
|
141
|
+
@ensure(lambda result: result is not None, "Must return parsed content")
|
|
142
|
+
def load_yaml(file_path: Path | str) -> Any:
|
|
143
|
+
"""
|
|
144
|
+
Load YAML from file (convenience function).
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
file_path: Path to YAML file
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Parsed YAML content
|
|
151
|
+
"""
|
|
152
|
+
utils = YAMLUtils()
|
|
153
|
+
return utils.load(file_path)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@beartype
|
|
157
|
+
@require(lambda file_path: isinstance(file_path, (Path, str)), "File path must be Path or str")
|
|
158
|
+
def dump_yaml(data: Any, file_path: Path | str) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Dump data to YAML file (convenience function).
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
data: Data to serialize
|
|
164
|
+
file_path: Output file path
|
|
165
|
+
"""
|
|
166
|
+
utils = YAMLUtils()
|
|
167
|
+
utils.dump(data, file_path)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@beartype
|
|
171
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
172
|
+
def yaml_to_string(data: Any) -> str:
|
|
173
|
+
"""
|
|
174
|
+
Convert data to YAML string (convenience function).
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
data: Data to serialize
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
YAML string
|
|
181
|
+
"""
|
|
182
|
+
utils = YAMLUtils()
|
|
183
|
+
return utils.dump_string(data)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@beartype
|
|
187
|
+
@require(lambda yaml_string: isinstance(yaml_string, str), "YAML string must be str")
|
|
188
|
+
@ensure(lambda result: result is not None, "Must return parsed content")
|
|
189
|
+
def string_to_yaml(yaml_string: str) -> Any:
|
|
190
|
+
"""
|
|
191
|
+
Parse YAML string (convenience function).
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
yaml_string: YAML content as string
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Parsed YAML content
|
|
198
|
+
"""
|
|
199
|
+
utils = YAMLUtils()
|
|
200
|
+
return utils.load_string(yaml_string)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SpecFact CLI validators.
|
|
3
|
+
|
|
4
|
+
This package contains validation logic for schemas, contracts,
|
|
5
|
+
protocols, and plans.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from specfact_cli.validators.fsm import FSMValidator
|
|
9
|
+
from specfact_cli.validators.repro_checker import ReproChecker, ReproReport
|
|
10
|
+
from specfact_cli.validators.schema import SchemaValidator, validate_plan_bundle, validate_protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"FSMValidator",
|
|
15
|
+
"ReproChecker",
|
|
16
|
+
"ReproReport",
|
|
17
|
+
"SchemaValidator",
|
|
18
|
+
"validate_plan_bundle",
|
|
19
|
+
"validate_protocol",
|
|
20
|
+
]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FSM (Finite State Machine) validation module.
|
|
3
|
+
|
|
4
|
+
This module provides validators for state machine protocols and transitions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import networkx as nx
|
|
12
|
+
from beartype import beartype
|
|
13
|
+
from icontract import ensure, require
|
|
14
|
+
|
|
15
|
+
from specfact_cli.models.deviation import Deviation, DeviationSeverity, DeviationType, ValidationReport
|
|
16
|
+
from specfact_cli.models.protocol import Protocol
|
|
17
|
+
from specfact_cli.utils.yaml_utils import load_yaml
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FSMValidator:
|
|
21
|
+
"""FSM validator for protocol validation."""
|
|
22
|
+
|
|
23
|
+
@beartype
|
|
24
|
+
@require(
|
|
25
|
+
lambda protocol, protocol_path: protocol is not None or protocol_path is not None,
|
|
26
|
+
"Either protocol or protocol_path must be provided",
|
|
27
|
+
)
|
|
28
|
+
@require(
|
|
29
|
+
lambda protocol_path: protocol_path is None or protocol_path.exists(), "Protocol path must exist if provided"
|
|
30
|
+
)
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
protocol: Protocol | None = None,
|
|
34
|
+
protocol_path: Path | None = None,
|
|
35
|
+
guard_functions: dict | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Initialize FSM validator.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
protocol: Protocol model to validate
|
|
42
|
+
protocol_path: Path to protocol YAML file (must exist if provided)
|
|
43
|
+
guard_functions: Optional dict of guard function implementations
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: If neither protocol nor protocol_path is provided
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
if protocol is None:
|
|
50
|
+
# Load protocol from file
|
|
51
|
+
data = load_yaml(protocol_path) # type: ignore
|
|
52
|
+
self.protocol = Protocol(**data)
|
|
53
|
+
else:
|
|
54
|
+
self.protocol = protocol
|
|
55
|
+
|
|
56
|
+
self.guard_functions = guard_functions if guard_functions is not None else {}
|
|
57
|
+
self.graph = self._build_graph()
|
|
58
|
+
|
|
59
|
+
def _build_graph(self) -> nx.DiGraph:
|
|
60
|
+
"""
|
|
61
|
+
Build directed graph from protocol transitions.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
NetworkX directed graph
|
|
65
|
+
"""
|
|
66
|
+
graph = nx.DiGraph()
|
|
67
|
+
|
|
68
|
+
# Add all states as nodes
|
|
69
|
+
for state in self.protocol.states:
|
|
70
|
+
graph.add_node(state)
|
|
71
|
+
|
|
72
|
+
# Add transitions as edges
|
|
73
|
+
for transition in self.protocol.transitions:
|
|
74
|
+
graph.add_edge(
|
|
75
|
+
transition.from_state,
|
|
76
|
+
transition.to_state,
|
|
77
|
+
event=transition.on_event,
|
|
78
|
+
guard=transition.guard,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return graph
|
|
82
|
+
|
|
83
|
+
@beartype
|
|
84
|
+
@ensure(lambda result: isinstance(result, ValidationReport), "Must return ValidationReport")
|
|
85
|
+
def validate(self) -> ValidationReport:
|
|
86
|
+
"""
|
|
87
|
+
Validate the FSM protocol.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Validation report with any deviations found
|
|
91
|
+
"""
|
|
92
|
+
report = ValidationReport()
|
|
93
|
+
|
|
94
|
+
# Check 1: Start state exists
|
|
95
|
+
if self.protocol.start not in self.protocol.states:
|
|
96
|
+
report.add_deviation(
|
|
97
|
+
Deviation(
|
|
98
|
+
type=DeviationType.FSM_MISMATCH,
|
|
99
|
+
severity=DeviationSeverity.HIGH,
|
|
100
|
+
description=f"Start state '{self.protocol.start}' not in states list",
|
|
101
|
+
location="protocol.start",
|
|
102
|
+
fix_hint=f"Add '{self.protocol.start}' to states list",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Check 2: All transition states exist
|
|
107
|
+
for transition in self.protocol.transitions:
|
|
108
|
+
if transition.from_state not in self.protocol.states:
|
|
109
|
+
report.add_deviation(
|
|
110
|
+
Deviation(
|
|
111
|
+
type=DeviationType.FSM_MISMATCH,
|
|
112
|
+
severity=DeviationSeverity.HIGH,
|
|
113
|
+
description=f"Transition from unknown state: '{transition.from_state}'",
|
|
114
|
+
location=f"transition[{transition.from_state} → {transition.to_state}]",
|
|
115
|
+
fix_hint=f"Add '{transition.from_state}' to states list",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if transition.to_state not in self.protocol.states:
|
|
120
|
+
report.add_deviation(
|
|
121
|
+
Deviation(
|
|
122
|
+
type=DeviationType.FSM_MISMATCH,
|
|
123
|
+
severity=DeviationSeverity.HIGH,
|
|
124
|
+
description=f"Transition to unknown state: '{transition.to_state}'",
|
|
125
|
+
location=f"transition[{transition.from_state} → {transition.to_state}]",
|
|
126
|
+
fix_hint=f"Add '{transition.to_state}' to states list",
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Check 3: Reachability - all states reachable from start
|
|
131
|
+
if report.passed: # Only if no critical errors so far
|
|
132
|
+
reachable = nx.descendants(self.graph, self.protocol.start)
|
|
133
|
+
reachable.add(self.protocol.start)
|
|
134
|
+
|
|
135
|
+
unreachable = set(self.protocol.states) - reachable
|
|
136
|
+
if unreachable:
|
|
137
|
+
for state in unreachable:
|
|
138
|
+
report.add_deviation(
|
|
139
|
+
Deviation(
|
|
140
|
+
type=DeviationType.FSM_MISMATCH,
|
|
141
|
+
severity=DeviationSeverity.MEDIUM,
|
|
142
|
+
description=f"State '{state}' is not reachable from start state",
|
|
143
|
+
location=f"state[{state}]",
|
|
144
|
+
fix_hint=f"Add transition path from '{self.protocol.start}' to '{state}'",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Check 4: Guards are defined
|
|
149
|
+
for transition in self.protocol.transitions:
|
|
150
|
+
if (
|
|
151
|
+
transition.guard
|
|
152
|
+
and transition.guard not in self.protocol.guards
|
|
153
|
+
and transition.guard not in self.guard_functions
|
|
154
|
+
):
|
|
155
|
+
# LOW severity if guard functions can be provided externally
|
|
156
|
+
report.add_deviation(
|
|
157
|
+
Deviation(
|
|
158
|
+
type=DeviationType.FSM_MISMATCH,
|
|
159
|
+
severity=DeviationSeverity.LOW,
|
|
160
|
+
description=f"Guard '{transition.guard}' not defined in protocol or guard_functions",
|
|
161
|
+
location=f"transition[{transition.from_state} → {transition.to_state}]",
|
|
162
|
+
fix_hint=f"Add guard definition for '{transition.guard}' in protocol.guards or pass guard_functions",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Check 5: Detect cycles (informational)
|
|
167
|
+
try:
|
|
168
|
+
cycles = list(nx.simple_cycles(self.graph))
|
|
169
|
+
if cycles:
|
|
170
|
+
for cycle in cycles:
|
|
171
|
+
report.add_deviation(
|
|
172
|
+
Deviation(
|
|
173
|
+
type=DeviationType.FSM_MISMATCH,
|
|
174
|
+
severity=DeviationSeverity.LOW,
|
|
175
|
+
description=f"Cycle detected: {' → '.join(cycle)}",
|
|
176
|
+
location="protocol.transitions",
|
|
177
|
+
fix_hint="Cycles may be intentional for workflows, verify this is expected",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
except nx.NetworkXNoCycle:
|
|
181
|
+
pass # No cycles is fine
|
|
182
|
+
|
|
183
|
+
return report
|
|
184
|
+
|
|
185
|
+
@beartype
|
|
186
|
+
@require(lambda from_state: isinstance(from_state, str) and len(from_state) > 0, "State must be non-empty string")
|
|
187
|
+
@ensure(lambda result: isinstance(result, set), "Must return set")
|
|
188
|
+
@ensure(lambda result: all(isinstance(s, str) for s in result), "All items must be strings")
|
|
189
|
+
def get_reachable_states(self, from_state: str) -> set[str]:
|
|
190
|
+
"""
|
|
191
|
+
Get all states reachable from given state.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
from_state: Starting state
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Set of reachable state names
|
|
198
|
+
"""
|
|
199
|
+
if from_state not in self.protocol.states:
|
|
200
|
+
return set()
|
|
201
|
+
|
|
202
|
+
reachable = nx.descendants(self.graph, from_state)
|
|
203
|
+
reachable.add(from_state)
|
|
204
|
+
return reachable
|
|
205
|
+
|
|
206
|
+
@beartype
|
|
207
|
+
@require(lambda state: isinstance(state, str) and len(state) > 0, "State must be non-empty string")
|
|
208
|
+
@ensure(lambda result: isinstance(result, list), "Must return list")
|
|
209
|
+
@ensure(lambda result: all(isinstance(t, dict) for t in result), "All items must be dictionaries")
|
|
210
|
+
def get_transitions_from(self, state: str) -> list[dict]:
|
|
211
|
+
"""
|
|
212
|
+
Get all transitions from given state.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
state: State name
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
List of transition dictionaries
|
|
219
|
+
"""
|
|
220
|
+
if state not in self.protocol.states:
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
transitions = []
|
|
224
|
+
for successor in self.graph.successors(state):
|
|
225
|
+
edge_data = self.graph.get_edge_data(state, successor)
|
|
226
|
+
transitions.append(
|
|
227
|
+
{
|
|
228
|
+
"from_state": state,
|
|
229
|
+
"to_state": successor,
|
|
230
|
+
"event": edge_data.get("event"),
|
|
231
|
+
"guard": edge_data.get("guard"),
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return transitions
|
|
236
|
+
|
|
237
|
+
@beartype
|
|
238
|
+
@require(
|
|
239
|
+
lambda from_state: isinstance(from_state, str) and len(from_state) > 0, "From state must be non-empty string"
|
|
240
|
+
)
|
|
241
|
+
@require(lambda on_event: isinstance(on_event, str) and len(on_event) > 0, "Event must be non-empty string")
|
|
242
|
+
@require(lambda to_state: isinstance(to_state, str) and len(to_state) > 0, "To state must be non-empty string")
|
|
243
|
+
@ensure(lambda result: isinstance(result, bool), "Must return boolean")
|
|
244
|
+
def is_valid_transition(self, from_state: str, on_event: str, to_state: str) -> bool:
|
|
245
|
+
"""
|
|
246
|
+
Check if transition is valid.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
from_state: Source state
|
|
250
|
+
on_event: Event that triggers the transition
|
|
251
|
+
to_state: Target state
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
True if transition exists with the given event
|
|
255
|
+
"""
|
|
256
|
+
# Check if edge exists
|
|
257
|
+
if not self.graph.has_edge(from_state, to_state):
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
# Check if the event matches
|
|
261
|
+
edge_data = self.graph.get_edge_data(from_state, to_state)
|
|
262
|
+
return edge_data.get("event") == on_event
|