@aryacode/dsh-engineering 0.4.7 → 0.4.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/README.md +40 -26
  3. package/cordis.patch.yml +7 -6
  4. package/entries/config.js +62 -0
  5. package/entries/ftview.js +5 -4
  6. package/entries/l5x.js +47 -15
  7. package/entries/mwi.js +26 -6
  8. package/entries/optix.js +105 -0
  9. package/lib/client.js +335 -6
  10. package/package.json +11 -10
  11. package/vendor/ftview/SKILL.md +17 -0
  12. package/vendor/l5x/SKILL.md +17 -0
  13. package/vendor/l5x/studio-bridge/NuGet.Config +1 -1
  14. package/vendor/mwi/README.md +41 -18
  15. package/vendor/mwi/SKILL.md +39 -20
  16. package/vendor/mwi/bridge/mw_bridge.ps1 +2768 -2763
  17. package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -30
  18. package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -22
  19. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  20. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
  21. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
  22. package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -339
  23. package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -765
  24. package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -399
  25. package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +313 -313
  26. package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -520
  27. package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -133
  28. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -672
  29. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -86
  30. package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -208
  31. package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +134 -130
  32. package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +673 -673
  33. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -790
  34. package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +642 -642
  35. package/vendor/mwi/lib/core/code-engine.d.ts +13 -0
  36. package/vendor/mwi/lib/core/code-engine.js +18 -1
  37. package/vendor/mwi/lib/edit.d.ts +1 -1
  38. package/vendor/mwi/lib/edit.js +1 -1
  39. package/vendor/mwi/lib/read/service.js +8 -1
  40. package/vendor/mwi/lib/session/backup.d.ts +72 -0
  41. package/vendor/mwi/lib/session/backup.js +257 -0
  42. package/vendor/mwi/lib/session/project.d.ts +4 -1
  43. package/vendor/mwi/lib/session/project.js +13 -2
  44. package/vendor/mwi/lib/session/service.d.ts +13 -6
  45. package/vendor/mwi/lib/session/service.js +127 -36
  46. package/vendor/mwi/lib/session/workspace.d.ts +10 -0
  47. package/vendor/mwi/lib/session/workspace.js +53 -3
  48. package/vendor/mwi/lib/skill.d.ts +2 -2
  49. package/vendor/mwi/lib/skill.js +2 -2
  50. package/vendor/mwi/lib/tools/edit.d.ts +3 -3
  51. package/vendor/mwi/lib/tools/edit.js +14 -15
  52. package/vendor/mwi/lib/tools/read.js +1 -1
  53. package/vendor/mwi/lib/tools/session.js +13 -9
  54. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -81
  55. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -38
  56. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -3
  57. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -67
  58. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
  59. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -2
  60. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -2
  61. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -2
  62. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -2
  63. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -2
  64. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +26 -26
  65. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
  66. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -2
  67. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -2
  68. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -57
  69. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -3
  70. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -3
  71. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -7
  72. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -3
  73. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -2
  74. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -16
  75. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
  76. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -2
  77. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +250 -250
  78. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
  79. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +9 -9
  80. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -19
  81. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -172
  82. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -21
  83. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -20
  84. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -11
  85. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
  86. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -26
  87. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -97
  88. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -38
  89. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -3
  90. package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -67
  91. package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
  92. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -2
  93. package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -2
  94. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -2
  95. package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -2
  96. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -2
  97. package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +26 -26
  98. package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
  99. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -2
  100. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -2
  101. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -57
  102. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -3
  103. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -3
  104. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -7
  105. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -3
  106. package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -444
  107. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -81
  108. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
  109. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -2
  110. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -163
  111. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -1663
  112. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -2471
  113. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -218
  114. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -524
  115. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -690
  116. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -1265
  117. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +252 -252
  118. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
  119. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -19
  120. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +9 -9
  121. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -19
  122. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -172
  123. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -21
  124. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -74
  125. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -420
  126. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -34
  127. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -47
  128. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -153
  129. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -118
  130. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -166
  131. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -56
  132. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -138
  133. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -87
  134. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -139
  135. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -168
  136. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -106
  137. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -37
  138. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -91
  139. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -93
  140. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
  141. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -77
  142. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -111
  143. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -4
  144. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
  145. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
  146. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
  147. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
  148. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
  149. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
  150. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
  151. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
  152. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
  153. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
  154. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
  155. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
  156. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
  157. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
  158. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
  159. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
  160. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
  161. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
  162. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
