statable 3.1.0__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.
Files changed (117) hide show
  1. codegen/__init__.py +38 -0
  2. codegen/c_code_generator.py +1752 -0
  3. codegen/code_merger.py +371 -0
  4. codegen/code_templates.py +509 -0
  5. codegen/config.py +167 -0
  6. codegen/enum_generator.py +377 -0
  7. codegen/event_queue_generator.py +303 -0
  8. codegen/interrupt_generator.py +345 -0
  9. codegen/naming_convention.py +171 -0
  10. codegen/osal_generator.py +282 -0
  11. codegen/role_function_generator.py +1244 -0
  12. codegen/sample_data.py +269 -0
  13. codegen/state_actions_generator.py +395 -0
  14. codegen/struct_generator.py +721 -0
  15. codegen/timer_generator.py +299 -0
  16. codegen/transition_generator.py +907 -0
  17. codegen/type_mapper.py +69 -0
  18. codegen/validate/__init__.py +38 -0
  19. codegen/validate/change_actions.py +90 -0
  20. codegen/validate/change_applier.py +416 -0
  21. codegen/validate/clipboard_manager.py +47 -0
  22. codegen/validate/data/__init__.py +30 -0
  23. codegen/validate/data/action_definitions.py +200 -0
  24. codegen/validate/data/keywords.py +46 -0
  25. codegen/validate/data/prompt_templates.py +176 -0
  26. codegen/validate/data/validation_rules.py +205 -0
  27. codegen/validate/items/__init__.py +30 -0
  28. codegen/validate/items/base_validator.py +25 -0
  29. codegen/validate/items/cell_validator.py +275 -0
  30. codegen/validate/items/custom_type_validator.py +75 -0
  31. codegen/validate/items/event_validator.py +78 -0
  32. codegen/validate/items/flag_validator.py +74 -0
  33. codegen/validate/items/interrupt_validator.py +75 -0
  34. codegen/validate/items/queue_validator.py +73 -0
  35. codegen/validate/items/role_function_validator.py +175 -0
  36. codegen/validate/items/state_validator.py +88 -0
  37. codegen/validate/items/timer_validator.py +120 -0
  38. codegen/validate/items/transition_validator.py +113 -0
  39. codegen/validate/items/variable_validator.py +132 -0
  40. codegen/validate/logger.py +52 -0
  41. codegen/validate/models.py +177 -0
  42. codegen/validate/prompt_generator.py +724 -0
  43. codegen/validate/response_parser.py +154 -0
  44. codegen/validate/response_validator.py +275 -0
  45. codegen/validate/validation_dialog.py +322 -0
  46. codegen/validate/validator.py +94 -0
  47. codegen/variable_generator.py +419 -0
  48. statable/__init__.py +71 -0
  49. statable/__main__.py +49 -0
  50. statable/cli.py +251 -0
  51. statable/global_defs.py +256 -0
  52. statable/mermaid_gen.py +144 -0
  53. statable/model.py +426 -0
  54. statable/parser.py +64 -0
  55. statable/sample_data.py +435 -0
  56. statable/shared/__init__.py +24 -0
  57. statable/shared/condition_library.py +63 -0
  58. statable/shared/literal_library.py +66 -0
  59. statable/shared/role_function_library.py +150 -0
  60. statable/state_machine.py +121 -0
  61. statable/xml_io.py +1164 -0
  62. statable-3.1.0.dist-info/METADATA +151 -0
  63. statable-3.1.0.dist-info/RECORD +117 -0
  64. statable-3.1.0.dist-info/WHEEL +5 -0
  65. statable-3.1.0.dist-info/entry_points.txt +2 -0
  66. statable-3.1.0.dist-info/top_level.txt +3 -0
  67. statable_gui/__init__.py +23 -0
  68. statable_gui/action_edit_dialog.py +214 -0
  69. statable_gui/code_generation_dialog.py +486 -0
  70. statable_gui/code_generation_settings_dialog.py +522 -0
  71. statable_gui/common_widgets.py +537 -0
  72. statable_gui/condition_builder_dialog.py +500 -0
  73. statable_gui/condition_edit_dialog.py +123 -0
  74. statable_gui/condition_template_dialog.py +58 -0
  75. statable_gui/config.py +21 -0
  76. statable_gui/dialogs.py +439 -0
  77. statable_gui/event_definition_dialog.py +605 -0
  78. statable_gui/event_delivery_settings_dialog.py +191 -0
  79. statable_gui/event_queue_dialog.py +245 -0
  80. statable_gui/global_defs.py +167 -0
  81. statable_gui/global_defs_dialog.py +796 -0
  82. statable_gui/i18n/__init__.py +118 -0
  83. statable_gui/interrupt_handler_edit_dialog.py +810 -0
  84. statable_gui/layer_settings_dialog.py +180 -0
  85. statable_gui/libcntrl/__init__.py +23 -0
  86. statable_gui/libcntrl/condition_library.py +13 -0
  87. statable_gui/libcntrl/literal_library.py +13 -0
  88. statable_gui/libcntrl/literal_management_dialog.py +185 -0
  89. statable_gui/libcntrl/role_function_edit_dialog.py +145 -0
  90. statable_gui/libcntrl/role_function_library.py +13 -0
  91. statable_gui/literal_definition_dialog.py +62 -0
  92. statable_gui/logger.py +83 -0
  93. statable_gui/main_window.py +1380 -0
  94. statable_gui/matrix_table.py +413 -0
  95. statable_gui/preference_keys.py +18 -0
  96. statable_gui/preferences.py +74 -0
  97. statable_gui/role_function_dialog.py +244 -0
  98. statable_gui/sample_data.py +11 -0
  99. statable_gui/state_actions_dialog.py +398 -0
  100. statable_gui/symbol_picker.py +170 -0
  101. statable_gui/traceball.py +37 -0
  102. statable_gui/transition_editor_direct/__init__.py +15 -0
  103. statable_gui/transition_editor_direct/actions_tab.py +856 -0
  104. statable_gui/transition_editor_direct/code_widget.py +344 -0
  105. statable_gui/transition_editor_direct/coverage_analyzer.py +163 -0
  106. statable_gui/transition_editor_direct/dialog.py +335 -0
  107. statable_gui/transition_editor_direct/draft.py +270 -0
  108. statable_gui/transition_editor_direct/edit_dialogs.py +160 -0
  109. statable_gui/transition_editor_direct/overview_tab.py +173 -0
  110. statable_gui/transition_editor_direct/palette_widget.py +233 -0
  111. statable_gui/transition_editor_direct/relations_edit_dialog.py +156 -0
  112. statable_gui/transition_editor_direct/relations_tab.py +230 -0
  113. statable_gui/transition_editor_direct/system_global_dialog.py +52 -0
  114. statable_gui/transition_editor_direct/transition_actions_dialog.py +109 -0
  115. statable_gui/transition_editor_direct/transitions_tab.py +620 -0
  116. statable_gui/validation_dialog.py +285 -0
  117. statable_gui/widgets.py +1315 -0
