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,724 @@
1
+ # codegen/validate/prompt_generator.py
2
+ """
3
+ AI prompt generation class
4
+
5
+ [v2.8.0 / SPEC_AI_PROMPT_v1.1]
6
+ - Full prompt refresh (XML-style tags).
7
+ - `_format_data` extended to include role_functions, cells, and
8
+ global_definitions (previously missing).
9
+ - `_format_validation` now emits structured <issue> elements
10
+ (severity / category / code / target / suggestion).
11
+ - `_format_actions` renders the 17 ACTION_DEFINITIONS as XML.
12
+ - Reserved role-function fields and used_* symbol trackers are
13
+ deliberately NOT sent (not consumed by codegen / not actionable).
14
+ - Backward compatible: `generate_diagnosis_prompt` signature is
15
+ unchanged; the legacy FEW_SHOT_EXAMPLE / VALIDATION_POINTS
16
+ constants remain importable from prompt_templates.
17
+
18
+ [v2.8.0-fix1]
19
+ - `_format_cells` now returns " <cells/>" for empty / missing
20
+ sections, consistent with every other <context> sub-builder.
21
+
22
+ [v2.8.0-fix2]
23
+ - `_format_global_definitions` now skips a pristine `timer_base`
24
+ (a freshly constructed GlobalDefinitions() has a default
25
+ TimerBaseDef that carries no user information). New helper
26
+ `_has_timer_content` decides whether the timer section is
27
+ worth emitting.
28
+ """
29
+
30
+ import sys
31
+ import os
32
+ from typing import Optional
33
+
34
+ sys.path.append(
35
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
36
+ )
37
+
38
+ from .logger import logger
39
+ from .data.prompt_templates import (
40
+ PROMPT_TEMPLATES,
41
+ OUTPUT_SCHEMA,
42
+ FEW_SHOT_EXAMPLE_1,
43
+ FEW_SHOT_EXAMPLE_2,
44
+ )
45
+ from .data.action_definitions import ACTION_DEFINITIONS
46
+
47
+
48
+ # ======================================================================
49
+ # XML attribute escaping
50
+ # ======================================================================
51
+ def _esc(value) -> str:
52
+ """Escape a value for safe inclusion in an XML attribute."""
53
+ if value is None:
54
+ return ""
55
+ s = str(value)
56
+ return (s.replace("&", "&amp;")
57
+ .replace("<", "&lt;")
58
+ .replace(">", "&gt;")
59
+ .replace('"', "&quot;"))
60
+
61
+
62
+ def _bool_attr(value: bool) -> str:
63
+ return "true" if value else "false"
64
+
65
+
66
+ # ======================================================================
67
+ # Normalization helpers (mirrors xml_io._normalize_actions)
68
+ # ======================================================================
69
+ def _as_str_list(value):
70
+ """Return a clean list of strings for legacy List[str] fields."""
71
+ if value is None:
72
+ return []
73
+ if isinstance(value, str):
74
+ s = value.strip()
75
+ return [s] if s else []
76
+ if isinstance(value, list):
77
+ return [str(x) for x in value if str(x).strip()]
78
+ return [str(value)]
79
+
80
+
81
+ class AIPromptGenerator:
82
+ """AI prompt generation class."""
83
+
84
+ def __init__(self):
85
+ logger.debug("AIPromptGenerator.__init__ started")
86
+ self.templates = PROMPT_TEMPLATES
87
+ self.output_schema = OUTPUT_SCHEMA
88
+ self.example_1 = FEW_SHOT_EXAMPLE_1
89
+ self.example_2 = FEW_SHOT_EXAMPLE_2
90
+ logger.debug("AIPromptGenerator.__init__ completed")
91
+
92
+ # ------------------------------------------------------------------
93
+ # Public entry point
94
+ # ------------------------------------------------------------------
95
+ def generate_diagnosis_prompt(self, sm, gd, validation_result=None) -> str:
96
+ """Build the full diagnosis prompt as a single string."""
97
+ template = self.templates['diagnosis']['template']
98
+ return template.format(
99
+ output_schema=self.output_schema,
100
+ example_1=self.example_1,
101
+ example_2=self.example_2,
102
+ context=self._format_data(sm, gd),
103
+ validation=self._format_validation(validation_result),
104
+ actions=self._format_actions(),
105
+ )
106
+
107
+ # ------------------------------------------------------------------
108
+ # <context> builder
109
+ # ------------------------------------------------------------------
110
+ def _format_data(self, sm, gd) -> str:
111
+ """Build the inner content of <context>...</context> as XML.
112
+
113
+ Includes: <layer>, <states>, <events>, <transitions>,
114
+ <role_functions>, <cells>, <global_definitions>.
115
+ Empty sections are emitted as self-closing tags so the AI
116
+ can tell them apart from a missing section.
117
+ """
118
+ parts = [
119
+ self._format_layer(sm),
120
+ self._format_states(sm),
121
+ self._format_events(sm),
122
+ self._format_transitions(sm),
123
+ self._format_role_functions(sm),
124
+ self._format_cells(sm),
125
+ self._format_global_definitions(gd),
126
+ ]
127
+ return "\n".join(p for p in parts if p)
128
+
129
+ # ---------- <layer> ----------
130
+ def _format_layer(self, sm) -> str:
131
+ name = getattr(sm, 'layer_name', '') or 'Untitled'
132
+ priority = getattr(sm, 'layer_priority', 5)
133
+ attrs = [f'name="{_esc(name)}"', f'priority="{priority}"']
134
+ initial = getattr(sm, 'initial_state', None)
135
+ if initial:
136
+ attrs.append(f'initial="{_esc(initial)}"')
137
+ desc = getattr(sm, 'layer_description', '') or ''
138
+ if desc:
139
+ attrs.append(f'description="{_esc(desc)}"')
140
+ return f" <layer {' '.join(attrs)}/>"
141
+
142
+ # ---------- <states> ----------
143
+ def _format_states(self, sm) -> str:
144
+ states = getattr(sm, 'states', None) or {}
145
+ if not states:
146
+ return " <states/>"
147
+ lines = [" <states>"]
148
+ for state in states.values():
149
+ attrs = [f'name="{_esc(state.name)}"',
150
+ f'type="{state.type.value}"']
151
+ if state.parent:
152
+ attrs.append(f'parent="{_esc(state.parent)}"')
153
+ if state.description:
154
+ attrs.append(f'description="{_esc(state.description)}"')
155
+
156
+ entry = self._format_action_list(getattr(state, 'entry', []) or [])
157
+ exit_ = self._format_action_list(getattr(state, 'exit', []) or [])
158
+ do = self._format_action_list(
159
+ getattr(state, 'do_actions', []) or [])
160
+
161
+ if not (entry or exit_ or do):
162
+ lines.append(f" <state {' '.join(attrs)}/>")
163
+ continue
164
+
165
+ lines.append(f" <state {' '.join(attrs)}>")
166
+ if entry:
167
+ lines.append(" <entry>")
168
+ lines.extend(entry)
169
+ lines.append(" </entry>")
170
+ if exit_:
171
+ lines.append(" <exit>")
172
+ lines.extend(exit_)
173
+ lines.append(" </exit>")
174
+ if do:
175
+ lines.append(" <do>")
176
+ lines.extend(do)
177
+ lines.append(" </do>")
178
+ lines.append(" </state>")
179
+ lines.append(" </states>")
180
+ return "\n".join(lines)
181
+
182
+ def _format_action_list(self, actions) -> list:
183
+ """Render List[ActionStep] as indented <action/> lines.
184
+
185
+ Simple role-only actions are emitted as
186
+ `<action role_function="X"/>` to match xml_io's canonical
187
+ output. action_type="custom" is skipped: it is user code,
188
+ not an AI-actionable change.
189
+ """
190
+ result = []
191
+ for a in actions or []:
192
+ role = getattr(a, 'role_function', '') or ''
193
+ cond = getattr(a, 'condition', '') or ''
194
+ atype = getattr(a, 'action_type', 'role') or 'role'
195
+ ename = getattr(a, 'event_name', '') or ''
196
+
197
+ if atype == 'custom':
198
+ continue
199
+
200
+ attrs = []
201
+ if atype == 'fire_event':
202
+ attrs.append('action_type="fire_event"')
203
+ if ename:
204
+ attrs.append(f'event_name="{_esc(ename)}"')
205
+ if not ename:
206
+ continue
207
+ else:
208
+ if not role:
209
+ continue
210
+ attrs.append(f'role_function="{_esc(role)}"')
211
+
212
+ if cond:
213
+ attrs.append(f'condition="{_esc(cond)}"')
214
+
215
+ result.append(f' <action {" ".join(attrs)}/>')
216
+ return result
217
+
218
+ # ---------- <events> ----------
219
+ def _format_events(self, sm) -> str:
220
+ events = getattr(sm, 'events', None) or {}
221
+ if not events:
222
+ return " <events/>"
223
+ lines = [" <events>"]
224
+ for event in events.values():
225
+ attrs = [
226
+ f'name="{_esc(event.name)}"',
227
+ f'kind="{event.kind.value}"',
228
+ f'delivery_type="{event.delivery_type.value}"',
229
+ f'source_layer="{event.source_layer.value}"',
230
+ ]
231
+ if event.description:
232
+ attrs.append(f'description="{_esc(event.description)}"')
233
+
234
+ trigger = self._format_trigger(event)
235
+ if trigger:
236
+ lines.append(f" <event {' '.join(attrs)}>")
237
+ lines.append(trigger)
238
+ lines.append(" </event>")
239
+ else:
240
+ lines.append(f" <event {' '.join(attrs)}/>")
241
+ lines.append(" </events>")
242
+ return "\n".join(lines)
243
+
244
+ def _format_trigger(self, event) -> str:
245
+ """Render an <event>'s trigger, type-specifically."""
246
+ td = getattr(event, 'trigger_detail', None)
247
+ trigger_text = getattr(event, 'trigger', '') or ''
248
+
249
+ if td is None:
250
+ if not trigger_text:
251
+ return ""
252
+ return (
253
+ ' <trigger type="manual" '
254
+ f'description="{_esc(trigger_text)}"/>'
255
+ )
256
+
257
+ t = (getattr(td, 'type', 'manual') or 'manual').lower()
258
+ attrs = [f'type="{_esc(t)}"']
259
+
260
+ def _opt(name, val):
261
+ if val:
262
+ attrs.append(f'{name}="{_esc(val)}"')
263
+
264
+ def _int(name, val):
265
+ if val:
266
+ attrs.append(f'{name}="{int(val)}"')
267
+
268
+ if t == "edge":
269
+ _opt("source", getattr(td, 'source', ''))
270
+ _opt("edge", getattr(td, 'edge', ''))
271
+ _int("debounce_ms", getattr(td, 'debounce_ms', 0))
272
+ _opt("description", getattr(td, 'description', ''))
273
+ elif t == "polling":
274
+ _opt("source", getattr(td, 'source', ''))
275
+ _int("period_ms", getattr(td, 'period_ms', 0))
276
+ _opt("description", getattr(td, 'description', ''))
277
+ elif t == "timer":
278
+ _opt("source", getattr(td, 'source', ''))
279
+ _int("period_ms", getattr(td, 'period_ms', 0))
280
+ if not getattr(td, 'auto_reload', True):
281
+ attrs.append('auto_reload="false"')
282
+ _opt("description", getattr(td, 'description', ''))
283
+ elif t == "call":
284
+ _opt("caller", getattr(td, 'caller', ''))
285
+ _opt("description", getattr(td, 'description', ''))
286
+ elif t == "comparison":
287
+ _opt("condition", getattr(td, 'condition', ''))
288
+ _int("poll_period_ms", getattr(td, 'poll_period_ms', 0))
289
+ _opt("description", getattr(td, 'description', ''))
290
+ else: # manual / unknown
291
+ _opt("source", getattr(td, 'source', ''))
292
+ _opt("description", getattr(td, 'description', ''))
293
+
294
+ return f' <trigger {" ".join(attrs)}/>'
295
+
296
+ # ---------- <transitions> ----------
297
+ def _format_transitions(self, sm) -> str:
298
+ transitions = getattr(sm, 'transitions', None) or []
299
+ if not transitions:
300
+ return " <transitions/>"
301
+ lines = [" <transitions>"]
302
+ for t in transitions:
303
+ attrs = [
304
+ f'source="{_esc(t.source)}"',
305
+ f'event="{_esc(t.event)}"',
306
+ f'target="{_esc(t.target)}"',
307
+ ]
308
+ if t.condition:
309
+ attrs.append(f'condition="{_esc(t.condition)}"')
310
+ attrs.append(f'early_return="{_bool_attr(t.early_return)}"')
311
+ if t.label:
312
+ attrs.append(f'label="{_esc(t.label)}"')
313
+ attrs.append(f'has_else="{_bool_attr(t.has_else)}"')
314
+ if t.else_target:
315
+ attrs.append(f'else_target="{_esc(t.else_target)}"')
316
+
317
+ pre = _as_str_list(getattr(t, 'pre_actions', []))
318
+ els = _as_str_list(getattr(t, 'else_actions', []))
319
+
320
+ if not (pre or els):
321
+ lines.append(f" <transition {' '.join(attrs)}/>")
322
+ continue
323
+
324
+ lines.append(f" <transition {' '.join(attrs)}>")
325
+ if pre:
326
+ lines.append(" <pre_actions>")
327
+ for p in pre:
328
+ lines.append(f' <action name="{_esc(p)}"/>')
329
+ lines.append(" </pre_actions>")
330
+ if els:
331
+ lines.append(" <else_actions>")
332
+ for e in els:
333
+ lines.append(f' <action name="{_esc(e)}"/>')
334
+ lines.append(" </else_actions>")
335
+ lines.append(" </transition>")
336
+ lines.append(" </transitions>")
337
+ return "\n".join(lines)
338
+
339
+ # ---------- <role_functions> ----------
340
+ def _format_role_functions(self, sm) -> str:
341
+ """Send only name / namespace / description.
342
+
343
+ Reserved fields (return_type, arg1_*, arg2_*) and GUI-only
344
+ symbol trackers (used_*) are deliberately omitted:
345
+ - Reserved fields do not affect codegen or the prompt's
346
+ semantic content.
347
+ - used_* mirrors are GUI metadata, not actionable by AI.
348
+ """
349
+ rfs = getattr(sm, 'role_functions', None) or {}
350
+ if not rfs:
351
+ return " <role_functions/>"
352
+ lines = [" <role_functions>"]
353
+ for rf in rfs.values():
354
+ attrs = [f'name="{_esc(rf.name)}"']
355
+ ns = getattr(rf, 'namespace', '') or ''
356
+ if ns:
357
+ attrs.append(f'namespace="{_esc(ns)}"')
358
+ desc = getattr(rf, 'description', '') or ''
359
+ if desc:
360
+ attrs.append(f'description="{_esc(desc)}"')
361
+ lines.append(f" <role_function {' '.join(attrs)}/>")
362
+ lines.append(" </role_functions>")
363
+ return "\n".join(lines)
364
+
365
+ # ---------- <cells> ----------
366
+ def _format_cells(self, sm) -> str:
367
+ """Render <cells>. Empty/missing section still emits <cells/>.
368
+
369
+ [v2.8.0-fix1]
370
+ Consistency: every other <context> sub-builder emits a
371
+ self-closing tag for empty sections. This method now matches
372
+ that convention so the AI can always tell whether a section
373
+ was intentionally empty vs. missing entirely.
374
+ """
375
+ try:
376
+ cell_keys = sm.get_cell_keys()
377
+ except AttributeError:
378
+ return " <cells/>"
379
+ if not cell_keys:
380
+ return " <cells/>"
381
+
382
+ lines = [" <cells>"]
383
+ for (source, event) in cell_keys:
384
+ lines.append(
385
+ f' <cell source="{_esc(source)}" event="{_esc(event)}">'
386
+ )
387
+
388
+ actions = []
389
+ try:
390
+ actions = sm.get_actions_for_cell(source, event) or []
391
+ except Exception:
392
+ actions = []
393
+ relations = []
394
+ try:
395
+ relations = sm.get_relations_for_cell(source, event) or []
396
+ except Exception:
397
+ relations = []
398
+
399
+ if actions:
400
+ lines.append(" <actions>")
401
+ for a in actions:
402
+ a_attrs = []
403
+ role = getattr(a, 'role_function', '') or ''
404
+ trig = getattr(a, 'trigger', 'before_transitions') \
405
+ or 'before_transitions'
406
+ if role:
407
+ a_attrs.append(f'role_function="{_esc(role)}"')
408
+ if trig and trig != 'before_transitions':
409
+ a_attrs.append(f'trigger="{_esc(trig)}"')
410
+ if not a_attrs:
411
+ continue
412
+ lines.append(f' <action {" ".join(a_attrs)}/>')
413
+ lines.append(" </actions>")
414
+
415
+ if relations:
416
+ lines.append(" <relations>")
417
+ for r in relations:
418
+ lines.extend(self._format_relation(r, indent=8))
419
+ lines.append(" </relations>")
420
+
421
+ lines.append(" </cell>")
422
+ lines.append(" </cells>")
423
+ return "\n".join(lines)
424
+
425
+ def _format_relation(self, rel, indent: int) -> list:
426
+ pad = ' ' * indent
427
+ attrs = [f'kind="{_esc(getattr(rel, "kind", "sequential"))}"']
428
+ members = getattr(rel, 'members', None) or []
429
+ if members:
430
+ joined = ",".join(_esc(m) for m in members)
431
+ attrs.append(f'members="{joined}"')
432
+ shared = getattr(rel, 'shared_condition', '') or ''
433
+ if shared:
434
+ attrs.append(f'shared_condition="{_esc(shared)}"')
435
+
436
+ children = getattr(rel, 'children', None) or []
437
+ if not children:
438
+ return [f'{pad}<relation {" ".join(attrs)}/>']
439
+
440
+ result = [f'{pad}<relation {" ".join(attrs)}>']
441
+ for c in children:
442
+ result.extend(self._format_relation(c, indent + 2))
443
+ result.append(f'{pad}</relation>')
444
+ return result
445
+
446
+ def _timer_variable_names(self, gd) -> set:
447
+ """Names of variables auto-generated by add_timer_variables().
448
+
449
+ [v2.8.0-fix3]
450
+ GlobalDefinitions.__init__ calls add_timer_variables(),
451
+ which registers the timer_base and derived timers into
452
+ gd.variables so the code generator can emit them. Those
453
+ entries are already shown in <timers>, so we exclude them
454
+ from <variables> to avoid duplication and to allow an
455
+ empty GlobalDefinitions() to collapse to <global_definitions/>.
456
+ """
457
+ names = set()
458
+ tb = getattr(gd, 'timer_base', None)
459
+ if tb is not None:
460
+ if getattr(tb, 'variable_name', ''):
461
+ names.add(tb.variable_name)
462
+ for d in getattr(tb, 'derived', None) or []:
463
+ if getattr(d, 'variable_name', ''):
464
+ names.add(d.variable_name)
465
+ for et in getattr(gd, 'extra_timers', None) or []:
466
+ if getattr(et, 'variable_name', ''):
467
+ names.add(et.variable_name)
468
+ for d in getattr(et, 'derived', None) or []:
469
+ if getattr(d, 'variable_name', ''):
470
+ names.add(d.variable_name)
471
+ return names
472
+
473
+ # ---------- <global_definitions> ----------
474
+ def _format_global_definitions(self, gd) -> str:
475
+ if gd is None:
476
+ return " <global_definitions/>"
477
+
478
+ sections = []
479
+
480
+ # Variables
481
+ # [v2.8.0-fix3] Exclude auto-generated timer variables;
482
+ # they are already shown in <timers>.
483
+ timer_names = self._timer_variable_names(gd)
484
+ variables = [
485
+ v for v in (getattr(gd, 'variables', None) or [])
486
+ if v.name not in timer_names
487
+ ]
488
+ if variables:
489
+ v_lines = [" <variables>"]
490
+ for v in variables:
491
+ v_attrs = [f'name="{_esc(v.name)}"',
492
+ f'type="{_esc(v.type)}"']
493
+ if getattr(v, 'group', ''):
494
+ v_attrs.append(f'group="{_esc(v.group)}"')
495
+ if getattr(v, 'description', ''):
496
+ v_attrs.append(
497
+ f'description="{_esc(v.description)}"')
498
+ v_lines.append(
499
+ f' <variable {" ".join(v_attrs)}/>')
500
+ v_lines.append(" </variables>")
501
+ sections.append("\n".join(v_lines))
502
+
503
+ # Flags
504
+ flags = getattr(gd, 'flags', None) or []
505
+ if flags:
506
+ f_lines = [" <flags>"]
507
+ for f in flags:
508
+ f_attrs = [
509
+ f'name="{_esc(f.name)}"',
510
+ f'min_value="{getattr(f, "min_value", 0)}"',
511
+ f'max_value="{getattr(f, "max_value", 0)}"',
512
+ ]
513
+ if getattr(f, 'group', ''):
514
+ f_attrs.append(f'group="{_esc(f.group)}"')
515
+ if getattr(f, 'description', ''):
516
+ f_attrs.append(
517
+ f'description="{_esc(f.description)}"')
518
+ f_lines.append(f' <flag {" ".join(f_attrs)}/>')
519
+ f_lines.append(" </flags>")
520
+ sections.append("\n".join(f_lines))
521
+
522
+ # Interrupts
523
+ interrupts = getattr(gd, 'interrupts', None) or []
524
+ if interrupts:
525
+ i_lines = [" <interrupts>"]
526
+ for intr in interrupts:
527
+ i_attrs = [f'name="{_esc(intr.name)}"']
528
+ evs = getattr(intr, 'event_names', None) or []
529
+ if evs:
530
+ i_attrs.append(
531
+ f'event_names="{",".join(_esc(e) for e in evs)}"')
532
+ if getattr(intr, 'is_timer', False):
533
+ i_attrs.append('is_timer="true"')
534
+ if getattr(intr, 'description', ''):
535
+ i_attrs.append(
536
+ f'description="{_esc(intr.description)}"')
537
+
538
+ actions = getattr(intr, 'actions', None) or []
539
+ if not actions:
540
+ i_lines.append(
541
+ f' <interrupt {" ".join(i_attrs)}/>')
542
+ else:
543
+ i_lines.append(
544
+ f' <interrupt {" ".join(i_attrs)}>')
545
+ for act in actions:
546
+ a_attrs = []
547
+ if getattr(act, 'condition', ''):
548
+ a_attrs.append(
549
+ f'condition="{_esc(act.condition)}"')
550
+ if getattr(act, 'action', ''):
551
+ a_attrs.append(
552
+ f'action="{_esc(act.action)}"')
553
+ if a_attrs:
554
+ i_lines.append(
555
+ f' <action {" ".join(a_attrs)}/>')
556
+ i_lines.append(" </interrupt>")
557
+ i_lines.append(" </interrupts>")
558
+ sections.append("\n".join(i_lines))
559
+
560
+ # Timers (timer_base + extra_timers)
561
+ # [v2.8.0-fix2] A freshly constructed GlobalDefinitions() has
562
+ # a default timer_base with only pristine values. That carries
563
+ # no information for the AI, so skip it. Only emit the timers
564
+ # section when the timer_base has been customized or when
565
+ # extra timers exist.
566
+ tb = getattr(gd, 'timer_base', None)
567
+ if tb is not None and not self._has_timer_content(tb):
568
+ tb = None
569
+ extra = getattr(gd, 'extra_timers', None) or []
570
+ if tb is not None or extra:
571
+ t_lines = [" <timers>"]
572
+ if tb is not None:
573
+ t_lines.extend(self._format_timer(tb, 'timer_base', 6))
574
+ for et in extra:
575
+ t_lines.extend(self._format_timer(et, 'extra_timer', 6))
576
+ t_lines.append(" </timers>")
577
+ sections.append("\n".join(t_lines))
578
+
579
+ # Event queues
580
+ queues = getattr(gd, 'event_queues', None) or []
581
+ if queues:
582
+ q_lines = [" <event_queues>"]
583
+ for q in queues:
584
+ q_attrs = [
585
+ f'name="{_esc(q.name)}"',
586
+ f'size="{getattr(q, "size", 8)}"',
587
+ f'element_type="{_esc(getattr(q, "element_type", "uint8_t"))}"',
588
+ ]
589
+ if getattr(q, 'priority_enabled', False):
590
+ q_attrs.append('priority_enabled="true"')
591
+ if not getattr(q, 'interrupt_safe', True):
592
+ q_attrs.append('interrupt_safe="false"')
593
+ if getattr(q, 'rtos_enabled', False):
594
+ q_attrs.append('rtos_enabled="true"')
595
+ if getattr(q, 'description', ''):
596
+ q_attrs.append(
597
+ f'description="{_esc(q.description)}"')
598
+ q_lines.append(f' <queue {" ".join(q_attrs)}/>')
599
+ q_lines.append(" </event_queues>")
600
+ sections.append("\n".join(q_lines))
601
+
602
+ if not sections:
603
+ return " <global_definitions/>"
604
+
605
+ return (" <global_definitions>\n"
606
+ + "\n".join(sections)
607
+ + "\n </global_definitions>")
608
+
609
+ def _has_timer_content(self, tb) -> bool:
610
+ """Return True if the timer_base has user-meaningful content.
611
+
612
+ [v2.8.0-fix2]
613
+ A freshly constructed GlobalDefinitions() has a default
614
+ TimerBaseDef with only pristine values. Those defaults
615
+ carry no information for the AI, so a pristine timer_base
616
+ can be skipped in <global_definitions>. Any deviation
617
+ from the defaults (or any derived timers) makes this
618
+ method return True.
619
+
620
+ [v2.8.0-fix4]
621
+ `TimerBaseDef.title` is auto-populated to
622
+ "Timer base: <variable_name>" by __post_init__, so it is
623
+ NOT a reliable indicator of user customization. The title
624
+ field is excluded from the pristine comparison; the
625
+ semantic identity of a timer is determined by
626
+ variable_name / unit / data_type / derived.
627
+ """
628
+ if getattr(tb, 'derived', None):
629
+ return True
630
+ defaults = {
631
+ 'variable_name': 'g_system_tick',
632
+ 'unit': '1ms',
633
+ 'data_type': 'volatile uint32_t',
634
+ 'interrupt_name': '',
635
+ }
636
+ for attr, expected in defaults.items():
637
+ if getattr(tb, attr, '') != expected:
638
+ return True
639
+ return False
640
+
641
+ def _format_timer(self, timer, tag: str, indent: int) -> list:
642
+ pad = ' ' * indent
643
+ attrs = [f'variable_name="{_esc(timer.variable_name)}"']
644
+ if getattr(timer, 'unit', ''):
645
+ attrs.append(f'unit="{_esc(timer.unit)}"')
646
+ if getattr(timer, 'data_type', ''):
647
+ attrs.append(f'data_type="{_esc(timer.data_type)}"')
648
+
649
+ derived = getattr(timer, 'derived', None) or []
650
+ if not derived:
651
+ return [f'{pad}<{tag} {" ".join(attrs)}/>']
652
+
653
+ result = [f'{pad}<{tag} {" ".join(attrs)}>']
654
+ for d in derived:
655
+ d_attrs = [
656
+ f'period_name="{_esc(d.period_name)}"',
657
+ f'multiplier="{getattr(d, "multiplier", 1)}"',
658
+ ]
659
+ if getattr(d, 'variable_name', ''):
660
+ d_attrs.append(
661
+ f'variable_name="{_esc(d.variable_name)}"')
662
+ if getattr(d, 'data_type', ''):
663
+ d_attrs.append(f'data_type="{_esc(d.data_type)}"')
664
+ result.append(f'{pad} <derived {" ".join(d_attrs)}/>')
665
+ result.append(f'{pad}</{tag}>')
666
+ return result
667
+
668
+ # ------------------------------------------------------------------
669
+ # <validation> builder
670
+ # ------------------------------------------------------------------
671
+ def _format_validation(self, validation_result) -> str:
672
+ """Emit each issue as a structured <issue> element.
673
+
674
+ Only category/code/target/suggestion are guaranteed to be
675
+ ASCII-safe metadata; `message` is passed through verbatim
676
+ (may be localized) so the AI can use it as context.
677
+ """
678
+ issues = []
679
+ if validation_result is not None:
680
+ issues = getattr(validation_result, 'issues', None) or []
681
+
682
+ if not issues:
683
+ return " <!-- No issues -->"
684
+
685
+ lines = []
686
+ for issue in issues:
687
+ sev = issue.severity.value.upper()
688
+ attrs = [f'severity="{sev}"']
689
+ if issue.category:
690
+ attrs.append(f'category="{_esc(issue.category)}"')
691
+ if issue.code:
692
+ attrs.append(f'code="{_esc(issue.code)}"')
693
+ if issue.target:
694
+ attrs.append(f'target="{_esc(issue.target)}"')
695
+ if issue.suggestion:
696
+ attrs.append(
697
+ f'suggestion="{_esc(issue.suggestion)}"')
698
+ msg = _esc(issue.message or '')
699
+ lines.append(f' <issue {" ".join(attrs)}>{msg}</issue>')
700
+ return "\n".join(lines)
701
+
702
+ # ------------------------------------------------------------------
703
+ # <actions> builder
704
+ # ------------------------------------------------------------------
705
+ def _format_actions(self) -> str:
706
+ """Render the 17 ACTION_DEFINITIONS as XML."""
707
+ lines = []
708
+ for name, definition in ACTION_DEFINITIONS.items():
709
+ desc = _esc(definition.get('description', ''))
710
+ lines.append(
711
+ f' <action name="{_esc(name)}" description="{desc}">'
712
+ )
713
+ for pname, pinfo in definition.get('params', {}).items():
714
+ p_attrs = [
715
+ f'name="{_esc(pname)}"',
716
+ f'type="{_esc(pinfo.get("type", "str"))}"',
717
+ f'required="{_bool_attr(bool(pinfo.get("required")))}"',
718
+ ]
719
+ if 'values' in pinfo:
720
+ vals = ",".join(_esc(v) for v in pinfo['values'])
721
+ p_attrs.append(f'values="{vals}"')
722
+ lines.append(f' <param {" ".join(p_attrs)}/>')
723
+ lines.append(' </action>')
724
+ return "\n".join(lines)