@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,103 +1,103 @@
1
- """Offline ST and synchronized declaration/grid writers.
2
-
3
- Plans preserve native control markers, worksheet trailers and untouched streams.
4
- The request dispatcher wraps mutations in full-project snapshot transactions.
5
- Read-back validation is separate from IDE Rebuild/Make acceptance.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import re
11
- import shutil
12
- import struct
13
- from dataclasses import dataclass, field
14
- from datetime import datetime
15
- from pathlib import Path
16
-
17
- from .cfb import CfbError, CompoundFile
18
- from .errors import MotionWorksError, NotFound, UnsupportedFormat
19
- from .variables import DeclarationTable, Variable, parse_declarations
20
-
21
- #: Marker text MotionWorks writes before each declaration block, e.g.
22
- #: ``(*Group:Default*)``.
23
- _GROUP_RE = re.compile(r"^\(\*Group\s*:.*?\*\)\s*$", re.IGNORECASE)
24
-
25
-
26
- class WriteRefused(MotionWorksError):
27
- """A write was not attempted because a safety precondition failed."""
28
-
29
-
30
- @dataclass
31
- class WritePlan:
32
- """What a write intends to change, computed before anything is touched."""
33
-
34
- target: Path
35
- stream: str
36
- before: bytes
37
- after: bytes
38
- backup: Path | None = None
39
- notes: list[str] = field(default_factory=list)
40
- # Additional streams in the SAME container that must change with this one. A POU
41
- # declaration lives in two stores - the .VB text and the .VGR grid - and the compiler
42
- # resolves variables from the grid, so writing only the text leaves a declaration that
43
- # reads back but cannot be used. They are carried here so both are written and verified
44
- # in one operation rather than two.
1
+ """Offline ST and synchronized declaration/grid writers.
2
+
3
+ Plans preserve native control markers, worksheet trailers and untouched streams.
4
+ The request dispatcher wraps mutations in full-project snapshot transactions.
5
+ Read-back validation is separate from IDE Rebuild/Make acceptance.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shutil
12
+ import struct
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from .cfb import CfbError, CompoundFile
18
+ from .errors import MotionWorksError, NotFound, UnsupportedFormat
19
+ from .variables import DeclarationTable, Variable, parse_declarations
20
+
21
+ #: Marker text MotionWorks writes before each declaration block, e.g.
22
+ #: ``(*Group:Default*)``.
23
+ _GROUP_RE = re.compile(r"^\(\*Group\s*:.*?\*\)\s*$", re.IGNORECASE)
24
+
25
+
26
+ class WriteRefused(MotionWorksError):
27
+ """A write was not attempted because a safety precondition failed."""
28
+
29
+
30
+ @dataclass
31
+ class WritePlan:
32
+ """What a write intends to change, computed before anything is touched."""
33
+
34
+ target: Path
35
+ stream: str
36
+ before: bytes
37
+ after: bytes
38
+ backup: Path | None = None
39
+ notes: list[str] = field(default_factory=list)
40
+ # Additional streams in the SAME container that must change with this one. A POU
41
+ # declaration lives in two stores - the .VB text and the .VGR grid - and the compiler
42
+ # resolves variables from the grid, so writing only the text leaves a declaration that
43
+ # reads back but cannot be used. They are carried here so both are written and verified
44
+ # in one operation rather than two.
45
45
  extra_streams: dict[str, bytes] = field(default_factory=dict)
46
46
  sidecars: dict[Path, tuple[bytes | None, bytes]] = field(default_factory=dict)
47
-
48
- @property
49
- def changed(self) -> bool:
47
+
48
+ @property
49
+ def changed(self) -> bool:
50
50
  return self.before != self.after or bool(self.extra_streams) or bool(self.sidecars)
51
-
52
- def describe(self) -> str:
53
- return (
54
- f"{self.target.name}::{self.stream} "
55
- f"{len(self.before)} -> {len(self.after)} bytes"
56
- )
57
-
58
-
59
- def require_ide_closed() -> None:
60
- """Refuse (or close, if configured) while the IDE is running.
61
-
62
- Delegates to :func:`motionworks_iec_mcp.ide.ensure_ide_closed` so the
63
- close-or-refuse policy has a single implementation.
64
- """
65
- from .ide import ensure_ide_closed
66
-
67
- ensure_ide_closed()
68
-
69
-
70
- def backup_files(paths: list[Path], project_root: Path) -> list[Path]:
71
- """Copy each file outside the project tree, returning the backup paths."""
72
- from .snapshot import default_backup_dir
73
-
74
- project_root = Path(project_root)
75
- stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
76
- destination_root = default_backup_dir(project_root) / stamp
77
- created: list[Path] = []
78
- for path in paths:
79
- if not path.is_file():
80
- continue
81
- try:
82
- relative = path.relative_to(project_root)
83
- except ValueError:
84
- relative = Path(path.name)
85
- target = destination_root / relative
86
- target.parent.mkdir(parents=True, exist_ok=True)
87
- shutil.copy2(path, target)
88
- created.append(target)
89
- return created
90
-
91
-
92
- def _apply(
93
- plan: WritePlan,
94
- project_root: Path,
95
- dry_run: bool = False,
96
- verify_siblings: bool = True,
97
- ) -> dict[str, object]:
98
- """Shared implementation: back up, write, read back, verify."""
99
- from .staging import assert_proven, assert_staged
100
- assert_proven(project_root)
51
+
52
+ def describe(self) -> str:
53
+ return (
54
+ f"{self.target.name}::{self.stream} "
55
+ f"{len(self.before)} -> {len(self.after)} bytes"
56
+ )
57
+
58
+
59
+ def require_ide_closed() -> None:
60
+ """Refuse (or close, if configured) while the IDE is running.
61
+
62
+ Delegates to :func:`motionworks_iec_mcp.ide.ensure_ide_closed` so the
63
+ close-or-refuse policy has a single implementation.
64
+ """
65
+ from .ide import ensure_ide_closed
66
+
67
+ ensure_ide_closed()
68
+
69
+
70
+ def backup_files(paths: list[Path], project_root: Path) -> list[Path]:
71
+ """Copy each file outside the project tree, returning the backup paths."""
72
+ from .snapshot import default_backup_dir
73
+
74
+ project_root = Path(project_root)
75
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
76
+ destination_root = default_backup_dir(project_root) / stamp
77
+ created: list[Path] = []
78
+ for path in paths:
79
+ if not path.is_file():
80
+ continue
81
+ try:
82
+ relative = path.relative_to(project_root)
83
+ except ValueError:
84
+ relative = Path(path.name)
85
+ target = destination_root / relative
86
+ target.parent.mkdir(parents=True, exist_ok=True)
87
+ shutil.copy2(path, target)
88
+ created.append(target)
89
+ return created
90
+
91
+
92
+ def _apply(
93
+ plan: WritePlan,
94
+ project_root: Path,
95
+ dry_run: bool = False,
96
+ verify_siblings: bool = True,
97
+ ) -> dict[str, object]:
98
+ """Shared implementation: back up, write, read back, verify."""
99
+ from .staging import assert_proven, assert_staged
100
+ assert_proven(project_root)
101
101
  assert_staged(plan.target)