@@ -0,0 +1,344 @@
1
+ # statable_gui/transition_editor_direct/code_widget.py
2
+ """Code display widget (read-only, ActionDraft support / v2.2).
3
+
4
+ Version: 2.2.1 (2026-09-19)
5
+ - Fix: Nested group emitted duplicate transition blocks when parent
6
+ and child both declared the same members. The parent now skips
7
+ labels that any descendant declares.
8
+
9
+ [v2.2 changes]
10
+ - Renders cell_actions (before_transitions / after_transitions).
11
+ Legacy "always" trigger is treated as "before_transitions".
12
+ - Respects early_return per transition (`_handled` guard).
13
+ - §12-5: Recursive group nesting via TransitionRelation.children.
14
+ - Handles entry / exit via State.entry / State.exit lists.
15
+ """
16
+
17
+ import logging
18
+ import re
19
+ from typing import List
20
+
21
+ from PySide6.QtWidgets import QPlainTextEdit
22
+ from .draft import ActionDraft, ensure_list
23
+
24
+ logger = logging.getLogger("transition_editor_direct.code")
25
+
26
+
27
+ _VALID_C_IDENTIFIER = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
28
+
29
+
30
+ def _to_pascal_case(name: str) -> str:
31
+ """snake_case / camelCase -> PascalCase."""
32
+ if not name:
33
+ return ""
34
+ parts = re.split(r'[_\s]+', name)
35
+ result = ""
36
+ for part in parts:
37
+ if part:
38
+ result += part[0].upper() + part[1:]
39
+ return result
40
+
41
+
42
+ class CodeWidget(QPlainTextEdit):
43
+ def __init__(self, draft: ActionDraft, parent=None):
44
+ super().__init__(parent)
45
+ self.draft = draft
46
+ self.setReadOnly(True)
47
+ self.update_code()
48
+
49
+ def update_code(self):
50
+ logger.debug("CodeWidget.update_code called")
51
+ code = self._generate_code()
52
+ self.draft.generated_code = code
53
+ self.setPlainText(code)
54
+ logger.debug(f"Code updated ({len(code)} chars)")
55
+
56
+ # ==================================================================
57
+ # Layer / type resolution
58
+ # ==================================================================
59
+ def _get_layer_name(self) -> str:
60
+ layer = getattr(self.draft, 'layer_name', '') or ''
61
+ if layer:
62
+ return layer
63
+
64
+ ns_count = {}
65
+ for item in self.draft.flow_items:
66
+ names = []
67
+ if item.item_type == "transition":
68
+ names.extend(ensure_list(item.params.get('pre_actions', [])))
69
+ names.extend(ensure_list(item.params.get('else_actions', [])))
70
+ elif item.item_type == "function":
71
+ names.append(item.name)
72
+ for n in names:
73
+ if '.' in n:
74
+ ns = n.split('.', 1)[0]
75
+ if ns and _VALID_C_IDENTIFIER.match(ns):
76
+ ns_count[ns] = ns_count.get(ns, 0) + 1
77
+
78
+ if ns_count:
79
+ return max(ns_count.items(), key=lambda kv: kv[1])[0]
80
+ return ''
81
+
82
+ def _context_type(self) -> str:
83
+ layer = self._get_layer_name()
84
+ return f"TransitionContext_{layer}_t" if layer else "TransitionContext_t"
85
+
86
+ # ==================================================================
87
+ # Reference -> RoleFunc name
88
+ # ==================================================================
89
+ def _role_func_name(self, ref: str) -> str:
90
+ """Convert reference string to RoleFunc_<Namespace>_<PascalName>."""
91
+ if not ref:
92
+ return ""
93
+ name = ref.strip()
94
+ if not name:
95
+ return ""
96
+
97
+ if '(' in name:
98
+ name = name.split('(', 1)[0].strip()
99
+
100
+ if name.startswith("RoleFunc_"):
101
+ rest = name[len("RoleFunc_"):]
102
+ rest = rest.replace('.', '_')
103
+ if _VALID_C_IDENTIFIER.match(rest):
104
+ return f"RoleFunc_{rest}"
105
+ return ""
106
+
107
+ if '.' in name:
108
+ ns, base = name.split('.', 1)
109
+ if not (_VALID_C_IDENTIFIER.match(ns)
110
+ and _VALID_C_IDENTIFIER.match(base)):
111
+ return ""
112
+ pascal = _to_pascal_case(base)
113
+ return f"RoleFunc_{ns}_{pascal}"
114
+
115
+ if not _VALID_C_IDENTIFIER.match(name):
116
+ logger.warning(
117
+ f"_role_func_name: reject invalid identifier: {ref!r}")
118
+ return ""
119
+ pascal = _to_pascal_case(name)
120
+ return f"RoleFunc_{pascal}"
121
+
122
+ def _call_stmt(self, ref: str) -> str:
123
+ fn = self._role_func_name(ref)
124
+ if not fn:
125
+ return f"/* Invalid reference: {ref} */"
126
+ return f"{fn}(transition, ctx);"
127
+
128
+ # ==================================================================
129
+ # Code generation
130
+ # ==================================================================
131
+ def _generate_code(self) -> str:
132
+ logger.debug("=== _generate_code START ===")
133
+ lines = []
134
+
135
+ context_type = self._context_type()
136
+
137
+ # ---- System globals ----
138
+ for g in self.draft.system_globals:
139
+ lines.append(f"{g.type} {g.name} = {g.initial_value};")
140
+ if self.draft.system_globals:
141
+ lines.append("")
142
+
143
+ # ---- Collect role function prototypes ----
144
+ proto_names = set()
145
+ for item in self.draft.flow_items:
146
+ if item.item_type == "transition":
147
+ pre_actions = ensure_list(item.params.get('pre_actions', []))
148
+ else_actions = ensure_list(item.params.get('else_actions', []))
149
+ for p in pre_actions:
150
+ proto_names.add(p)
151
+ for ea in else_actions:
152
+ proto_names.add(ea)
153
+ elif item.item_type == "function":
154
+ proto_names.add(item.name)
155
+
156
+ for a in self.draft.cell_actions:
157
+ if a.role_function:
158
+ proto_names.add(a.role_function)
159
+
160
+ if proto_names:
161
+ for ref in sorted(proto_names):
162
+ func_name = self._role_func_name(ref)
163
+ if not func_name:
164
+ lines.append(
165
+ f"/* Undefined reference (invalid identifier): {ref} */")
166
+ continue
167
+ lines.append(
168
+ f"int {func_name}("
169
+ f"const {context_type} *transition, "
170
+ f"SystemContext_t *ctx);"
171
+ )
172
+ lines.append("")
173
+
174
+ # ---- Body ----
175
+ needs_handled = any(
176
+ item.params.get('early_return', False)
177
+ for item in self.draft.flow_items
178
+ if item.item_type == "transition"
179
+ )
180
+
181
+ # ---- Cell actions (pre): before_transitions (+ legacy always) ----
182
+ pre_actions_for_cell = [
183
+ a for a in self.draft.cell_actions
184
+ if a.trigger in ("before_transitions", "always")
185
+ ]
186
+ if pre_actions_for_cell:
187
+ lines.append("/* ===== Cell actions (before_transitions) ===== */")
188
+ for a in pre_actions_for_cell:
189
+ lines.append(" " + self._call_stmt(a.role_function))
190
+ lines.append("")
191
+
192
+ # ---- Transitions (v2.2 §12-5 recursive) ----
193
+ transitions = [it for it in self.draft.flow_items
194
+ if it.item_type == "transition"]
195
+ if transitions:
196
+ if needs_handled:
197
+ lines.append("bool _handled = false;")
198
+ lines.append("")
199
+
200
+ # Build by_label map
201
+ by_label = {}
202
+ for it in transitions:
203
+ lbl = it.params.get('label', '') or ''
204
+ if lbl:
205
+ by_label[lbl] = it
206
+
207
+ # Collect mentioned labels
208
+ mentioned = set()
209
+
210
+ def collect(rel):
211
+ for m in (rel.members or []):
212
+ mentioned.add(m)
213
+ for c in (getattr(rel, 'children', None) or []):
214
+ collect(c)
215
+
216
+ for rel in self.draft.cell_relations:
217
+ collect(rel)
218
+
219
+ # Emit relations in order
220
+ for rel in self.draft.cell_relations:
221
+ self._emit_relation_inline(
222
+ rel, by_label, lines, indent_level=1)
223
+
224
+ # Emit un-mentioned transitions
225
+ for it in transitions:
226
+ lbl = it.params.get('label', '') or ''
227
+ if lbl and lbl in mentioned:
228
+ continue
229
+ self._emit_transition_item(it, lines, indent=1)
230
+
231
+ # ---- Cell actions (post): after_transitions ----
232
+ after_actions = [a for a in self.draft.cell_actions
233
+ if a.trigger == "after_transitions"]
234
+ if after_actions:
235
+ lines.append("/* ===== Cell actions (after_transitions) ===== */")
236
+ for a in after_actions:
237
+ lines.append(" " + self._call_stmt(a.role_function))
238
+ lines.append("")
239
+
240
+ generated = "\n".join(lines).rstrip()
241
+ logger.debug("=== Generated Code ===")
242
+ logger.debug(generated)
243
+ logger.debug("=== _generate_code END ===")
244
+ return generated
245
+
246
+ # ==================================================================
247
+ # §12-5: Recursive relation emission
248
+ # ==================================================================
249
+ def _collect_child_labels_inline(self, rel) -> set:
250
+ """Recursively collect all labels mentioned in descendants of rel.
251
+
252
+ [v2.2.1 fix]
253
+ Used to avoid emitting the same transition twice when a parent
254
+ and its child both declare the same members.
255
+ """
256
+ labels = set()
257
+ for child in (getattr(rel, 'children', None) or []):
258
+ labels.update(getattr(child, 'members', None) or [])
259
+ labels.update(self._collect_child_labels_inline(child))
260
+ return labels
261
+
262
+ def _emit_relation_inline(self, rel, by_label, lines, indent_level=1):
263
+ """Recursively emit one relation into `lines`.
264
+
265
+ [v2.2.1 fix]
266
+ Labels handled by child relations are not emitted by the
267
+ parent, avoiding duplicate transition blocks.
268
+ """
269
+ pad = " " * indent_level
270
+ shared_cond = (getattr(rel, 'shared_condition', '') or '').strip()
271
+ has_cond = bool(shared_cond)
272
+
273
+ if has_cond:
274
+ lines.append("/* ===== Group (shared_condition) ===== */")
275
+ lines.append(f"{pad}if ({shared_cond}) {{")
276
+ inner_indent = indent_level + 1
277
+ else:
278
+ inner_indent = indent_level
279
+
280
+ # v2.2.1: labels handled by descendants are skipped here
281
+ child_labels = self._collect_child_labels_inline(rel)
282
+
283
+ # Members (skip those delegated to children)
284
+ for label in (getattr(rel, 'members', None) or []):
285
+ if label in child_labels:
286
+ continue
287
+ it = by_label.get(label)
288
+ if it is None:
289
+ continue
290
+ self._emit_transition_item(it, lines, indent=inner_indent)
291
+
292
+ # Children (recursively)
293
+ for child in (getattr(rel, 'children', None) or []):
294
+ self._emit_relation_inline(child, by_label, lines,
295
+ indent_level=inner_indent)
296
+
297
+ if has_cond:
298
+ lines.append(f"{pad}}}")
299
+
300
+ def _emit_transition_item(self, item, lines, indent=1):
301
+ """Emit one transition item into `lines` at the given indent."""
302
+ params = item.params
303
+ pad = " " * indent
304
+ label = params.get('label', '') or ''
305
+ cond = params.get('condition', '')
306
+ target = params.get('target', '') or self.draft.default_target
307
+ pre_actions = ensure_list(params.get('pre_actions', []))
308
+ else_actions = ensure_list(params.get('else_actions', []))
309
+ has_else = params.get('has_else', True)
310
+ else_target = (params.get('else_target', '')
311
+ or self.draft.default_target)
312
+ early_return = params.get('early_return', False)
313
+
314
+ cond_expr = cond if cond else "1"
315
+ mode = "Commit" if early_return else "Tentative"
316
+ lines.append(f"{pad}/* ===== Transition[{label}] ({mode}) ===== */")
317
+
318
+ if early_return:
319
+ lines.append(f"{pad}if (!_handled && {cond_expr}) {{")
320
+ else:
321
+ lines.append(f"{pad}if ({cond_expr}) {{")
322
+
323
+ for p in pre_actions:
324
+ lines.append(f"{pad} " + self._call_stmt(p))
325
+ if target:
326
+ lines.append(f"{pad} next_state = {target};")
327
+ if early_return:
328
+ lines.append(f"{pad} _handled = true;")
329
+
330
+ if has_else and (else_target or else_actions):
331
+ if early_return:
332
+ lines.append(
333
+ f"{pad}}} else if (!_handled && !({cond_expr})) {{")
334
+ else:
335
+ lines.append(f"{pad}}} else {{")
336
+ for ea in else_actions:
337
+ lines.append(f"{pad} " + self._call_stmt(ea))
338
+ if else_target:
339
+ lines.append(f"{pad} next_state = {else_target};")
340
+ if early_return:
341
+ lines.append(f"{pad} _handled = true;")
342
+
343
+ lines.append(f"{pad}}}")
344
+ lines.append("")
@@ -0,0 +1,163 @@
1
+ # statable_gui/transition_editor_direct/coverage_analyzer.py
2
+ """Coverage / reachability analyzer (v2.2).
3
+
4
+ Two levels:
5
+ - analyze_cell : unreachable transitions / duplicate targets / overlaps
6
+ - analyze_state_graph : unreachable / terminal / self-loop states
7
+ """
8
+
9
+ import logging
10
+ from dataclasses import dataclass, field
11
+ from typing import Dict, List, Tuple, Set
12
+
13
+ from statable.model import Transition
14
+ from statable.state_machine import StateMachine
15
+
16
+ logger = logging.getLogger("transition_editor_direct.coverage_analyzer")
17
+
18
+
19
+ class DuplicateTargetMap(dict):
20
+ """Dict subclass where empty compares equal to []."""
21
+
22
+ def __eq__(self, other):
23
+ if isinstance(other, list) and len(other) == 0:
24
+ return len(self) == 0
25
+ return super().__eq__(other)
26
+
27
+ def __ne__(self, other):
28
+ return not self.__eq__(other)
29
+
30
+ __hash__ = None # mutable
31
+
32
+
33
+ @dataclass
34
+ class CellReport:
35
+ source: str = ""
36
+ event: str = ""
37
+ transitions_count: int = 0
38
+ unreachable_labels: List[str] = field(default_factory=list)
39
+ duplicate_targets: DuplicateTargetMap = field(default_factory=DuplicateTargetMap)
40
+ overlap_pairs: List[Tuple[str, str]] = field(default_factory=list)
41
+
42
+
43
+ @dataclass
44
+ class StateGraphReport:
45
+ unreachable_states: List[str] = field(default_factory=list)
46
+ terminal_states: List[str] = field(default_factory=list)
47
+ self_loops: List[Tuple[str, str]] = field(default_factory=list)
48
+
49
+
50
+ class CoverageAnalyzer:
51
+ """Cell-internal and state-graph analysis."""
52
+
53
+ # ==================================================================
54
+ # Cell-level analysis
55
+ # ==================================================================
56
+ def analyze_cell(self, sm: StateMachine,
57
+ source: str, event: str) -> CellReport:
58
+ """Analyze a single (source, event) cell."""
59
+ report = CellReport(source=source, event=event)
60
+ transitions = sm.get_transitions_for_cell(source, event)
61
+ report.transitions_count = len(transitions)
62
+
63
+ if not transitions:
64
+ return report
65
+
66
+ # ---- 1. Unreachable detection ----
67
+ # A transition is unreachable if some earlier transition
68
+ # has early_return=True AND definitely fires:
69
+ # (no condition) OR (has_else=True)
70
+ blocker_found = False
71
+ for t in transitions:
72
+ lbl = getattr(t, 'label', '') or ''
73
+ if blocker_found:
74
+ report.unreachable_labels.append(lbl)
75
+ continue
76
+ er = getattr(t, 'early_return', False)
77
+ cond = (getattr(t, 'condition', '') or '').strip()
78
+ has_else = getattr(t, 'has_else', True)
79
+ if er and (not cond or has_else):
80
+ blocker_found = True
81
+
82
+ # ---- 2. Duplicate targets ----
83
+ target_labels: Dict[str, List[str]] = {}
84
+ for t in transitions:
85
+ target = getattr(t, 'target', '') or ''
86
+ if target:
87
+ lbl = getattr(t, 'label', '') or ''
88
+ target_labels.setdefault(target, []).append(lbl)
89
+
90
+ for tgt, labels in target_labels.items():
91
+ if len(labels) > 1:
92
+ report.duplicate_targets[tgt] = labels
93
+
94
+ # ---- 3. Overlap pairs (same condition string) ----
95
+ seen: Dict[str, str] = {}
96
+ for t in transitions:
97
+ cond = (getattr(t, 'condition', '') or '').strip()
98
+ if not cond:
99
+ continue
100
+ lbl = getattr(t, 'label', '') or ''
101
+ if cond in seen:
102
+ report.overlap_pairs.append((seen[cond], lbl))
103
+ else:
104
+ seen[cond] = lbl
105
+
106
+ return report
107
+
108
+ # ==================================================================
109
+ # State-graph analysis
110
+ # ==================================================================
111
+ def analyze_state_graph(self, sm: StateMachine) -> StateGraphReport:
112
+ """Analyze the full state graph."""
113
+ report = StateGraphReport()
114
+
115
+ all_states: Set[str] = set(sm.states.keys())
116
+ if not all_states:
117
+ return report
118
+
119
+ # ---- Build outgoing-edge map ----
120
+ outgoing: Dict[str, Set[str]] = {s: set() for s in all_states}
121
+ for t in sm.transitions:
122
+ src = getattr(t, 'source', '') or ''
123
+ tgt = getattr(t, 'target', '') or ''
124
+ if src in outgoing and tgt:
125
+ outgoing[src].add(tgt)
126
+ # else_target does not affect reachability (else is fallback),
127
+ # but treat it as an outgoing edge too for terminal detection.
128
+ etgt = getattr(t, 'else_target', '') or ''
129
+ if src in outgoing and etgt:
130
+ outgoing[src].add(etgt)
131
+
132
+ # ---- Reachability from initial ----
133
+ initial = getattr(sm, 'initial_state', None)
134
+ reachable: Set[str] = set()
135
+ if initial and initial in all_states:
136
+ stack = [initial]
137
+ while stack:
138
+ s = stack.pop()
139
+ if s in reachable:
140
+ continue
141
+ reachable.add(s)
142
+ for nxt in outgoing.get(s, ()): # type: ignore
143
+ if nxt not in reachable:
144
+ stack.append(nxt)
145
+
146
+ report.unreachable_states = sorted(all_states - reachable)
147
+
148
+ # ---- Terminal states (no outgoing edges) ----
149
+ report.terminal_states = sorted(
150
+ s for s in all_states if not outgoing.get(s)
151
+ )
152
+
153
+ # ---- Self loops ----
154
+ self_loops: List[Tuple[str, str]] = []
155
+ for t in sm.transitions:
156
+ src = getattr(t, 'source', '') or ''
157
+ tgt = getattr(t, 'target', '') or ''
158
+ evt = getattr(t, 'event', '') or ''
159
+ if src and tgt and src == tgt:
160
+ self_loops.append((src, evt))
161
+ report.self_loops = self_loops
162
+
163
+ return report