@@ -1,676 +1,676 @@
1
- """Static checks for MotionWorks IEC Structured Text bodies.
2
-
3
- This linter is deliberately conservative. It reports only *high-confidence*
4
- problems and stays silent whenever the answer depends on information it cannot
5
- see: library types, function-block pin diagrams, struct member layouts, device
6
- I/O configuration, and the internals of other POUs.
7
-
8
- That restraint is the whole design. An earlier revision flagged every member
9
- name and function-block pin (``Axis1.AxisNum``, ``MC.Power``'s ``.Execute``,
10
- ``.Done``, ``.Error``) as an undefined symbol, which produced hundreds of
11
- findings on correct production code. A noisy linter is worse than none, because
12
- an agent will either ignore it or start "fixing" working logic.
13
-
14
- Checks performed:
15
-
16
- ``undefined-assignment-target``
17
- An assignment target (``Name := ...``) is declared nowhere reachable. This
18
- is the highest-value check: it is exactly the mistake an agent makes when it
19
- invents a variable name, and an assignment target is never a member access
20
- unless written as ``Struct.Member := ...``, which is excluded.
21
-
22
- ``unused-local``
23
- A plain ``VAR`` local is never referenced in the executable region.
24
-
25
- ``unassigned-fb-output``
26
- A function-block instance is called but none of its outputs is read, so the
27
- result is discarded. Reported only when the instance has no output-style
28
- member access at all.
29
-
30
- ``unterminated-statement``
31
- A logical statement does not end with ``;``. Statements are joined across
32
- continuation lines first, so multi-line assignments are not misreported.
33
-
34
- Scope note: graphical (``.GB``) bodies are not text and cannot be analysed.
35
- """
36
-
37
- from __future__ import annotations
38
-
39
- import re
40
- from dataclasses import dataclass, field
41
-
42
- #: IEC 61131-3 keywords and operators that must never be reported as symbols.
43
- KEYWORDS = {
44
- "IF", "THEN", "ELSIF", "ELSE", "END_IF", "CASE", "OF", "END_CASE",
45
- "FOR", "TO", "BY", "DO", "END_FOR", "WHILE", "END_WHILE", "REPEAT",
46
- "UNTIL", "END_REPEAT", "RETURN", "EXIT", "CONTINUE", "VAR", "VAR_INPUT",
47
- "VAR_OUTPUT", "VAR_IN_OUT", "VAR_GLOBAL", "VAR_EXTERNAL", "VAR_TEMP",
48
- "END_VAR", "PROGRAM", "FUNCTION", "FUNCTION_BLOCK", "END_PROGRAM",
49
- "END_FUNCTION", "END_FUNCTION_BLOCK", "TYPE", "END_TYPE", "STRUCT",
50
- "END_STRUCT", "AT", "RETAIN", "NON_RETAIN", "CONSTANT", "ARRAY", "AND",
51
- "OR", "XOR", "NOT", "MOD", "TRUE", "FALSE", "NULL",
52
- }
53
-
54
- ELEMENTARY_TYPES = {
55
- "BOOL", "BYTE", "WORD", "DWORD", "LWORD", "SINT", "USINT", "INT", "UINT",
56
- "DINT", "UDINT", "LINT", "ULINT", "REAL", "LREAL", "STRING", "WSTRING",
57
- "TIME", "LTIME", "DATE", "LDATE", "TOD", "LTOD", "DT", "LDT",
58
- }
59
-
60
- _IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
61
- _BLOCK_COMMENT_RE = re.compile(r"\(\*.*?\*\)", re.DOTALL)
62
- _LINE_COMMENT_RE = re.compile(r"//[^\n]*")
63
- _STRING_RE = re.compile(r"'[^'\n]*'")
64
- _TYPED_LITERAL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)#")
65
- #: Identifiers that are the *root* of an expression: not preceded by a dot
66
- #: (so not a member name) and not followed by a dot (so not a struct reference).
67
- _ROOT_IDENT_RE = re.compile(r"(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)\b(?!\s*\.)")
68
- #: An assignment target at the start of a statement, optionally address-prefixed.
69
- #: Retained for reference; statement-level extraction is done by
70
- #: :func:`assignment_targets`, which tracks parenthesis depth.
71
- _MEMBER_NAMES_RE = re.compile(r"\.\s*([A-Za-z_][A-Za-z0-9_]*)")
72
- #: The root of a member access, e.g. ``Axis1`` in ``Axis1.AxisNum``. Deliberately
73
- #: separate from _ROOT_IDENT_RE, which skips these.
74
- _MEMBER_ROOT_RE = re.compile(r"(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)\s*\.")
75
- _CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(")
76
- _END_VAR_RE = re.compile(r"\bEND_VAR\b", re.IGNORECASE)
77
-
78
-
79
- @dataclass
80
- class Finding:
81
- """One lint result."""
82
-
83
- severity: str # "error" | "warning" | "info"
84
- code: str
85
- message: str
86
- line: int | None = None
87
- hint: str | None = None
88
-
89
- def format(self) -> str:
90
- location = f"line {self.line}: " if self.line else ""
91
- text = f"[{self.severity.upper()}] {location}{self.message}"
92
- if self.hint:
93
- text += f"\n hint: {self.hint}"
94
- return text
95
-
96
-
97
- @dataclass
98
- class LintResult:
99
- findings: list[Finding] = field(default_factory=list)
100
- used_symbols: set[str] = field(default_factory=set)
101
- referenced_members: set[str] = field(default_factory=set)
102
-
103
- @property
104
- def errors(self) -> list[Finding]:
105
- return [f for f in self.findings if f.severity == "error"]
106
-
107
- @property
108
- def warnings(self) -> list[Finding]:
109
- return [f for f in self.findings if f.severity == "warning"]
110
-
111
- @property
112
- def ok(self) -> bool:
113
- return not self.errors
114
-
115
- def add(self, severity: str, code: str, message: str, line: int | None = None,
116
- hint: str | None = None) -> None:
117
- self.findings.append(
118
- Finding(severity=severity, code=code, message=message, line=line, hint=hint)
119
- )
120
-
121
-
122
- def strip_comments_and_strings(text: str) -> str:
123
- """Remove comments and string literals, preserving line structure.
124
-
125
- Line count is preserved so reported line numbers stay accurate.
126
- """
127
-
128
- def blank(match: re.Match[str]) -> str:
129
- return re.sub(r"[^\n]", " ", match.group(0))
130
-
131
- return _STRING_RE.sub(blank, _LINE_COMMENT_RE.sub(blank, _BLOCK_COMMENT_RE.sub(blank, text)))
132
-
133
-
134
- def split_code_region(body: str, clean: str) -> tuple[str, str, int]:
135
- """Split a POU into its declaration region and executable region.
136
-
137
- Returns ``(declarations, code, code_start_line)``.
138
-
139
- MotionWorks stores POU declarations in the separate ``<POU>V.VB`` stream,
140
- so the ST body normally contains *only* executable code and there is no
141
- ``END_VAR`` to find. Some other IEC toolchains inline declarations before
142
- the implementation; that form is handled too.
143
- """
144
- matches = list(_END_VAR_RE.finditer(clean))
145
- if not matches:
146
- return "", clean, 1
147
- last = matches[-1]
148
- return clean[: last.start()], clean[last.end():], body.count("\n", 0, last.end()) + 1
149
-
150
-
151
- #: A line that legitimately does not need a trailing semicolon because it opens
152
- #: or closes a block.
153
- _BLOCK_EDGE_RE = re.compile(
154
- r"^(END_|ELSE\b|ELSIF\b|CASE\b|REPEAT$|UNTIL\b|TYPE\b|STRUCT\b|"
155
- r"FOR\b|WHILE\b|IF\b)",
156
- re.IGNORECASE,
157
- )
158
- #: A line whose ending means the statement carries on, so the missing ';' is
159
- #: expected rather than a defect.
160
- _CONTINUATION_TAIL_RE = re.compile(
161
- r"(\:\=|[+\-*/(,=<>&|^]|\b(?:AND|OR|XOR|MOD|NOT|TO|BY))$",
162
- re.IGNORECASE,
163
- )
164
- #: A line ending that completes a statement or a block header. The `:` branch
165
- #: accepts any label-style colon, including a typed-literal CASE label such as
166
- #: ``INT#10:``, while the lookahead excludes the `:` of `:=`, which marks an
167
- #: assignment that continues onto the next line.
168
- _TERMINAL_TAIL_RE = re.compile(
169
- r"(;(?!\s*=)|:(?!\s*=)|\bTHEN$|\bDO$|\bREPEAT$|\bELSE$|"
170
- r"\bEND_IF$|\bEND_CASE$|\bEND_WHILE$|\bEND_FOR$|\bEND_REPEAT$)",
171
- re.IGNORECASE,
172
- )
173
-
174
-
175
- def _next_significant_line(lines: list[str], offset: int) -> str:
176
- """Return the next non-blank line after ``offset``, or an empty string."""
177
- for probe in lines[offset + 1:]:
178
- text = probe.strip()
179
- if text:
180
- return text
181
- return ""
182
-
183
-
184
- def unterminated_lines(code: str, start_line: int) -> list[tuple[int, str]]:
185
- """Find lines that look like they are missing a terminating ``;``.
186
-
187
- MotionWorks ST deliberately spreads one statement over several lines::
188
-
189
- EIP_ToCLX_CLXMasterHealthy :=
190
- xCLXHeartbeatValidated
191
- AND (NOT fbCLXHeartbeatTimeout.Q);
192
-
193
- IF
194
- xCLXMasterHealthyPrev
195
- AND (NOT EIP_ToCLX_CLXMasterHealthy)
196
- THEN
197
-
198
- Only the *opening* line of such a statement could carry a semicolon, and
199
- continuation lines never do. Detection therefore tracks whether the previous
200
- line reached a terminal token (``;``, ``:``, ``THEN``, ``DO``, ``REPEAT``,
201
- ``ELSE``, or an ``END_*``):
202
-
203
- * if it did, this line starts a fresh statement, so a missing terminator is a
204
- real defect and the line is reported;
205
- * if it did not, this line continues the previous statement and is skipped.
206
-
207
- A statement is treated as continuing only when it *ends* with something that
208
- cannot end a statement (an operator or a bare ``:=``), or when it sits inside
209
- an unclosed parenthesis. Mid-line ``:=`` is deliberately not a continuation
210
- signal, because ``X := value`` is a complete statement; treating it as open
211
- would hide a genuinely dropped semicolon.
212
-
213
- One special case is closed explicitly: a complete-looking assignment such as
214
- ``X := Y.Z`` immediately before a block edge (``IF``, ``END_IF``) must be
215
- missing its semicolon, since an expression cannot continue into a block
216
- keyword.
217
- """
218
- findings: list[tuple[int, str]] = []
219
- lines = code.splitlines()
220
- depth = 0
221
- previous_terminal = True # start of region behaves like a fresh statement
222
-
223
- for offset, raw in enumerate(lines):
224
- # Right-strip as well as strip: a line ending in ':=' followed by
225
- # trailing whitespace would defeat the $-anchored checks below.
226
- line = raw.strip().rstrip()
227
- if not line:
228
- # A blank line does not change whether we are mid-statement.
229
- continue
230
-
231
- depth_before = depth
232
- depth += line.count("(") - line.count(")")
233
- opens_group = depth_before != 0 or depth > 0
234
-
235
- terminates = bool(_TERMINAL_TAIL_RE.search(line))
236
- continues = bool(_CONTINUATION_TAIL_RE.search(line))
237
-
238
- # `X := Y.Z` right before a block edge is a dropped semicolon.
239
- if (
240
- not terminates
241
- and not continues
242
- and not opens_group
243
- and ":=" in line
244
- and _BLOCK_EDGE_RE.match(_next_significant_line(lines, offset) or "")
245
- ):
246
- findings.append((start_line + offset, line))
247
- previous_terminal = True
248
- continue
249
-
250
- if (
251
- previous_terminal
252
- and not terminates
253
- and not opens_group
254
- and not _BLOCK_EDGE_RE.match(line)
255
- and not continues
256
- ):
257
- findings.append((start_line + offset, line))
258
-
259
- # `continues` means the statement is still open, so the next line is a
260
- # continuation and must NOT be treated as starting a fresh statement.
261
- # `opens_group` likewise leaves the statement open.
262
- previous_terminal = (not continues) and (not opens_group) and terminates
263
-
264
- return findings
265
-
266
-
267
- def _line_of(text: str, index: int) -> int:
268
- return text.count("\n", 0, index) + 1
269
-
270
-
271
- def assignment_targets(code: str) -> list[tuple[int, str]]:
272
- """Find ``Name := `` at *statement* level, ignoring named FB parameters.
273
-
274
- MotionWorks function blocks are called with named parameters::
275
-
276
- MC_Power_1(Enable := TRUE, Axis := Axis1);
277
-
278
- That ``Enable := TRUE`` sits inside parentheses and is an input pin, not a
279
- variable assignment. Reporting it as an undefined symbol is exactly the
280
- false positive that makes a linter useless, so only assignments at paren
281
- depth zero are treated as statement targets.
282
- """
283
- results: list[tuple[int, str]] = []
284
- i = 0
285
- length = len(code)
286
- while i < length:
287
- char = code[i]
288
- if char == "(":
289
- depth = 1
290
- i += 1
291
- # Skip the entire parenthesised group, including nested ones.
292
- while i < length and depth:
293
- if code[i] == "(":
294
- depth += 1
295
- elif code[i] == ")":
296
- depth -= 1
297
- i += 1
298
- continue
299
- if char == ":" and i + 1 < length and code[i + 1] == "=":
300
- # Walk back over the optional lvalue to its first identifier.
301
- j = i - 1
302
- while j >= 0 and code[j] in " \t":
303
- j -= 1
304
- # Allow an array index on the lvalue.
305
- if j >= 0 and code[j] == "]":
306
- depth = 1
307
- j -= 1
308
- while j >= 0 and depth:
309
- if code[j] == "]":
310
- depth += 1
311
- elif code[j] == "[":
312
- depth -= 1
313
- j -= 1
314
- while j >= 0 and code[j] in " \t":
315
- j -= 1
316
- end = j
317
- while j >= 0 and (code[j].isalnum() or code[j] == "_"):
318
- j -= 1
319
- start = j + 1
320
- name = code[start : end + 1]
321
- if name and (name[0].isalpha() or name[0] == "_"):
322
- results.append((start, name))
323
- i += 2
324
- continue
325
- i += 1
326
- return results
327
-
328
-
329
- def lint(
330
- body: str,
331
- declared: set[str] | None = None,
332
- known_symbols: set[str] | None = None,
333
- fb_instance_types: set[str] | None = None,
334
- local_vars: set[str] | None = None,
335
- project_globals: set[str] | None = None,
336
- ) -> LintResult:
337
- """Lint one Structured Text body.
338
-
339
- Args:
340
- body: Raw ST source.
341
- declared: Every name reachable in this POU (locals plus externals).
342
- known_symbols: Project globals plus every type/library name that could
343
- legitimately appear. Names here are never reported.
344
- fb_instance_types: Names of declared function-block instances.
345
- local_vars: Names declared in a plain ``VAR`` block, used for the
346
- unused-local check. Externals are excluded: they are owned
347
- elsewhere and may legitimately be unused here.
348
- project_globals: Names of the project's VAR_GLOBAL declarations. A
349
- global is reachable ONLY if this POU declares it as VAR_EXTERNAL,
350
- with matching name and type. Compiler behavior depends on context.
351
- """
352
- result = LintResult()
353
- declared_u = {n.upper() for n in (declared or set())}
354
- known_u = {n.upper() for n in (known_symbols or set())}
355
- fb_instances = {n.upper() for n in (fb_instance_types or set())}
356
- # Preserve original casing so messages quote the declared name, not an
357
- # upper-cased comparison key.
358
- declared_display = {n.upper(): n for n in (local_vars or set())}
359
- locals_u = set(declared_display)
360
- globals_u = {n.upper() for n in (project_globals or set())}
361
-
362
- clean = strip_comments_and_strings(body)
363
- if not clean.strip():
364
- return result
365
-
366
- _declarations, code, code_start = split_code_region(body, clean)
367
- if not code.strip():
368
- return result
369
-
370
- # Member names (`.Done`, `.AxisNum`) are never symbols of this POU.
371
- members = {m.group(1).upper() for m in _MEMBER_NAMES_RE.finditer(code)}
372
- result.referenced_members = members
373
-
374
- # Every root identifier in the executable region: a name that is neither
375
- # preceded by a dot (so not a member) nor followed by a dot (so not a struct
376
- # access).
377
- roots = {m.group(1) for m in _ROOT_IDENT_RE.finditer(code)}
378
- # Roots of member accesses, e.g. Axis1 in `Axis1.AxisNum`. These ARE uses of
379
- # the variable, even though the root regex deliberately skips them.
380
- member_roots = {m.group(1) for m in _MEMBER_ROOT_RE.finditer(code)}
381
- invoked = {m.group(1).upper() for m in _CALL_RE.finditer(code)}
382
- # A name used in any of these ways counts as used.
383
- used = {r.upper() for r in roots} | {r.upper() for r in member_roots} | invoked
384
- result.used_symbols = roots | member_roots
385
-
386
- # ------------------------------------------------------------------
387
- # High-confidence check: assignment targets that resolve to nothing.
388
- # ------------------------------------------------------------------
389
- for start, name in assignment_targets(code):
390
- upper = name.upper()
391
- if upper in KEYWORDS or upper in declared_u:
392
- continue
393
- if upper in globals_u:
394
- # THE SILENT STALL. This name exists - it is a project global - so the
395
- # old check let it through via known_u. But MotionWorks only reaches a
396
- # global from a POU that declares it VAR_EXTERNAL, and when it does not
397
- # it does not report an undeclared symbol: the build STALLS with
398
- # is_compiled=false, is_modified=true and an EMPTY Errors pane, so the
399
- # agent sees a hang and no reason. Measured with PLCMODE_ON, a system
400
- # global present in the project from the start.
401
- line = code_start + code.count("\n", 0, start)
402
- result.add(
403
- "error",
404
- "global-not-declared-external",
405
- f"{name!r} is a project global but this POU does not declare it as "
406
- f"VAR_EXTERNAL with a matching resource-global name and type",
407
- line,
408
- hint=(
409
- "Add it to this POU's VAR_EXTERNAL block, or use a variable this "
410
- "POU already declares. Inspect mw_code_read_st and mw_code_globals; "
411
- "use mw_code_reference for VAR_EXTERNAL scope guidance."
412
- ),
413
- )
414
- continue
415
- if upper in known_u:
416
- continue
417
- if upper in ELEMENTARY_TYPES or upper in members:
418
- continue
419
- line = code_start + code.count("\n", 0, start)
420
- result.add(
421
- "error",
422
- "undefined-assignment-target",
423
- f"assignment to {name!r}, which is not declared in this POU, in the "
424
- f"project's globals, or as a known library name",
425
- line,
426
- hint=(
1
+ """Static checks for MotionWorks IEC Structured Text bodies.
2
+
3
+ This linter is deliberately conservative. It reports only *high-confidence*
4
+ problems and stays silent whenever the answer depends on information it cannot
5
+ see: library types, function-block pin diagrams, struct member layouts, device
6
+ I/O configuration, and the internals of other POUs.
7
+
8
+ That restraint is the whole design. An earlier revision flagged every member
9
+ name and function-block pin (``Axis1.AxisNum``, ``MC.Power``'s ``.Execute``,
10
+ ``.Done``, ``.Error``) as an undefined symbol, which produced hundreds of
11
+ findings on correct production code. A noisy linter is worse than none, because
12
+ an agent will either ignore it or start "fixing" working logic.
13
+
14
+ Checks performed:
15
+
16
+ ``undefined-assignment-target``
17
+ An assignment target (``Name := ...``) is declared nowhere reachable. This
18
+ is the highest-value check: it is exactly the mistake an agent makes when it
19
+ invents a variable name, and an assignment target is never a member access
20
+ unless written as ``Struct.Member := ...``, which is excluded.
21
+
22
+ ``unused-local``
23
+ A plain ``VAR`` local is never referenced in the executable region.
24
+
25
+ ``unassigned-fb-output``
26
+ A function-block instance is called but none of its outputs is read, so the
27
+ result is discarded. Reported only when the instance has no output-style
28
+ member access at all.
29
+
30
+ ``unterminated-statement``
31
+ A logical statement does not end with ``;``. Statements are joined across
32
+ continuation lines first, so multi-line assignments are not misreported.
33
+
34
+ Scope note: graphical (``.GB``) bodies are not text and cannot be analysed.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from dataclasses import dataclass, field
41
+
42
+ #: IEC 61131-3 keywords and operators that must never be reported as symbols.
43
+ KEYWORDS = {
44
+ "IF", "THEN", "ELSIF", "ELSE", "END_IF", "CASE", "OF", "END_CASE",
45
+ "FOR", "TO", "BY", "DO", "END_FOR", "WHILE", "END_WHILE", "REPEAT",
46
+ "UNTIL", "END_REPEAT", "RETURN", "EXIT", "CONTINUE", "VAR", "VAR_INPUT",
47
+ "VAR_OUTPUT", "VAR_IN_OUT", "VAR_GLOBAL", "VAR_EXTERNAL", "VAR_TEMP",
48
+ "END_VAR", "PROGRAM", "FUNCTION", "FUNCTION_BLOCK", "END_PROGRAM",
49
+ "END_FUNCTION", "END_FUNCTION_BLOCK", "TYPE", "END_TYPE", "STRUCT",
50
+ "END_STRUCT", "AT", "RETAIN", "NON_RETAIN", "CONSTANT", "ARRAY", "AND",
51
+ "OR", "XOR", "NOT", "MOD", "TRUE", "FALSE", "NULL",
52
+ }
53
+
54
+ ELEMENTARY_TYPES = {
55
+ "BOOL", "BYTE", "WORD", "DWORD", "LWORD", "SINT", "USINT", "INT", "UINT",
56
+ "DINT", "UDINT", "LINT", "ULINT", "REAL", "LREAL", "STRING", "WSTRING",
57
+ "TIME", "LTIME", "DATE", "LDATE", "TOD", "LTOD", "DT", "LDT",
58
+ }
59
+
60
+ _IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
61
+ _BLOCK_COMMENT_RE = re.compile(r"\(\*.*?\*\)", re.DOTALL)
62
+ _LINE_COMMENT_RE = re.compile(r"//[^\n]*")
63
+ _STRING_RE = re.compile(r"'[^'\n]*'")
64
+ _TYPED_LITERAL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)#")
65
+ #: Identifiers that are the *root* of an expression: not preceded by a dot
66
+ #: (so not a member name) and not followed by a dot (so not a struct reference).
67
+ _ROOT_IDENT_RE = re.compile(r"(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)\b(?!\s*\.)")
68
+ #: An assignment target at the start of a statement, optionally address-prefixed.
69
+ #: Retained for reference; statement-level extraction is done by
70
+ #: :func:`assignment_targets`, which tracks parenthesis depth.
71
+ _MEMBER_NAMES_RE = re.compile(r"\.\s*([A-Za-z_][A-Za-z0-9_]*)")
72
+ #: The root of a member access, e.g. ``Axis1`` in ``Axis1.AxisNum``. Deliberately
73
+ #: separate from _ROOT_IDENT_RE, which skips these.
74
+ _MEMBER_ROOT_RE = re.compile(r"(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)\s*\.")
75
+ _CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(")
76
+ _END_VAR_RE = re.compile(r"\bEND_VAR\b", re.IGNORECASE)
77
+
78
+
79
+ @dataclass
80
+ class Finding:
81
+ """One lint result."""
82
+
83
+ severity: str # "error" | "warning" | "info"
84
+ code: str
85
+ message: str
86
+ line: int | None = None
87
+ hint: str | None = None
88
+
89
+ def format(self) -> str:
90
+ location = f"line {self.line}: " if self.line else ""
91
+ text = f"[{self.severity.upper()}] {location}{self.message}"
92
+ if self.hint:
93
+ text += f"\n hint: {self.hint}"
94
+ return text
95
+
96
+
97
+ @dataclass
98
+ class LintResult:
99
+ findings: list[Finding] = field(default_factory=list)
100
+ used_symbols: set[str] = field(default_factory=set)
101
+ referenced_members: set[str] = field(default_factory=set)
102
+
103
+ @property
104
+ def errors(self) -> list[Finding]:
105
+ return [f for f in self.findings if f.severity == "error"]
106
+
107
+ @property
108
+ def warnings(self) -> list[Finding]:
109
+ return [f for f in self.findings if f.severity == "warning"]
110
+
111
+ @property
112
+ def ok(self) -> bool:
113
+ return not self.errors
114
+
115
+ def add(self, severity: str, code: str, message: str, line: int | None = None,
116
+ hint: str | None = None) -> None:
117
+ self.findings.append(
118
+ Finding(severity=severity, code=code, message=message, line=line, hint=hint)
119
+ )
120
+
121
+
122
+ def strip_comments_and_strings(text: str) -> str:
123
+ """Remove comments and string literals, preserving line structure.
124
+
125
+ Line count is preserved so reported line numbers stay accurate.
126
+ """
127
+
128
+ def blank(match: re.Match[str]) -> str:
129
+ return re.sub(r"[^\n]", " ", match.group(0))
130
+
131
+ return _STRING_RE.sub(blank, _LINE_COMMENT_RE.sub(blank, _BLOCK_COMMENT_RE.sub(blank, text)))
132
+
133
+
134
+ def split_code_region(body: str, clean: str) -> tuple[str, str, int]:
135
+ """Split a POU into its declaration region and executable region.
136
+
137
+ Returns ``(declarations, code, code_start_line)``.
138
+
139
+ MotionWorks stores POU declarations in the separate ``<POU>V.VB`` stream,
140
+ so the ST body normally contains *only* executable code and there is no
141
+ ``END_VAR`` to find. Some other IEC toolchains inline declarations before
142
+ the implementation; that form is handled too.
143
+ """
144
+ matches = list(_END_VAR_RE.finditer(clean))
145
+ if not matches:
146
+ return "", clean, 1
147
+ last = matches[-1]
148
+ return clean[: last.start()], clean[last.end():], body.count("\n", 0, last.end()) + 1
149
+
150
+
151
+ #: A line that legitimately does not need a trailing semicolon because it opens
152
+ #: or closes a block.
153
+ _BLOCK_EDGE_RE = re.compile(
154
+ r"^(END_|ELSE\b|ELSIF\b|CASE\b|REPEAT$|UNTIL\b|TYPE\b|STRUCT\b|"
155
+ r"FOR\b|WHILE\b|IF\b)",
156
+ re.IGNORECASE,
157
+ )
158
+ #: A line whose ending means the statement carries on, so the missing ';' is
159
+ #: expected rather than a defect.
160
+ _CONTINUATION_TAIL_RE = re.compile(
161
+ r"(\:\=|[+\-*/(,=<>&|^]|\b(?:AND|OR|XOR|MOD|NOT|TO|BY))$",
162
+ re.IGNORECASE,
163
+ )
164
+ #: A line ending that completes a statement or a block header. The `:` branch
165
+ #: accepts any label-style colon, including a typed-literal CASE label such as
166
+ #: ``INT#10:``, while the lookahead excludes the `:` of `:=`, which marks an
167
+ #: assignment that continues onto the next line.
168
+ _TERMINAL_TAIL_RE = re.compile(
169
+ r"(;(?!\s*=)|:(?!\s*=)|\bTHEN$|\bDO$|\bREPEAT$|\bELSE$|"
170
+ r"\bEND_IF$|\bEND_CASE$|\bEND_WHILE$|\bEND_FOR$|\bEND_REPEAT$)",
171
+ re.IGNORECASE,
172
+ )
173
+
174
+
175
+ def _next_significant_line(lines: list[str], offset: int) -> str:
176
+ """Return the next non-blank line after ``offset``, or an empty string."""
177
+ for probe in lines[offset + 1:]:
178
+ text = probe.strip()
179
+ if text:
180
+ return text
181
+ return ""
182
+
183
+
184
+ def unterminated_lines(code: str, start_line: int) -> list[tuple[int, str]]:
185
+ """Find lines that look like they are missing a terminating ``;``.
186
+
187
+ MotionWorks ST deliberately spreads one statement over several lines::
188
+
189
+ EIP_ToCLX_CLXMasterHealthy :=
190
+ xCLXHeartbeatValidated
191
+ AND (NOT fbCLXHeartbeatTimeout.Q);
192
+
193
+ IF
194
+ xCLXMasterHealthyPrev
195
+ AND (NOT EIP_ToCLX_CLXMasterHealthy)
196
+ THEN
197
+
198
+ Only the *opening* line of such a statement could carry a semicolon, and
199
+ continuation lines never do. Detection therefore tracks whether the previous
200
+ line reached a terminal token (``;``, ``:``, ``THEN``, ``DO``, ``REPEAT``,
201
+ ``ELSE``, or an ``END_*``):
202
+
203
+ * if it did, this line starts a fresh statement, so a missing terminator is a
204
+ real defect and the line is reported;
205
+ * if it did not, this line continues the previous statement and is skipped.
206
+
207
+ A statement is treated as continuing only when it *ends* with something that
208
+ cannot end a statement (an operator or a bare ``:=``), or when it sits inside
209
+ an unclosed parenthesis. Mid-line ``:=`` is deliberately not a continuation
210
+ signal, because ``X := value`` is a complete statement; treating it as open
211
+ would hide a genuinely dropped semicolon.
212
+
213
+ One special case is closed explicitly: a complete-looking assignment such as
214
+ ``X := Y.Z`` immediately before a block edge (``IF``, ``END_IF``) must be
215
+ missing its semicolon, since an expression cannot continue into a block
216
+ keyword.
217
+ """
218
+ findings: list[tuple[int, str]] = []
219
+ lines = code.splitlines()
220
+ depth = 0
221
+ previous_terminal = True # start of region behaves like a fresh statement
222
+
223
+ for offset, raw in enumerate(lines):
224
+ # Right-strip as well as strip: a line ending in ':=' followed by
225
+ # trailing whitespace would defeat the $-anchored checks below.
226
+ line = raw.strip().rstrip()
227
+ if not line:
228
+ # A blank line does not change whether we are mid-statement.
229
+ continue
230
+
231
+ depth_before = depth
232
+ depth += line.count("(") - line.count(")")
233
+ opens_group = depth_before != 0 or depth > 0
234
+
235
+ terminates = bool(_TERMINAL_TAIL_RE.search(line))
236
+ continues = bool(_CONTINUATION_TAIL_RE.search(line))
237
+
238
+ # `X := Y.Z` right before a block edge is a dropped semicolon.
239
+ if (
240
+ not terminates
241
+ and not continues
242
+ and not opens_group
243
+ and ":=" in line
244
+ and _BLOCK_EDGE_RE.match(_next_significant_line(lines, offset) or "")
245
+ ):
246
+ findings.append((start_line + offset, line))
247
+ previous_terminal = True
248
+ continue
249
+
250
+ if (
251
+ previous_terminal
252
+ and not terminates
253
+ and not opens_group
254
+ and not _BLOCK_EDGE_RE.match(line)
255
+ and not continues
256
+ ):
257
+ findings.append((start_line + offset, line))
258
+
259
+ # `continues` means the statement is still open, so the next line is a
260
+ # continuation and must NOT be treated as starting a fresh statement.
261
+ # `opens_group` likewise leaves the statement open.
262
+ previous_terminal = (not continues) and (not opens_group) and terminates
263
+
264
+ return findings
265
+
266
+
267
+ def _line_of(text: str, index: int) -> int:
268
+ return text.count("\n", 0, index) + 1
269
+
270
+
271
+ def assignment_targets(code: str) -> list[tuple[int, str]]:
272
+ """Find ``Name := `` at *statement* level, ignoring named FB parameters.
273
+
274
+ MotionWorks function blocks are called with named parameters::
275
+
276
+ MC_Power_1(Enable := TRUE, Axis := Axis1);
277
+
278
+ That ``Enable := TRUE`` sits inside parentheses and is an input pin, not a
279
+ variable assignment. Reporting it as an undefined symbol is exactly the
280
+ false positive that makes a linter useless, so only assignments at paren
281
+ depth zero are treated as statement targets.
282
+ """
283
+ results: list[tuple[int, str]] = []
284
+ i = 0
285
+ length = len(code)
286
+ while i < length:
287
+ char = code[i]
288
+ if char == "(":
289
+ depth = 1
290
+ i += 1
291
+ # Skip the entire parenthesised group, including nested ones.
292
+ while i < length and depth:
293
+ if code[i] == "(":
294
+ depth += 1
295
+ elif code[i] == ")":
296
+ depth -= 1
297
+ i += 1
298
+ continue
299
+ if char == ":" and i + 1 < length and code[i + 1] == "=":
300
+ # Walk back over the optional lvalue to its first identifier.
301
+ j = i - 1
302
+ while j >= 0 and code[j] in " \t":
303
+ j -= 1
304
+ # Allow an array index on the lvalue.
305
+ if j >= 0 and code[j] == "]":
306
+ depth = 1
307
+ j -= 1
308
+ while j >= 0 and depth:
309
+ if code[j] == "]":
310
+ depth += 1
311
+ elif code[j] == "[":
312
+ depth -= 1
313
+ j -= 1
314
+ while j >= 0 and code[j] in " \t":
315
+ j -= 1
316
+ end = j
317
+ while j >= 0 and (code[j].isalnum() or code[j] == "_"):
318
+ j -= 1
319
+ start = j + 1
320
+ name = code[start : end + 1]
321
+ if name and (name[0].isalpha() or name[0] == "_"):
322
+ results.append((start, name))
323
+ i += 2
324
+ continue
325
+ i += 1
326
+ return results
327
+
328
+
329
+ def lint(
330
+ body: str,
331
+ declared: set[str] | None = None,
332
+ known_symbols: set[str] | None = None,
333
+ fb_instance_types: set[str] | None = None,
334
+ local_vars: set[str] | None = None,
335
+ project_globals: set[str] | None = None,
336
+ ) -> LintResult:
337
+ """Lint one Structured Text body.
338
+
339
+ Args:
340
+ body: Raw ST source.
341
+ declared: Every name reachable in this POU (locals plus externals).
342
+ known_symbols: Project globals plus every type/library name that could
343
+ legitimately appear. Names here are never reported.
344
+ fb_instance_types: Names of declared function-block instances.
345
+ local_vars: Names declared in a plain ``VAR`` block, used for the
346
+ unused-local check. Externals are excluded: they are owned
347
+ elsewhere and may legitimately be unused here.
348
+ project_globals: Names of the project's VAR_GLOBAL declarations. A
349
+ global is reachable ONLY if this POU declares it as VAR_EXTERNAL,
350
+ with matching name and type. Compiler behavior depends on context.
351
+ """
352
+ result = LintResult()
353
+ declared_u = {n.upper() for n in (declared or set())}
354
+ known_u = {n.upper() for n in (known_symbols or set())}
355
+ fb_instances = {n.upper() for n in (fb_instance_types or set())}
356
+ # Preserve original casing so messages quote the declared name, not an
357
+ # upper-cased comparison key.
358
+ declared_display = {n.upper(): n for n in (local_vars or set())}
359
+ locals_u = set(declared_display)
360
+ globals_u = {n.upper() for n in (project_globals or set())}
361
+
362
+ clean = strip_comments_and_strings(body)
363
+ if not clean.strip():
364
+ return result
365
+
366
+ _declarations, code, code_start = split_code_region(body, clean)
367
+ if not code.strip():
368
+ return result
369
+
370
+ # Member names (`.Done`, `.AxisNum`) are never symbols of this POU.
371
+ members = {m.group(1).upper() for m in _MEMBER_NAMES_RE.finditer(code)}
372
+ result.referenced_members = members
373
+
374
+ # Every root identifier in the executable region: a name that is neither
375
+ # preceded by a dot (so not a member) nor followed by a dot (so not a struct
376
+ # access).
377
+ roots = {m.group(1) for m in _ROOT_IDENT_RE.finditer(code)}
378
+ # Roots of member accesses, e.g. Axis1 in `Axis1.AxisNum`. These ARE uses of
379
+ # the variable, even though the root regex deliberately skips them.
380
+ member_roots = {m.group(1) for m in _MEMBER_ROOT_RE.finditer(code)}
381
+ invoked = {m.group(1).upper() for m in _CALL_RE.finditer(code)}
382
+ # A name used in any of these ways counts as used.
383
+ used = {r.upper() for r in roots} | {r.upper() for r in member_roots} | invoked
384
+ result.used_symbols = roots | member_roots
385
+
386
+ # ------------------------------------------------------------------
387
+ # High-confidence check: assignment targets that resolve to nothing.
388
+ # ------------------------------------------------------------------
389
+ for start, name in assignment_targets(code):
390
+ upper = name.upper()
391
+ if upper in KEYWORDS or upper in declared_u:
392
+ continue
393
+ if upper in globals_u:
394
+ # THE SILENT STALL. This name exists - it is a project global - so the
395
+ # old check let it through via known_u. But MotionWorks only reaches a
396
+ # global from a POU that declares it VAR_EXTERNAL, and when it does not
397
+ # it does not report an undeclared symbol: the build STALLS with
398
+ # is_compiled=false, is_modified=true and an EMPTY Errors pane, so the
399
+ # agent sees a hang and no reason. Measured with PLCMODE_ON, a system
400
+ # global present in the project from the start.
401
+ line = code_start + code.count("\n", 0, start)
402
+ result.add(
403
+ "error",
404
+ "global-not-declared-external",
405
+ f"{name!r} is a project global but this POU does not declare it as "
406
+ f"VAR_EXTERNAL with a matching resource-global name and type",
407
+ line,
408
+ hint=(
409
+ "Add it to this POU's VAR_EXTERNAL block, or use a variable this "
410
+ "POU already declares. Inspect mw_code_read_st and mw_code_globals; "
411
+ "use mw_code_reference for VAR_EXTERNAL scope guidance."
412
+ ),
413
+ )
414
+ continue
415
+ if upper in known_u:
416
+ continue
417
+ if upper in ELEMENTARY_TYPES or upper in members:
418
+ continue
419
+ line = code_start + code.count("\n", 0, start)
420
+ result.add(
421
+ "error",
422
+ "undefined-assignment-target",
423
+ f"assignment to {name!r}, which is not declared in this POU, in the "
424
+ f"project's globals, or as a known library name",
425
+ line,
426
+ hint=(
427
427
  "Check the spelling and intended scope. Shared resource data uses "
428
428
  "VAR_GLOBAL plus a matching VAR_EXTERNAL in the consuming POU. "
429
429
  "Another POU's plain VAR does not establish a resource global."
430
- ),
431
- )
432
-
433
- # ------------------------------------------------------------------
434
- # The silent stall, for READS as well as writes.
435
- #
436
- # The assignment check above catches `X := ...`. Reading an undeclared global stalls
437
- # the build just as hard - `IF PLCMODE_ON THEN` never finishes either - so the same
438
- # rule has to be applied to every reference, not only to assignment targets.
439
- # ------------------------------------------------------------------
440
- reported = {f.line for f in result.findings if f.code == "global-not-declared-external"}
441
- for name in sorted(used, key=str.casefold):
442
- if name not in globals_u or name in declared_u:
443
- continue
444
- if name in KEYWORDS or name in fb_instances:
445
- continue
446
- line = code_start + code.count("\n", 0, code.upper().find(name))
447
- if line in reported:
448
- continue
449
- reported.add(line)
450
- result.add(
451
- "error",
452
- "global-not-declared-external",
453
- f"{name} is a project global but this POU does not declare it as "
454
- f"VAR_EXTERNAL with a matching resource-global name and type",
455
- line,
456
- hint=(
457
- "Add it to this POU's VAR_EXTERNAL block, or use a variable this POU "
458
- "already declares. Compare resource globals and exact types; "
459
- "use mw_code_reference for VAR_EXTERNAL scope guidance."
460
- ),
461
- )
462
-
463
- # ------------------------------------------------------------------
464
- # Unused locals.
465
- # ------------------------------------------------------------------
466
- for name in sorted(locals_u, key=str.casefold):
467
- if name in used:
468
- continue
469
- result.add(
470
- "warning",
471
- "unused-local",
472
- f"local variable {declared_display.get(name, name)!r} is declared but "
473
- f"never used in the body",
474
- None,
475
- hint="Remove it, or confirm it is reserved intentionally.",
476
- )
477
-
478
- # ------------------------------------------------------------------
479
- # Function-block instances whose outputs are all discarded.
480
- # ------------------------------------------------------------------
481
- for name in sorted(fb_instances, key=str.casefold):
482
- pattern = re.compile(rf"(?<![.\w]){re.escape(name)}\s*\(", re.IGNORECASE)
483
- if not pattern.search(code):
484
- continue
485
- # Any `Inst.something` read is treated as using the block's result.
486
- reads = re.findall(
487
- rf"(?<![.\w]){re.escape(name)}\s*\.\s*([A-Za-z_]\w*)", code, re.IGNORECASE
488
- )
489
- if not reads:
490
- result.add(
491
- "warning",
492
- "unassigned-fb-output",
493
- f"function block {name!r} is called but none of its outputs is read",
494
- None,
495
- hint=(
496
- "MotionWorks blocks expose results as outputs such as .Done, "
497
- ".Busy and .Error. Discarding them is legal but often a mistake."
498
- ),
499
- )
500
-
501
- # ------------------------------------------------------------------
502
- # Statements missing their terminator.
503
- # ------------------------------------------------------------------
504
- for line_no, statement in unterminated_lines(code, code_start):
505
- result.add(
506
- "warning",
507
- "unterminated-statement",
508
- f"line does not end with ';': {statement[:70]!r}",
509
- line_no,
510
- )
511
-
512
- return result
513
-
514
-
515
- def lint_pou(pou: object, known_symbols: set[str] | None = None,
516
- project_globals: set[str] | None = None) -> LintResult:
517
- """Lint a POU by combining its declarations with its body.
518
-
519
- ``pou`` is a :class:`motionworks_iec_mcp.project.PouInfo`. Typed loosely to
520
- avoid a circular import.
521
- """
522
- body = pou.st_body() # type: ignore[attr-defined]
523
- if body is None:
524
- result = LintResult()
525
- result.add(
526
- "info",
527
- "not-structured-text",
528
- "POU body is graphical (LD/FBD), not Structured Text; nothing to lint",
529
- )
530
- return result
531
-
532
- try:
533
- table = pou.declarations() # type: ignore[attr-defined]
534
- except Exception as exc: # noqa: BLE001
535
- result = LintResult()
536
- result.add(
537
- "warning",
538
- "declarations-unreadable",
539
- f"declarations could not be read, so results are limited: {exc}",
540
- )
541
- return result
542
-
543
- declared = {v.name for v in table.variables}
544
- locals_only = {v.name for v in table.variables if not v.is_external}
545
- fb_types = {v.name for v in table.variables if v.is_fb_instance}
546
- return lint(
547
- body,
548
- declared=declared,
549
- known_symbols=known_symbols,
550
- fb_instance_types=fb_types,
551
- local_vars=locals_only,
552
- project_globals=project_globals,
553
- )
554
-
555
-
556
- # ── assignment type checking ─────────────────────────────────────────────────────
557
- #
558
- # Measured: a body that assigns a UINT to a BOOL makes the builder destroy the POU - .VB to 0 bytes
559
- # and the grid to 79 MB - where a type-correct body merely stalls and leaves the POU intact. A type
560
- # error is therefore worth refusing here, before it can reach the compiler.
561
- #
562
- # Deliberately narrow. Anything whose type cannot be established confidently is ignored, because a
563
- # false refusal blocks correct code and costs more than the check saves.
564
-
565
- _INTEGER_TYPES = frozenset({
566
- "SINT", "USINT", "INT", "UINT", "DINT", "UDINT", "LINT", "ULINT",
567
- "BYTE", "WORD", "DWORD", "LWORD",
568
- })
569
- _REAL_TYPES = frozenset({"REAL", "LREAL"})
570
- _BOOL_TYPES = frozenset({"BOOL"})
571
- _STRING_TYPES = frozenset({"STRING", "WSTRING", "CHAR", "WCHAR"})
572
-
573
- _ASSIGN_LINE = re.compile(
574
- r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*(.+?)\s*;\s*$"
575
- )
576
- _COMPARISON = re.compile(r"<=|>=|<>|=|<|>")
577
- _LIT_REAL = re.compile(r"^[+-]?\d+\.\d*([eE][+-]?\d+)?$|^[+-]?\d+[eE][+-]?\d+$")
578
- _LIT_INT = re.compile(r"^[+-]?\d+$")
579
- _LIT_BOOL = re.compile(r"^(TRUE|FALSE)$", re.IGNORECASE)
580
- _LIT_STRING = re.compile(r"^'[^']*'$")
581
- _KEYWORDS = frozenset({"TRUE", "FALSE", "NOT", "AND", "OR", "XOR", "MOD", "DIV", "AND_THEN",
582
- "OR_ELSE"})
583
-
584
-
585
- def elementary_family(type_name):
586
- """'integer', 'real', 'bool', 'string' - or None when the type is not an elementary one."""
587
- t = (type_name or "").strip().upper()
588
- if t in _INTEGER_TYPES:
589
- return "integer"
590
- if t in _REAL_TYPES:
591
- return "real"
592
- if t in _BOOL_TYPES:
593
- return "bool"
594
- if t in _STRING_TYPES:
595
- return "string"
596
- return None
597
-
598
-
599
- def _strip_comments(text):
600
- return re.sub(r"\(\*.*?\*\)", " ", text)
601
-
602
-
603
- def _rhs_family(expr, types):
604
- """The family of a right-hand side, or None when it cannot be told confidently."""
605
- e = _strip_comments(expr).strip()
606
- while e.startswith("(") and e.endswith(")"):
607
- e = e[1:-1].strip()
608
- if not e:
609
- return None
610
-
611
- if _LIT_STRING.match(e):
612
- return "string"
613
- if _LIT_BOOL.match(e):
614
- return "bool"
615
- if _LIT_REAL.match(e):
616
- return "real"
617
- if _LIT_INT.match(e):
618
- return "integer"
619
-
620
- if re.match(r"^NOT\b", e, re.IGNORECASE):
621
- return "bool"
622
- if _COMPARISON.search(e):
623
- head = _COMPARISON.split(e)[0].strip()
624
- if head and not _LIT_INT.match(head) and not _LIT_REAL.match(head):
625
- return "bool"
626
-
627
- tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", e)
628
- if not tokens:
629
- return None
630
- families = set()
631
- for tok in tokens:
632
- if tok.upper() in _KEYWORDS:
633
- continue
634
- declared = types.get(tok)
635
- if declared is None:
636
- return None # unknown to us - say nothing
637
- fam = elementary_family(declared)
638
- if fam is None:
639
- return None # struct, FB instance, array - not our business
640
- families.add(fam)
641
-
642
- if len(families) == 1:
643
- return families.pop()
644
- if families == {"integer", "real"}:
645
- return "real" # INTEGER widens to REAL; never the reverse
646
- return None
647
-
648
-
649
- def check_assignment_types(code, types):
650
- """Assignments whose right-hand side cannot fit the left.
651
-
652
- ``types`` maps a declared name to its type name. Returns a list of
653
- ``(line_number, target, declared_type, rhs_family)``.
654
- """
655
- findings = []
656
- if not types:
657
- return findings
658
- for number, line in enumerate(code.split("\n"), start=1):
659
- clean = _strip_comments(line)
660
- if ":=" not in clean:
661
- continue
662
- m = _ASSIGN_LINE.match(clean)
663
- if not m:
664
- continue
665
- target, rhs = m.group(1), m.group(2)
666
- declared = types.get(target)
667
- if declared is None:
668
- continue
669
- want = elementary_family(declared)
670
- got = _rhs_family(rhs, types)
671
- if want is None or got is None or want == got:
672
- continue
673
- if want == "real" and got == "integer":
674
- continue # a widening the compiler accepts
675
- findings.append((number, target, declared, got))
676
- return findings
430
+ ),
431
+ )
432
+
433
+ # ------------------------------------------------------------------
434
+ # The silent stall, for READS as well as writes.
435
+ #
436
+ # The assignment check above catches `X := ...`. Reading an undeclared global stalls
437
+ # the build just as hard - `IF PLCMODE_ON THEN` never finishes either - so the same
438
+ # rule has to be applied to every reference, not only to assignment targets.
439
+ # ------------------------------------------------------------------
440
+ reported = {f.line for f in result.findings if f.code == "global-not-declared-external"}
441
+ for name in sorted(used, key=str.casefold):
442
+ if name not in globals_u or name in declared_u:
443
+ continue
444
+ if name in KEYWORDS or name in fb_instances:
445
+ continue
446
+ line = code_start + code.count("\n", 0, code.upper().find(name))
447
+ if line in reported:
448
+ continue
449
+ reported.add(line)
450
+ result.add(
451
+ "error",
452
+ "global-not-declared-external",
453
+ f"{name} is a project global but this POU does not declare it as "
454
+ f"VAR_EXTERNAL with a matching resource-global name and type",
455
+ line,
456
+ hint=(
457
+ "Add it to this POU's VAR_EXTERNAL block, or use a variable this POU "
458
+ "already declares. Compare resource globals and exact types; "
459
+ "use mw_code_reference for VAR_EXTERNAL scope guidance."
460
+ ),
461
+ )
462
+
463
+ # ------------------------------------------------------------------
464
+ # Unused locals.
465
+ # ------------------------------------------------------------------
466
+ for name in sorted(locals_u, key=str.casefold):
467
+ if name in used:
468
+ continue
469
+ result.add(
470
+ "warning",
471
+ "unused-local",
472
+ f"local variable {declared_display.get(name, name)!r} is declared but "
473
+ f"never used in the body",
474
+ None,
475
+ hint="Remove it, or confirm it is reserved intentionally.",
476
+ )
477
+
478
+ # ------------------------------------------------------------------
479
+ # Function-block instances whose outputs are all discarded.
480
+ # ------------------------------------------------------------------
481
+ for name in sorted(fb_instances, key=str.casefold):
482
+ pattern = re.compile(rf"(?<![.\w]){re.escape(name)}\s*\(", re.IGNORECASE)
483
+ if not pattern.search(code):
484
+ continue
485
+ # Any `Inst.something` read is treated as using the block's result.
486
+ reads = re.findall(
487
+ rf"(?<![.\w]){re.escape(name)}\s*\.\s*([A-Za-z_]\w*)", code, re.IGNORECASE
488
+ )
489
+ if not reads:
490
+ result.add(
491
+ "warning",
492
+ "unassigned-fb-output",
493
+ f"function block {name!r} is called but none of its outputs is read",
494
+ None,
495
+ hint=(
496
+ "MotionWorks blocks expose results as outputs such as .Done, "
497
+ ".Busy and .Error. Discarding them is legal but often a mistake."
498
+ ),
499
+ )
500
+
501
+ # ------------------------------------------------------------------
502
+ # Statements missing their terminator.
503
+ # ------------------------------------------------------------------
504
+ for line_no, statement in unterminated_lines(code, code_start):
505
+ result.add(
506
+ "warning",
507
+ "unterminated-statement",
508
+ f"line does not end with ';': {statement[:70]!r}",
509
+ line_no,
510
+ )
511
+
512
+ return result
513
+
514
+
515
+ def lint_pou(pou: object, known_symbols: set[str] | None = None,
516
+ project_globals: set[str] | None = None) -> LintResult:
517
+ """Lint a POU by combining its declarations with its body.
518
+
519
+ ``pou`` is a :class:`motionworks_iec_mcp.project.PouInfo`. Typed loosely to
520
+ avoid a circular import.
521
+ """
522
+ body = pou.st_body() # type: ignore[attr-defined]
523
+ if body is None:
524
+ result = LintResult()
525
+ result.add(
526
+ "info",
527
+ "not-structured-text",
528
+ "POU body is graphical (LD/FBD), not Structured Text; nothing to lint",
529
+ )
530
+ return result
531
+
532
+ try:
533
+ table = pou.declarations() # type: ignore[attr-defined]
534
+ except Exception as exc: # noqa: BLE001
535
+ result = LintResult()
536
+ result.add(
537
+ "warning",
538
+ "declarations-unreadable",
539
+ f"declarations could not be read, so results are limited: {exc}",
540
+ )
541
+ return result
542
+
543
+ declared = {v.name for v in table.variables}
544
+ locals_only = {v.name for v in table.variables if not v.is_external}
545
+ fb_types = {v.name for v in table.variables if v.is_fb_instance}
546
+ return lint(
547
+ body,
548
+ declared=declared,
549
+ known_symbols=known_symbols,
550
+ fb_instance_types=fb_types,
551
+ local_vars=locals_only,
552
+ project_globals=project_globals,
553
+ )
554
+
555
+
556
+ # ── assignment type checking ─────────────────────────────────────────────────────
557
+ #
558
+ # Measured: a body that assigns a UINT to a BOOL makes the builder destroy the POU - .VB to 0 bytes
559
+ # and the grid to 79 MB - where a type-correct body merely stalls and leaves the POU intact. A type
560
+ # error is therefore worth refusing here, before it can reach the compiler.
561
+ #
562
+ # Deliberately narrow. Anything whose type cannot be established confidently is ignored, because a
563
+ # false refusal blocks correct code and costs more than the check saves.
564
+
565
+ _INTEGER_TYPES = frozenset({
566
+ "SINT", "USINT", "INT", "UINT", "DINT", "UDINT", "LINT", "ULINT",
567
+ "BYTE", "WORD", "DWORD", "LWORD",
568
+ })
569
+ _REAL_TYPES = frozenset({"REAL", "LREAL"})
570
+ _BOOL_TYPES = frozenset({"BOOL"})
571
+ _STRING_TYPES = frozenset({"STRING", "WSTRING", "CHAR", "WCHAR"})
572
+
573
+ _ASSIGN_LINE = re.compile(
574
+ r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*(.+?)\s*;\s*$"
575
+ )
576
+ _COMPARISON = re.compile(r"<=|>=|<>|=|<|>")
577
+ _LIT_REAL = re.compile(r"^[+-]?\d+\.\d*([eE][+-]?\d+)?$|^[+-]?\d+[eE][+-]?\d+$")
578
+ _LIT_INT = re.compile(r"^[+-]?\d+$")
579
+ _LIT_BOOL = re.compile(r"^(TRUE|FALSE)$", re.IGNORECASE)
580
+ _LIT_STRING = re.compile(r"^'[^']*'$")
581
+ _KEYWORDS = frozenset({"TRUE", "FALSE", "NOT", "AND", "OR", "XOR", "MOD", "DIV", "AND_THEN",
582
+ "OR_ELSE"})
583
+
584
+
585
+ def elementary_family(type_name):
586
+ """'integer', 'real', 'bool', 'string' - or None when the type is not an elementary one."""
587
+ t = (type_name or "").strip().upper()
588
+ if t in _INTEGER_TYPES:
589
+ return "integer"
590
+ if t in _REAL_TYPES:
591
+ return "real"
592
+ if t in _BOOL_TYPES:
593
+ return "bool"
594
+ if t in _STRING_TYPES:
595
+ return "string"
596
+ return None
597
+
598
+
599
+ def _strip_comments(text):
600
+ return re.sub(r"\(\*.*?\*\)", " ", text)
601
+
602
+
603
+ def _rhs_family(expr, types):
604
+ """The family of a right-hand side, or None when it cannot be told confidently."""
605
+ e = _strip_comments(expr).strip()
606
+ while e.startswith("(") and e.endswith(")"):
607
+ e = e[1:-1].strip()
608
+ if not e:
609
+ return None
610
+
611
+ if _LIT_STRING.match(e):
612
+ return "string"
613
+ if _LIT_BOOL.match(e):
614
+ return "bool"
615
+ if _LIT_REAL.match(e):
616
+ return "real"
617
+ if _LIT_INT.match(e):
618
+ return "integer"
619
+
620
+ if re.match(r"^NOT\b", e, re.IGNORECASE):
621
+ return "bool"
622
+ if _COMPARISON.search(e):
623
+ head = _COMPARISON.split(e)[0].strip()
624
+ if head and not _LIT_INT.match(head) and not _LIT_REAL.match(head):
625
+ return "bool"
626
+
627
+ tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", e)
628
+ if not tokens:
629
+ return None
630
+ families = set()
631
+ for tok in tokens:
632
+ if tok.upper() in _KEYWORDS:
633
+ continue
634
+ declared = types.get(tok)
635
+ if declared is None:
636
+ return None # unknown to us - say nothing
637
+ fam = elementary_family(declared)
638
+ if fam is None:
639
+ return None # struct, FB instance, array - not our business
640
+ families.add(fam)
641
+
642
+ if len(families) == 1:
643
+ return families.pop()
644
+ if families == {"integer", "real"}:
645
+ return "real" # INTEGER widens to REAL; never the reverse
646
+ return None
647
+
648
+
649
+ def check_assignment_types(code, types):
650
+ """Assignments whose right-hand side cannot fit the left.
651
+
652
+ ``types`` maps a declared name to its type name. Returns a list of
653
+ ``(line_number, target, declared_type, rhs_family)``.
654
+ """
655
+ findings = []
656
+ if not types:
657
+ return findings
658
+ for number, line in enumerate(code.split("\n"), start=1):
659
+ clean = _strip_comments(line)
660
+ if ":=" not in clean:
661
+ continue
662
+ m = _ASSIGN_LINE.match(clean)
663
+ if not m:
664
+ continue
665
+ target, rhs = m.group(1), m.group(2)
666
+ declared = types.get(target)
667
+ if declared is None:
668
+ continue
669
+ want = elementary_family(declared)
670
+ got = _rhs_family(rhs, types)
671
+ if want is None or got is None or want == got:
672
+ continue
673
+ if want == "real" and got == "integer":
674
+ continue # a widening the compiler accepts
675
+ findings.append((number, target, declared, got))
676
+ return findings