102
102
  for path, (before, after) in plan.sidecars.items():
103
103
  assert_staged(path)
@@ -105,98 +105,98 @@ def _apply(
105
105
  raise WriteRefused("Sidecar must belong to the edited container directory")
106
106
  if (path.read_bytes() if path.exists() else None) != before:
107
107
  raise WriteRefused("Sidecar changed after planning")
108
- if not plan.changed:
109
- return {
110
- "applied": False,
111
- "reason": "no change required",
112
- "target": str(plan.target),
113
- "stream": plan.stream,
114
- }
115
-
116
- if not dry_run:
117
- require_ide_closed()
118
-
119
- cfb = CompoundFile(plan.target)
120
- if cfb.read_stream(plan.stream) != plan.before:
121
- raise WriteRefused("The source changed after planning; read and plan again")
122
- siblings_before = {
123
- name: cfb.read_stream(name)
124
- for name in cfb.stream_names()
125
- if name != plan.stream and name not in plan.extra_streams
126
- }
127
-
128
- if dry_run:
129
- return {
130
- "applied": False,
131
- "dry_run": True,
132
- "target": str(plan.target),
133
- "stream": plan.stream,
134
- "before_bytes": len(plan.before),
135
- "after_bytes": len(plan.after),
136
- "notes": plan.notes,
137
- }
138
-
108
+ if not plan.changed:
109
+ return {
110
+ "applied": False,
111
+ "reason": "no change required",
112
+ "target": str(plan.target),
113
+ "stream": plan.stream,
114
+ }
115
+
116
+ if not dry_run:
117
+ require_ide_closed()
118
+
119
+ cfb = CompoundFile(plan.target)
120
+ if cfb.read_stream(plan.stream) != plan.before:
121
+ raise WriteRefused("The source changed after planning; read and plan again")
122
+ siblings_before = {
123
+ name: cfb.read_stream(name)
124
+ for name in cfb.stream_names()
125
+ if name != plan.stream and name not in plan.extra_streams
126
+ }
127
+
128
+ if dry_run:
129
+ return {
130
+ "applied": False,
131
+ "dry_run": True,
132
+ "target": str(plan.target),
133
+ "stream": plan.stream,
134
+ "before_bytes": len(plan.before),
135
+ "after_bytes": len(plan.after),
136
+ "notes": plan.notes,
137
+ }
138
+
139
139
  backups = backup_files([plan.target, *plan.sidecars], project_root)
140
-
141
- # Record where each backup came from, explicitly. Recovering the original
142
- # path by searching the backup path for the project name is fragile: a name
143
- # can appear more than once in the path, and the match then lands in the
144
- # wrong place, so a restore would write to the wrong location.
145
- backup_pairs = [
140
+
141
+ # Record where each backup came from, explicitly. Recovering the original
142
+ # path by searching the backup path for the project name is fragile: a name
143
+ # can appear more than once in the path, and the match then lands in the
144
+ # wrong place, so a restore would write to the wrong location.
145
+ backup_pairs = [
146
146
  {"original": str(original), "backup": str(created)}
147
147
  for original, created in zip([p for p in [plan.target, *plan.sidecars] if p.is_file()], backups)
148
- ]
149
-
150
- # Build the new container beside the target and verify it there *before*
151
- # replacing anything. Verifying after the write is too late: the target has
152
- # already been corrupted, and the caller is left with a damaged project that
153
- # only a backup can rescue. Measured: an ordinary replace_streams corrupts a
154
- # normal stream that shrinks and then grows back (and one promoted from mini
155
- # storage), so a verification that cannot prevent the write is not enough.
156
- import tempfile
157
-
158
- with tempfile.NamedTemporaryFile(
159
- dir=plan.target.parent, delete=False, suffix=".verify"
160
- ) as handle:
161
- scratch = Path(handle.name)
162
- try:
163
- writes = {plan.stream: plan.after}
164
- writes.update(plan.extra_streams)
165
- cfb.replace_streams(writes, destination=scratch)
166
- staged = CompoundFile(scratch)
167
- written = staged.read_stream(plan.stream)
168
- if written != plan.after:
169
- raise WriteRefused(
170
- f"refusing to write {plan.target.name}: the container writer did not "
171
- f"reproduce {plan.stream!r} ({len(plan.after)} bytes in, "
172
- f"{len(written)} out). Nothing was written; this is a known defect in "
173
- f"growth handling, not a problem with your edit."
174
- )
175
- for extra_name, extra_payload in plan.extra_streams.items():
176
- if staged.read_stream(extra_name) != extra_payload:
177
- raise WriteRefused(f"Paired stream failed pre-commit verification: {extra_name}")
178
- # The other streams must survive untouched, checked here too so a sibling
179
- # cannot be damaged on disk either.
180
- damaged = [
181
- name
182
- for name, payload in siblings_before.items()
183
- if staged.read_stream(name) != payload
184
- ]
185
- if damaged and verify_siblings:
186
- raise WriteRefused(
187
- f"refusing to write {plan.target.name}: the edit would have damaged "
188
- f"unrelated stream(s) {damaged}. Nothing was written."
189
- )
190
- finally:
191
- scratch.unlink(missing_ok=True)
192
-
193
- # The REAL write must carry the extra streams too. The scratch verification above
194
- # already did, which is exactly what made this hard to see: the plan was right, the
195
- # dry run was right, the verification was right, and only the commit wrote one stream -
196
- # so a declaration landed in the .VB and never in the .VGR, and the tool reported
197
- # success while the variable stayed unusable.
198
- commit = {plan.stream: plan.after}
199
- commit.update(plan.extra_streams)
148
+ ]
149
+
150
+ # Build the new container beside the target and verify it there *before*
151
+ # replacing anything. Verifying after the write is too late: the target has
152
+ # already been corrupted, and the caller is left with a damaged project that
153
+ # only a backup can rescue. Measured: an ordinary replace_streams corrupts a
154
+ # normal stream that shrinks and then grows back (and one promoted from mini
155
+ # storage), so a verification that cannot prevent the write is not enough.
156
+ import tempfile
157
+
158
+ with tempfile.NamedTemporaryFile(
159
+ dir=plan.target.parent, delete=False, suffix=".verify"
160
+ ) as handle:
161
+ scratch = Path(handle.name)
162
+ try:
163
+ writes = {plan.stream: plan.after}
164
+ writes.update(plan.extra_streams)
165
+ cfb.replace_streams(writes, destination=scratch)
166
+ staged = CompoundFile(scratch)
167
+ written = staged.read_stream(plan.stream)
168
+ if written != plan.after:
169
+ raise WriteRefused(
170
+ f"refusing to write {plan.target.name}: the container writer did not "
171
+ f"reproduce {plan.stream!r} ({len(plan.after)} bytes in, "
172
+ f"{len(written)} out). Nothing was written; this is a known defect in "
173
+ f"growth handling, not a problem with your edit."
174
+ )
175
+ for extra_name, extra_payload in plan.extra_streams.items():
176
+ if staged.read_stream(extra_name) != extra_payload:
177
+ raise WriteRefused(f"Paired stream failed pre-commit verification: {extra_name}")
178
+ # The other streams must survive untouched, checked here too so a sibling
179
+ # cannot be damaged on disk either.
180
+ damaged = [
181
+ name
182
+ for name, payload in siblings_before.items()
183
+ if staged.read_stream(name) != payload
184
+ ]
185
+ if damaged and verify_siblings:
186
+ raise WriteRefused(
187
+ f"refusing to write {plan.target.name}: the edit would have damaged "
188
+ f"unrelated stream(s) {damaged}. Nothing was written."
189
+ )
190
+ finally:
191
+ scratch.unlink(missing_ok=True)
192
+
193
+ # The REAL write must carry the extra streams too. The scratch verification above
194
+ # already did, which is exactly what made this hard to see: the plan was right, the
195
+ # dry run was right, the verification was right, and only the commit wrote one stream -
196
+ # so a declaration landed in the .VB and never in the .VGR, and the tool reported
197
+ # success while the variable stayed unusable.
198
+ commit = {plan.stream: plan.after}
199
+ commit.update(plan.extra_streams)
200
200
  cfb.replace_streams(commit)
201
201
  for path, (_, after) in plan.sidecars.items():
202
202
  with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as handle:
@@ -208,459 +208,459 @@ def _apply(
208
208
  pending.unlink(missing_ok=True)
209
209
  if path.read_bytes() != after:
210
210
  raise WriteRefused(f"Sidecar read-back failed: {path}")
211
-
212
- check = CompoundFile(plan.target)
213
- for name, payload in commit.items():
214
- if check.read_stream(name) != payload:
215
- raise WriteRefused(f"Read-back verification failed for {name}")
216
- written = check.read_stream(plan.stream)
217
- if written != plan.after:
218
- raise WriteRefused(
219
- f"read-back verification failed for {plan.stream!r}: wrote "
220
- f"{len(plan.after)} bytes, read {len(written)}"
221
- )
222
- damaged = []
223
- if verify_siblings:
224
- damaged = [
225
- name
226
- for name, payload in siblings_before.items()
227
- if check.read_stream(name) != payload
228
- ]
229
- if damaged:
230
- raise WriteRefused(
231
- f"sibling streams changed unexpectedly: {damaged}. Restore from "
232
- f"{backups[0].parent if backups else '<no backup>'}"
233
- )
234
-
235
- return {
236
- "applied": True,
237
- "target": str(plan.target),
238
- "stream": plan.stream,
239
- "before_bytes": len(plan.before),
240
- "after_bytes": len(plan.after),
241
- "siblings_verified": len(siblings_before) if verify_siblings else 0,
242
- "backups": [str(b) for b in backups],
243
- "backup_pairs": backup_pairs,
244
- "notes": plan.notes,
245
- "next": "Open MotionWorks IEC and run Build -> Rebuild Project, then Make.",
246
- }
247
-
248
-
249
- # ---------------------------------------------------------------------------
250
- # Description editing (.VB only)
251
- # ---------------------------------------------------------------------------
252
-
253
-
254
- def _description_pattern(name: str) -> re.Pattern[str]:
255
- """Match a variable's declaration line, including any trailing description.
256
-
257
- Descriptions are the last thing on the declaration line, which is what makes
258
- them safely replaceable by text substitution. The trailing ``(*...*)`` is
259
- part of the match so that removing a description actually removes it rather
260
- than leaving the old comment in place.
261
-
262
- The match is deliberately **greedy to the last ``*)`` on the line**. Real
263
- projects contain nested-looking comments --
264
- ``(*SGDV Linear - 1 (* Do Not Modify!! *) *)`` -- and a non-greedy match stops
265
- at the inner ``*)``, orphaning the outer one and corrupting the line. The
266
- reader in :mod:`motionworks_iec_mcp.variables` resolves the same text greedily,
267
- so the two must agree or a write cannot be read back as it was written.
268
-
269
- The description group is anchored to the end of its line and its body is
270
- bounded to that line, matching greedily to the **last** ``*)``. Both details
271
- are load-bearing, and each was found the hard way:
272
-
273
- * a non-greedy body stops at the first ``*)``, so a nested-looking description
274
- like ``(*SGDV Linear - 1 (* Do Not Modify!! *) *)`` loses its outer
275
- delimiter -- but a body that is not bounded to the line matches across lines
276
- and swallows the declarations that follow;
277
- * the anchor must tolerate a carriage return. Declaration text uses CRLF, and
278
- in multiline mode ``$`` matches *before* the ``\\n``, so the ``\\r`` still
279
- has to be consumed. Omitting it makes descriptions undetectable in every
280
- real project, which is how the first version of this fix regressed.
281
- """
282
- return re.compile(
283
- rf"(?m)^([ \t]*{re.escape(name)}[ \t]*(?:AT[ \t]+\S+[ \t]*)?:[^\r\n;]*;)"
284
- rf"([ \t]*\(\*(?P<desc>[^\r\n]*)\*\)[ \t\r]*$)?",
285
- re.IGNORECASE,
286
- )
287
-
288
-
289
- def set_variable_description(
290
- text: str, name: str, description: str | None
291
- ) -> tuple[str, list[str]]:
292
- """Return ``text`` with ``name``'s description set (or removed if None).
293
-
294
- Only ``.VB`` text is involved. Verified by inspection that MotionWorks does
295
- not store descriptions in ``.VGR`` at all, so no binary edit is required --
296
- which is why this is the safest write the server offers.
297
- """
298
- notes: list[str] = []
299
- pattern = _description_pattern(name)
300
- matches = list(pattern.finditer(text))
301
- if not matches:
302
- # Fall back to a looser match that also accepts a line without ';'.
303
- loose = re.compile(
304
- rf"(?m)^([ \t]*{re.escape(name)}[ \t]*:[^\r\n]*?)(\r?\n|$)",
305
- re.IGNORECASE,
306
- )
307
- candidates = [m for m in loose.finditer(text) if m.group(1).strip()]
308
- if not candidates:
309
- raise WriteRefused(
310
- f"variable {name!r} was not found in the declaration text"
311
- )
312
- match = candidates[0]
313
- line = match.group(1)
314
- ending = match.group(2) or ""
315
- body = _strip_comment(line)
316
- replacement = body + (f"(*{description}*)" if description else "")
317
- return text[: match.start()] + replacement + ending + text[match.end():], [
318
- f"{name}: rewrote a declaration line that had no trailing ';'"
319
- ]
320
-
321
- if len(matches) > 1:
322
- notes.append(
323
- f"{name!r} appears on {len(matches)} lines; only the first was changed"
324
- )
325
-
326
- match = matches[0]
327
- body = _strip_comment(match.group(1))
328
- replacement = body + (f"(*{description}*)" if description else "")
329
- return text[: match.start()] + replacement + text[match.end():], notes
330
-
331
-
332
- def _strip_comment(prefix: str) -> str:
333
- """Remove a trailing ``(*...*)`` and only the whitespace that joined it.
334
-
335
- Greedy, to match :func:`_description_pattern` and the reader: a description can
336
- contain a nested-looking ``*)``, and stopping at the first one leaves an
337
- orphaned delimiter behind.
338
-
339
- Blanket ``rstrip()`` is wrong here: a description may legitimately end with a
340
- space inside the comment -- ``(*SGD7S - 1 (Do Not Modify!!) *)`` does -- and
341
- stripping it changes content a later write cannot restore.
342
- """
343
- without = re.sub(r"\(\*[^\r\n]*\*\)[ \t\r]*$", "", prefix)
344
- # Drop only the separator space(s) between the declaration and the comment.
345
- return without.rstrip(" \t")
346
-
347
-
348
- def _declaration_target(
349
- project_root: Path, pou_name: str | None
350
- ) -> tuple[Path, str, bytes, str]:
351
- """Resolve ``(source_path, stream_name, current_bytes, text)``.
352
-
353
- ``pou_name`` of None targets the project's global variables.
354
- """
355
- from . import project as P
356
-
357
- proj = P.Project(root=project_root)
358
- if pou_name:
359
- pou = proj.pou(pou_name)
360
- source = pou.source_path
361
- stream = pou.declaration_stream_name()
362
- if stream is None:
363
- raise WriteRefused(f"POU {pou_name!r} has no declaration stream")
364
- else:
365
- source = proj.resource_source()
366
- if source is None:
367
- raise WriteRefused("no resource src.st1 found for global variables")
368
- candidates = [
369
- n for n in CompoundFile(source).stream_names() if n.upper().endswith(".VB")
370
- ]
371
- if not candidates:
372
- raise WriteRefused("resource src.st1 has no textual declaration stream")
373
- stream = candidates[0]
374
-
375
- cfb = CompoundFile(source)
376
- before = cfb.read_stream(stream)
377
- return source, stream, before, before.decode("latin1")
378
-
379
-
380
- def _reference_check(
381
- project_root: Path,
382
- variable: str,
383
- excluding_pou: str | None,
384
- include_named_pou: str | None = None,
385
- ) -> list[str]:
386
- """POUs whose body references ``variable``.
387
-
388
- ``excluding_pou`` omits a POU entirely; ``include_named_pou`` force-includes
389
- one. For a delete the declaring POU's *own* body must be searched, because a
390
- POU-local variable is most likely used there -- the first version of this
391
- check excluded it and consequently failed to catch a delete that broke the
392
- build.
393
- """
394
- from . import project as P
395
- from .declarations import find_referencing_pous
396
-
397
- proj = P.Project(root=project_root)
398
- bodies: dict[str, str] = {}
399
- for pou in proj.pous():
400
- if excluding_pou and pou.name.casefold() == excluding_pou.casefold():
401
- if not (
402
- include_named_pou
403
- and pou.name.casefold() == include_named_pou.casefold()
404
- ):
405
- continue
406
- try:
407
- body = pou.st_body()
408
- except Exception: # noqa: BLE001 - an unreadable POU cannot be checked
409
- continue
410
- if body:
411
- bodies[pou.name] = body
412
- return find_referencing_pous(variable, bodies)
413
-
414
-
415
- from .variable_edit import add as plan_variable_add, edit as plan_variable_edit, delete as plan_variable_delete
416
-
417
-
418
- def apply_declaration(plan: WritePlan, project_root: Path, dry_run: bool = False) -> dict[str, object]:
419
- """Apply a declaration plan (add, edit or delete)."""
420
- return _apply(plan, project_root, dry_run=dry_run)
421
-
422
-
423
- def plan_variable_description(
424
- project_root: Path,
425
- pou_name: str | None,
426
- variable: str,
427
- description: str | None,
428
- ) -> WritePlan:
429
- """Build the plan for a description change without writing anything.
430
-
431
- ``pou_name`` of None targets the project's global variables.
432
- """
433
- from . import project as P
434
-
435
- proj = P.Project(root=project_root)
436
- if pou_name:
437
- pou = proj.pou(pou_name)
438
- source = pou.source_path
439
- stream = pou.declaration_stream_name()
440
- if stream is None:
441
- raise WriteRefused(f"POU {pou_name!r} has no declaration stream")
442
- else:
443
- source = proj.resource_source()
444
- if source is None:
445
- raise WriteRefused("no resource src.st1 found for global variables")
446
- cfb_names = CompoundFile(source).stream_names()
447
- candidates = [n for n in cfb_names if n.upper().endswith(".VB")]
448
- if not candidates:
449
- raise WriteRefused("resource src.st1 has no textual declaration stream")
450
- stream = candidates[0]
451
-
452
- cfb = CompoundFile(source)
453
- before = cfb.read_stream(stream)
454
- text = before.decode("latin1")
455
- updated, notes = set_variable_description(text, variable, description)
456
- return WritePlan(
457
- target=source,
458
- stream=stream,
459
- before=before,
460
- after=updated.encode("latin1"),
461
- notes=notes,
462
- )
463
-
464
-
465
- def apply_plan(plan: WritePlan, project_root: Path, dry_run: bool = False) -> dict[str, object]:
466
- """Back up, write, and verify a plan. Returns a result summary."""
467
- return _apply(plan, project_root, dry_run=dry_run)
468
-
469
-
470
- # ---------------------------------------------------------------------------
471
- # Structured Text body replacement (.STB only)
472
- # ---------------------------------------------------------------------------
473
-
474
-
475
- def plan_st_body(
476
- project_root: Path,
477
- pou_name: str,
478
- body: str,
479
- run_lint: bool = True,
480
- ) -> WritePlan:
481
- """Build the plan for replacing a POU's Structured Text body.
482
-
483
- Only the ``.STB`` stream changes. The body does not reference the variable
484
- grid, so this needs no binary grid edit and no project-tree edit.
485
-
486
- Refuses graphical POUs (their logic is proprietary binary), compressed
487
- declaration streams, and -- when ``run_lint`` is set -- a body whose new
488
- variables would be undeclared.
489
- """
490
- from . import project as P
491
- from .stlint import Finding, lint, check_assignment_types
492
-
493
- proj = P.Project(root=project_root)
494
- pou = proj.pou(pou_name)
495
-
496
- found = pou.body_stream()
497
- if found is None:
498
- raise WriteRefused(f"POU {pou_name!r} has no body stream")
499
- stream, language = found
500
- if language != "ST":
501
- raise WriteRefused(
502
- f"POU {pou_name!r} is {language} (graphical). Its body is proprietary "
503
- f"binary and is not replaced as text; transplanting a proven body is "
504
- f"the supported route."
505
- )
506
-
507
- # A POU whose declarations cannot be read cannot be safely checked.
508
- table = pou.declarations()
509
- if table.warnings and not table.variables and table.source_stream is None:
510
- raise WriteRefused(
511
- f"declarations for {pou_name!r} are unreadable, so the new body "
512
- f"cannot be validated: {'; '.join(table.warnings)}"
513
- )
514
-
515
- notes: list[str] = []
516
- # Read the current body first, so the new one can follow its conventions.
517
- cfb = CompoundFile(pou.source_path)
518
- before = cfb.read_stream(stream)
519
-
520
- normalised = body.replace("\r\n", "\n").replace("\r", "\n")
521
- normalised = normalised.replace("\n", "\r\n")
522
- # An empty body stays empty. Appending a CRLF to it would turn a no-op into a
523
- # two-byte write, so replacing an empty body with itself would change the file
524
- # -- which a stress matrix caught, since some POUs legitimately have a zero-byte
525
- # body stream.
526
- #
527
- # A trailing newline is only added when the body would otherwise have none *and*
528
- # the existing stream ends with one. `st_body()` reports the body without a
529
- # final newline even when the stored stream has one, so consulting the stream is
530
- # what keeps a same-body write a true no-op instead of growing the file by two
531
- # bytes every time.
532
- stream_ends_with_newline = before.endswith(b"\n")
533
- if (
534
- normalised
535
- and not normalised.endswith("\r\n")
536
- and stream_ends_with_newline
537
- ):
538
- normalised += "\r\n"
539
- notes.append("added a trailing CRLF to the body")
540
-
541
- native_prefix = re.match(rb'^(?:\(\*[^*]*[\x00-\x08\x0b-\x1f][\s\S]*?\*\)(?:\r\n|\n)*)+', before)
542
- if native_prefix and not normalised.encode("latin1").startswith(native_prefix.group(0)):
543
- normalised = native_prefix.group(0).decode("latin1") + normalised
544
- notes.append("Preserved native leading MotionWorks control markers")
545
-
546
- if run_lint:
547
- declared = {v.name for v in table.variables}
548
- locals_only = {v.name for v in table.variables if not v.is_external}
549
- fb_types = {v.name for v in table.variables if v.is_fb_instance}
550
- result = lint(
551
- normalised,
552
- declared=declared,
553
- known_symbols=P.known_symbol_names(proj),
554
- fb_instance_types=fb_types,
555
- local_vars=locals_only,
556
- project_globals=P.global_names(proj),
557
- )
558
- errors = [f for f in result.findings if f.severity == "error"]
559
-
560
- # A type error does not merely fail a build. Measured: assigning a UINT to a BOOL makes
561
- # the builder DESTROY the POU - .VB to 0 bytes and the grid to 79,432,063 bytes - while
562
- # a type-correct body with a different fault stalls harmlessly and leaves it intact. So
563
- # a type error is refused here, alongside the undeclared-symbol errors above, and the
564
- # body never reaches the compiler. Built as its own list and extended, so it cannot
565
- # depend on the order these lines run in.
566
- _types = {}
567
- for _v in table.variables:
568
- _n = getattr(_v, 'name', None)
569
- _t = getattr(_v, 'type_name', None)
570
- if _n and _t:
571
- _types[_n] = _t
572
- errors.extend(
573
- Finding(
574
- severity='error',
575
- code='type-mismatch',
576
- message=(
577
- f"line {_line}: assigning a {_got.upper()} to '{_target}', which is "
578
- f"{_want} - a type error makes the build DESTROY this POU rather than "
579
- f"report it, so the body is refused"
580
- ),
581
- line=_line,
582
- hint='Convert the value explicitly, or declare a variable of the right type.',
583
- )
584
- for _line, _target, _want, _got in check_assignment_types(normalised, _types)
585
- )
586
-
587
- # ── a real compiler, when the machine has one ────────────────────────────────
588
- #
589
- # The checks above are hand-written and catch what they were written for. This asks an actual
590
- # IEC 61131-3 compiler, which knows considerably more - and it fails soft, because a missing
591
- # tool must never stop a write that would otherwise be fine.
592
- if run_lint:
593
- try:
594
- from . import iec as _iec
595
-
596
- _diagnostics = _iec.validate_pou(pou_name, table, normalised)
597
- if _diagnostics is None:
598
- notes.append("not checked by an IEC compiler (none available)")
599
- else:
600
- notes.append(_iec.summary(_diagnostics))
601
- for _d in _diagnostics:
602
- errors.append(Finding(
603
- severity="error",
604
- code=_d.code,
605
- message=f"IEC compiler: {_d.as_text()}",
606
- line=_d.line,
607
- hint="Fix the statement; a real compiler rejected it before it was written.",
608
- ))
609
- except Exception as _exc: # a broken compiler must never block a write
610
- notes.append(f"IEC compiler check skipped: {type(_exc).__name__}")
611
-
612
- if errors:
613
- detail = "\n ".join(f.message for f in errors[:8])
614
- raise WriteRefused(
615
- f"the new body has lint errors and was not written:\n {detail}\n"
616
- f"Pass run_lint=False to override, but expect the build to fail."
617
- )
618
- warnings = [f for f in result.findings if f.severity == "warning"]
619
- if warnings:
620
- notes.append(f"{len(warnings)} lint warning(s) present")
621
-
622
- after = normalised.encode("latin1")
623
-
624
- # A guard used to refuse growing a mini stream past the cutoff, because the
625
- # container writer corrupted that promotion. The container defect is fixed and
626
- # verified (a stream promoted from 809 to 18016 bytes reads back exactly, and so
627
- # does the restore), so the restriction is gone: twelve ST POUs across the
628
- # sample projects keep their body as a mini stream, and refusing would have
629
- # blocked a large body edit on every one of them.
630
- return WritePlan(
631
- target=pou.source_path,
632
- stream=stream,
633
- before=before,
634
- after=after,
635
- notes=notes,
636
- )
637
-
638
-
639
- def plan_st_body_from_file(
640
- project_root: Path,
641
- pou_name: str,
642
- body_path: Path,
643
- run_lint: bool = True,
644
- ) -> WritePlan:
645
- """Plan an ST body replacement taking the new body from a file.
646
-
647
- Preferred over passing the body inline for anything sizeable: real ST bodies
648
- run to tens of kilobytes (the largest in the sample projects is 30,405
649
- characters), which is awkward and error-prone to move through a JSON tool
650
- argument.
651
- """
652
- body_path = Path(body_path)
653
- if not body_path.is_file():
654
- raise WriteRefused(f"body file not found: {body_path}")
655
- try:
656
- text = body_path.read_text(encoding="utf-8")
657
- except UnicodeDecodeError:
658
- text = body_path.read_text(encoding="latin1")
659
- plan = plan_st_body(project_root, pou_name, text, run_lint=run_lint)
660
- plan.notes.append(f"body read from {body_path}")
661
- return plan
662
-
663
-
664
- def apply_st_body(plan: WritePlan, project_root: Path, dry_run: bool = False) -> dict[str, object]:
665
- """Apply a Structured Text body plan."""
666
- return _apply(plan, project_root, dry_run=dry_run)
211
+
212
+ check = CompoundFile(plan.target)
213
+ for name, payload in commit.items():
214
+ if check.read_stream(name) != payload:
215
+ raise WriteRefused(f"Read-back verification failed for {name}")
216
+ written = check.read_stream(plan.stream)
217
+ if written != plan.after:
218
+ raise WriteRefused(
219
+ f"read-back verification failed for {plan.stream!r}: wrote "
220
+ f"{len(plan.after)} bytes, read {len(written)}"
221
+ )
222
+ damaged = []
223
+ if verify_siblings:
224
+ damaged = [
225
+ name
226
+ for name, payload in siblings_before.items()
227
+ if check.read_stream(name) != payload
228
+ ]
229
+ if damaged:
230
+ raise WriteRefused(
231
+ f"sibling streams changed unexpectedly: {damaged}. Restore from "
232
+ f"{backups[0].parent if backups else '<no backup>'}"
233
+ )
234
+
235
+ return {
236
+ "applied": True,
237
+ "target": str(plan.target),
238
+ "stream": plan.stream,
239
+ "before_bytes": len(plan.before),
240
+ "after_bytes": len(plan.after),
241
+ "siblings_verified": len(siblings_before) if verify_siblings else 0,
242
+ "backups": [str(b) for b in backups],
243
+ "backup_pairs": backup_pairs,
244
+ "notes": plan.notes,
245
+ "next": "Open MotionWorks IEC and run Build -> Rebuild Project, then Make.",
246
+ }
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Description editing (.VB only)
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def _description_pattern(name: str) -> re.Pattern[str]:
255
+ """Match a variable's declaration line, including any trailing description.
256
+
257
+ Descriptions are the last thing on the declaration line, which is what makes
258
+ them safely replaceable by text substitution. The trailing ``(*...*)`` is
259
+ part of the match so that removing a description actually removes it rather
260
+ than leaving the old comment in place.
261
+
262
+ The match is deliberately **greedy to the last ``*)`` on the line**. Real
263
+ projects contain nested-looking comments --
264
+ ``(*SGDV Linear - 1 (* Do Not Modify!! *) *)`` -- and a non-greedy match stops
265
+ at the inner ``*)``, orphaning the outer one and corrupting the line. The
266
+ reader in :mod:`motionworks_iec_mcp.variables` resolves the same text greedily,
267
+ so the two must agree or a write cannot be read back as it was written.
268
+
269
+ The description group is anchored to the end of its line and its body is
270
+ bounded to that line, matching greedily to the **last** ``*)``. Both details
271
+ are load-bearing, and each was found the hard way:
272
+
273
+ * a non-greedy body stops at the first ``*)``, so a nested-looking description
274
+ like ``(*SGDV Linear - 1 (* Do Not Modify!! *) *)`` loses its outer
275
+ delimiter -- but a body that is not bounded to the line matches across lines
276
+ and swallows the declarations that follow;
277
+ * the anchor must tolerate a carriage return. Declaration text uses CRLF, and
278
+ in multiline mode ``$`` matches *before* the ``\\n``, so the ``\\r`` still
279
+ has to be consumed. Omitting it makes descriptions undetectable in every
280
+ real project, which is how the first version of this fix regressed.
281
+ """
282
+ return re.compile(
283
+ rf"(?m)^([ \t]*{re.escape(name)}[ \t]*(?:AT[ \t]+\S+[ \t]*)?:[^\r\n;]*;)"
284
+ rf"([ \t]*\(\*(?P<desc>[^\r\n]*)\*\)[ \t\r]*$)?",
285
+ re.IGNORECASE,
286
+ )
287
+
288
+
289
+ def set_variable_description(
290
+ text: str, name: str, description: str | None
291
+ ) -> tuple[str, list[str]]:
292
+ """Return ``text`` with ``name``'s description set (or removed if None).
293
+
294
+ Only ``.VB`` text is involved. Verified by inspection that MotionWorks does
295
+ not store descriptions in ``.VGR`` at all, so no binary edit is required --
296
+ which is why this is the safest write the server offers.
297
+ """
298
+ notes: list[str] = []
299
+ pattern = _description_pattern(name)
300
+ matches = list(pattern.finditer(text))
301
+ if not matches:
302
+ # Fall back to a looser match that also accepts a line without ';'.
303
+ loose = re.compile(
304
+ rf"(?m)^([ \t]*{re.escape(name)}[ \t]*:[^\r\n]*?)(\r?\n|$)",
305
+ re.IGNORECASE,
306
+ )
307
+ candidates = [m for m in loose.finditer(text) if m.group(1).strip()]
308
+ if not candidates:
309
+ raise WriteRefused(
310
+ f"variable {name!r} was not found in the declaration text"
311
+ )
312
+ match = candidates[0]
313
+ line = match.group(1)
314
+ ending = match.group(2) or ""
315
+ body = _strip_comment(line)
316
+ replacement = body + (f"(*{description}*)" if description else "")
317
+ return text[: match.start()] + replacement + ending + text[match.end():], [
318
+ f"{name}: rewrote a declaration line that had no trailing ';'"
319
+ ]
320
+
321
+ if len(matches) > 1:
322
+ notes.append(
323
+ f"{name!r} appears on {len(matches)} lines; only the first was changed"
324
+ )
325
+
326
+ match = matches[0]
327
+ body = _strip_comment(match.group(1))
328
+ replacement = body + (f"(*{description}*)" if description else "")
329
+ return text[: match.start()] + replacement + text[match.end():], notes
330
+
331
+
332
+ def _strip_comment(prefix: str) -> str:
333
+ """Remove a trailing ``(*...*)`` and only the whitespace that joined it.
334
+
335
+ Greedy, to match :func:`_description_pattern` and the reader: a description can
336
+ contain a nested-looking ``*)``, and stopping at the first one leaves an
337
+ orphaned delimiter behind.
338
+
339
+ Blanket ``rstrip()`` is wrong here: a description may legitimately end with a
340
+ space inside the comment -- ``(*SGD7S - 1 (Do Not Modify!!) *)`` does -- and
341
+ stripping it changes content a later write cannot restore.
342
+ """
343
+ without = re.sub(r"\(\*[^\r\n]*\*\)[ \t\r]*$", "", prefix)
344
+ # Drop only the separator space(s) between the declaration and the comment.
345
+ return without.rstrip(" \t")
346
+
347
+
348
+ def _declaration_target(
349
+ project_root: Path, pou_name: str | None
350
+ ) -> tuple[Path, str, bytes, str]:
351
+ """Resolve ``(source_path, stream_name, current_bytes, text)``.
352
+
353
+ ``pou_name`` of None targets the project's global variables.
354
+ """
355
+ from . import project as P
356
+
357
+ proj = P.Project(root=project_root)
358
+ if pou_name:
359
+ pou = proj.pou(pou_name)
360
+ source = pou.source_path
361
+ stream = pou.declaration_stream_name()
362
+ if stream is None:
363
+ raise WriteRefused(f"POU {pou_name!r} has no declaration stream")
364
+ else:
365
+ source = proj.resource_source()
366
+ if source is None:
367
+ raise WriteRefused("no resource src.st1 found for global variables")
368
+ candidates = [
369
+ n for n in CompoundFile(source).stream_names() if n.upper().endswith(".VB")
370
+ ]
371
+ if not candidates:
372
+ raise WriteRefused("resource src.st1 has no textual declaration stream")
373
+ stream = candidates[0]
374
+
375
+ cfb = CompoundFile(source)
376
+ before = cfb.read_stream(stream)
377
+ return source, stream, before, before.decode("latin1")
378
+
379
+
380
+ def _reference_check(
381
+ project_root: Path,
382
+ variable: str,
383
+ excluding_pou: str | None,
384
+ include_named_pou: str | None = None,
385
+ ) -> list[str]:
386
+ """POUs whose body references ``variable``.
387
+
388
+ ``excluding_pou`` omits a POU entirely; ``include_named_pou`` force-includes
389
+ one. For a delete the declaring POU's *own* body must be searched, because a
390
+ POU-local variable is most likely used there -- the first version of this
391
+ check excluded it and consequently failed to catch a delete that broke the
392
+ build.
393
+ """
394
+ from . import project as P
395
+ from .declarations import find_referencing_pous
396
+
397
+ proj = P.Project(root=project_root)
398
+ bodies: dict[str, str] = {}
399
+ for pou in proj.pous():
400
+ if excluding_pou and pou.name.casefold() == excluding_pou.casefold():
401
+ if not (
402
+ include_named_pou
403
+ and pou.name.casefold() == include_named_pou.casefold()
404
+ ):
405
+ continue
406
+ try:
407
+ body = pou.st_body()
408
+ except Exception: # noqa: BLE001 - an unreadable POU cannot be checked
409
+ continue
410
+ if body:
411
+ bodies[pou.name] = body
412
+ return find_referencing_pous(variable, bodies)
413
+
414
+
415
+ from .variable_edit import add as plan_variable_add, edit as plan_variable_edit, delete as plan_variable_delete
416
+
417
+
418
+ def apply_declaration(plan: WritePlan, project_root: Path, dry_run: bool = False) -> dict[str, object]:
419
+ """Apply a declaration plan (add, edit or delete)."""
420
+ return _apply(plan, project_root, dry_run=dry_run)
421
+
422
+
423
+ def plan_variable_description(
424
+ project_root: Path,
425
+ pou_name: str | None,
426
+ variable: str,
427
+ description: str | None,
428
+ ) -> WritePlan:
429
+ """Build the plan for a description change without writing anything.
430
+
431
+ ``pou_name`` of None targets the project's global variables.
432
+ """
433
+ from . import project as P
434
+
435
+ proj = P.Project(root=project_root)
436
+ if pou_name:
437
+ pou = proj.pou(pou_name)
438
+ source = pou.source_path
439
+ stream = pou.declaration_stream_name()
440
+ if stream is None:
441
+ raise WriteRefused(f"POU {pou_name!r} has no declaration stream")
442
+ else:
443
+ source = proj.resource_source()
444
+ if source is None:
445
+ raise WriteRefused("no resource src.st1 found for global variables")
446
+ cfb_names = CompoundFile(source).stream_names()
447
+ candidates = [n for n in cfb_names if n.upper().endswith(".VB")]
448
+ if not candidates:
449
+ raise WriteRefused("resource src.st1 has no textual declaration stream")
450
+ stream = candidates[0]
451
+
452
+ cfb = CompoundFile(source)
453
+ before = cfb.read_stream(stream)
454
+ text = before.decode("latin1")
455
+ updated, notes = set_variable_description(text, variable, description)
456
+ return WritePlan(
457
+ target=source,
458
+ stream=stream,
459
+ before=before,
460
+ after=updated.encode("latin1"),
461
+ notes=notes,
462
+ )
463
+
464
+
465
+ def apply_plan(plan: WritePlan, project_root: Path, dry_run: bool = False) -> dict[str, object]:
466
+ """Back up, write, and verify a plan. Returns a result summary."""
467
+ return _apply(plan, project_root, dry_run=dry_run)
468
+
469
+
470
+ # ---------------------------------------------------------------------------
471
+ # Structured Text body replacement (.STB only)
472
+ # ---------------------------------------------------------------------------
473
+
474
+
475
+ def plan_st_body(
476
+ project_root: Path,
477
+ pou_name: str,
478
+ body: str,
479
+ run_lint: bool = True,
480
+ ) -> WritePlan:
481
+ """Build the plan for replacing a POU's Structured Text body.
482
+
483
+ Only the ``.STB`` stream changes. The body does not reference the variable
484
+ grid, so this needs no binary grid edit and no project-tree edit.
485
+
486
+ Refuses graphical POUs (their logic is proprietary binary), compressed
487
+ declaration streams, and -- when ``run_lint`` is set -- a body whose new
488
+ variables would be undeclared.
489
+ """
490
+ from . import project as P
491
+ from .stlint import Finding, lint, check_assignment_types
492
+
493
+ proj = P.Project(root=project_root)
494
+ pou = proj.pou(pou_name)
495
+
496
+ found = pou.body_stream()
497
+ if found is None:
498
+ raise WriteRefused(f"POU {pou_name!r} has no body stream")
499
+ stream, language = found
500
+ if language != "ST":
501
+ raise WriteRefused(
502
+ f"POU {pou_name!r} is {language} (graphical). Its body is proprietary "
503
+ f"binary and is not replaced as text; transplanting a proven body is "
504
+ f"the supported route."
505
+ )
506
+
507
+ # A POU whose declarations cannot be read cannot be safely checked.
508
+ table = pou.declarations()
509
+ if table.warnings and not table.variables and table.source_stream is None:
510
+ raise WriteRefused(
511
+ f"declarations for {pou_name!r} are unreadable, so the new body "
512
+ f"cannot be validated: {'; '.join(table.warnings)}"
513
+ )
514
+
515
+ notes: list[str] = []
516
+ # Read the current body first, so the new one can follow its conventions.
517
+ cfb = CompoundFile(pou.source_path)
518
+ before = cfb.read_stream(stream)
519
+
520
+ normalised = body.replace("\r\n", "\n").replace("\r", "\n")
521
+ normalised = normalised.replace("\n", "\r\n")
522
+ # An empty body stays empty. Appending a CRLF to it would turn a no-op into a
523
+ # two-byte write, so replacing an empty body with itself would change the file
524
+ # -- which a stress matrix caught, since some POUs legitimately have a zero-byte
525
+ # body stream.
526
+ #
527
+ # A trailing newline is only added when the body would otherwise have none *and*
528
+ # the existing stream ends with one. `st_body()` reports the body without a
529
+ # final newline even when the stored stream has one, so consulting the stream is
530
+ # what keeps a same-body write a true no-op instead of growing the file by two
531
+ # bytes every time.
532
+ stream_ends_with_newline = before.endswith(b"\n")
533
+ if (
534
+ normalised
535
+ and not normalised.endswith("\r\n")
536
+ and stream_ends_with_newline
537
+ ):
538
+ normalised += "\r\n"
539
+ notes.append("added a trailing CRLF to the body")
540
+
541
+ native_prefix = re.match(rb'^(?:\(\*[^*]*[\x00-\x08\x0b-\x1f][\s\S]*?\*\)(?:\r\n|\n)*)+', before)
542
+ if native_prefix and not normalised.encode("latin1").startswith(native_prefix.group(0)):
543
+ normalised = native_prefix.group(0).decode("latin1") + normalised
544
+ notes.append("Preserved native leading MotionWorks control markers")
545
+
546
+ if run_lint:
547
+ declared = {v.name for v in table.variables}
548
+ locals_only = {v.name for v in table.variables if not v.is_external}
549
+ fb_types = {v.name for v in table.variables if v.is_fb_instance}
550
+ result = lint(
551
+ normalised,
552
+ declared=declared,
553
+ known_symbols=P.known_symbol_names(proj),
554
+ fb_instance_types=fb_types,
555
+ local_vars=locals_only,
556
+ project_globals=P.global_names(proj),
557
+ )
558
+ errors = [f for f in result.findings if f.severity == "error"]
559
+
560
+ # A type error does not merely fail a build. Measured: assigning a UINT to a BOOL makes
561
+ # the builder DESTROY the POU - .VB to 0 bytes and the grid to 79,432,063 bytes - while
562
+ # a type-correct body with a different fault stalls harmlessly and leaves it intact. So
563
+ # a type error is refused here, alongside the undeclared-symbol errors above, and the
564
+ # body never reaches the compiler. Built as its own list and extended, so it cannot
565
+ # depend on the order these lines run in.
566
+ _types = {}
567
+ for _v in table.variables:
568
+ _n = getattr(_v, 'name', None)
569
+ _t = getattr(_v, 'type_name', None)
570
+ if _n and _t:
571
+ _types[_n] = _t
572
+ errors.extend(
573
+ Finding(
574
+ severity='error',
575
+ code='type-mismatch',
576
+ message=(
577
+ f"line {_line}: assigning a {_got.upper()} to '{_target}', which is "
578
+ f"{_want} - a type error makes the build DESTROY this POU rather than "
579
+ f"report it, so the body is refused"
580
+ ),
581
+ line=_line,
582
+ hint='Convert the value explicitly, or declare a variable of the right type.',
583
+ )
584
+ for _line, _target, _want, _got in check_assignment_types(normalised, _types)
585
+ )
586
+
587
+ # ── a real compiler, when the machine has one ────────────────────────────────
588
+ #
589
+ # The checks above are hand-written and catch what they were written for. This asks an actual
590
+ # IEC 61131-3 compiler, which knows considerably more - and it fails soft, because a missing
591
+ # tool must never stop a write that would otherwise be fine.
592
+ if run_lint:
593
+ try:
594
+ from . import iec as _iec
595
+
596
+ _diagnostics = _iec.validate_pou(pou_name, table, normalised)
597
+ if _diagnostics is None:
598
+ notes.append("not checked by an IEC compiler (none available)")
599
+ else:
600
+ notes.append(_iec.summary(_diagnostics))
601
+ for _d in _diagnostics:
602
+ errors.append(Finding(
603
+ severity="error",
604
+ code=_d.code,
605
+ message=f"IEC compiler: {_d.as_text()}",
606
+ line=_d.line,
607
+ hint="Fix the statement; a real compiler rejected it before it was written.",
608
+ ))
609
+ except Exception as _exc: # a broken compiler must never block a write
610
+ notes.append(f"IEC compiler check skipped: {type(_exc).__name__}")
611
+
612
+ if errors:
613
+ detail = "\n ".join(f.message for f in errors[:8])
614
+ raise WriteRefused(
615
+ f"the new body has lint errors and was not written:\n {detail}\n"
616
+ f"Pass run_lint=False to override, but expect the build to fail."
617
+ )
618
+ warnings = [f for f in result.findings if f.severity == "warning"]
619
+ if warnings:
620
+ notes.append(f"{len(warnings)} lint warning(s) present")
621
+
622
+ after = normalised.encode("latin1")
623
+
624
+ # A guard used to refuse growing a mini stream past the cutoff, because the
625
+ # container writer corrupted that promotion. The container defect is fixed and
626
+ # verified (a stream promoted from 809 to 18016 bytes reads back exactly, and so
627
+ # does the restore), so the restriction is gone: twelve ST POUs across the
628
+ # sample projects keep their body as a mini stream, and refusing would have
629
+ # blocked a large body edit on every one of them.
630
+ return WritePlan(
631
+ target=pou.source_path,
632
+ stream=stream,
633
+ before=before,
634
+ after=after,
635
+ notes=notes,
636
+ )
637
+
638
+
639
+ def plan_st_body_from_file(
640
+ project_root: Path,
641
+ pou_name: str,
642
+ body_path: Path,
643
+ run_lint: bool = True,
644
+ ) -> WritePlan:
645
+ """Plan an ST body replacement taking the new body from a file.
646
+
647
+ Preferred over passing the body inline for anything sizeable: real ST bodies
648
+ run to tens of kilobytes (the largest in the sample projects is 30,405
649
+ characters), which is awkward and error-prone to move through a JSON tool
650
+ argument.
651
+ """
652
+ body_path = Path(body_path)
653
+ if not body_path.is_file():
654
+ raise WriteRefused(f"body file not found: {body_path}")
655
+ try:
656
+ text = body_path.read_text(encoding="utf-8")
657
+ except UnicodeDecodeError:
658
+ text = body_path.read_text(encoding="latin1")
659
+ plan = plan_st_body(project_root, pou_name, text, run_lint=run_lint)
660
+ plan.notes.append(f"body read from {body_path}")
661
+ return plan
662
+
663
+
664
+ def apply_st_body(plan: WritePlan, project_root: Path, dry_run: bool = False) -> dict[str, object]:
665
+ """Apply a Structured Text body plan."""
666
+ return _apply(plan, project_root, dry_run=dry_run)