@aryacode/dsh-engineering 0.0.0-stage → 0.3.0

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 (281) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/cordis.patch.yml +122 -0
  5. package/locale/en.json +6 -0
  6. package/locale/zh.json +6 -0
  7. package/package.json +77 -3
  8. package/vendor/ftview/README.md +55 -0
  9. package/vendor/ftview/SKILL.md +74 -0
  10. package/vendor/ftview/lib/checks.d.ts +27 -0
  11. package/vendor/ftview/lib/checks.js +39 -0
  12. package/vendor/ftview/lib/core.d.ts +208 -0
  13. package/vendor/ftview/lib/core.js +160 -0
  14. package/vendor/ftview/lib/edit/drafts.d.ts +49 -0
  15. package/vendor/ftview/lib/edit/drafts.js +84 -0
  16. package/vendor/ftview/lib/edit/operations.d.ts +62 -0
  17. package/vendor/ftview/lib/edit/operations.js +126 -0
  18. package/vendor/ftview/lib/edit/prepare.d.ts +38 -0
  19. package/vendor/ftview/lib/edit/prepare.js +133 -0
  20. package/vendor/ftview/lib/edit/tools.d.ts +25 -0
  21. package/vendor/ftview/lib/edit/tools.js +198 -0
  22. package/vendor/ftview/lib/edit.d.ts +30 -0
  23. package/vendor/ftview/lib/edit.js +38 -0
  24. package/vendor/ftview/lib/index.d.ts +31 -0
  25. package/vendor/ftview/lib/index.js +36 -0
  26. package/vendor/ftview/lib/model.d.ts +80 -0
  27. package/vendor/ftview/lib/model.js +267 -0
  28. package/vendor/ftview/lib/session.d.ts +48 -0
  29. package/vendor/ftview/lib/session.js +98 -0
  30. package/vendor/ftview/lib/skill.d.ts +29 -0
  31. package/vendor/ftview/lib/skill.js +43 -0
  32. package/vendor/ftview/lib/studio/discover.d.ts +36 -0
  33. package/vendor/ftview/lib/studio/discover.js +87 -0
  34. package/vendor/ftview/lib/studio/preflight.d.ts +51 -0
  35. package/vendor/ftview/lib/studio/preflight.js +64 -0
  36. package/vendor/ftview/lib/studio/tools.d.ts +15 -0
  37. package/vendor/ftview/lib/studio/tools.js +83 -0
  38. package/vendor/ftview/lib/studio.d.ts +30 -0
  39. package/vendor/ftview/lib/studio.js +37 -0
  40. package/vendor/ftview/lib/tools.d.ts +14 -0
  41. package/vendor/ftview/lib/tools.js +202 -0
  42. package/vendor/l5x/README.md +62 -0
  43. package/vendor/l5x/SKILL.md +81 -0
  44. package/vendor/l5x/lib/approvals.d.ts +50 -0
  45. package/vendor/l5x/lib/approvals.js +93 -0
  46. package/vendor/l5x/lib/client.js +412 -0
  47. package/vendor/l5x/lib/client.js.map +1 -0
  48. package/vendor/l5x/lib/core/errors.d.ts +38 -0
  49. package/vendor/l5x/lib/core/errors.js +48 -0
  50. package/vendor/l5x/lib/core/params.d.ts +49 -0
  51. package/vendor/l5x/lib/core/params.js +39 -0
  52. package/vendor/l5x/lib/core/result.d.ts +46 -0
  53. package/vendor/l5x/lib/core/result.js +21 -0
  54. package/vendor/l5x/lib/core/tool.d.ts +102 -0
  55. package/vendor/l5x/lib/core/tool.js +19 -0
  56. package/vendor/l5x/lib/drafts.d.ts +66 -0
  57. package/vendor/l5x/lib/drafts.js +98 -0
  58. package/vendor/l5x/lib/editing.d.ts +30 -0
  59. package/vendor/l5x/lib/editing.js +42 -0
  60. package/vendor/l5x/lib/engine/acd-edit.d.ts +13 -0
  61. package/vendor/l5x/lib/engine/acd-edit.js +115 -0
  62. package/vendor/l5x/lib/engine/analysis.d.ts +22 -0
  63. package/vendor/l5x/lib/engine/analysis.js +258 -0
  64. package/vendor/l5x/lib/engine/capabilities.d.ts +9 -0
  65. package/vendor/l5x/lib/engine/capabilities.js +40 -0
  66. package/vendor/l5x/lib/engine/change-set.d.ts +122 -0
  67. package/vendor/l5x/lib/engine/change-set.js +446 -0
  68. package/vendor/l5x/lib/engine/configuration.d.ts +21 -0
  69. package/vendor/l5x/lib/engine/configuration.js +42 -0
  70. package/vendor/l5x/lib/engine/details.d.ts +47 -0
  71. package/vendor/l5x/lib/engine/details.js +173 -0
  72. package/vendor/l5x/lib/engine/direct-polling.d.ts +91 -0
  73. package/vendor/l5x/lib/engine/direct-polling.js +193 -0
  74. package/vendor/l5x/lib/engine/download-review.d.ts +15 -0
  75. package/vendor/l5x/lib/engine/download-review.js +63 -0
  76. package/vendor/l5x/lib/engine/logic-syntax.d.ts +60 -0
  77. package/vendor/l5x/lib/engine/logic-syntax.js +237 -0
  78. package/vendor/l5x/lib/engine/observations.d.ts +41 -0
  79. package/vendor/l5x/lib/engine/observations.js +67 -0
  80. package/vendor/l5x/lib/engine/project-analysis.d.ts +131 -0
  81. package/vendor/l5x/lib/engine/project-analysis.js +314 -0
  82. package/vendor/l5x/lib/engine/review-evidence.d.ts +8 -0
  83. package/vendor/l5x/lib/engine/review-evidence.js +49 -0
  84. package/vendor/l5x/lib/engine/review.d.ts +18 -0
  85. package/vendor/l5x/lib/engine/review.js +123 -0
  86. package/vendor/l5x/lib/engine/studio-sdk.d.ts +101 -0
  87. package/vendor/l5x/lib/engine/studio-sdk.js +571 -0
  88. package/vendor/l5x/lib/engine/tag-resolution.d.ts +20 -0
  89. package/vendor/l5x/lib/engine/tag-resolution.js +169 -0
  90. package/vendor/l5x/lib/engine/usage.d.ts +16 -0
  91. package/vendor/l5x/lib/engine/usage.js +92 -0
  92. package/vendor/l5x/lib/engine/xml-regions.d.ts +23 -0
  93. package/vendor/l5x/lib/engine/xml-regions.js +77 -0
  94. package/vendor/l5x/lib/fs.d.ts +17 -0
  95. package/vendor/l5x/lib/fs.js +36 -0
  96. package/vendor/l5x/lib/index.d.ts +36 -0
  97. package/vendor/l5x/lib/index.js +42 -0
  98. package/vendor/l5x/lib/live.d.ts +48 -0
  99. package/vendor/l5x/lib/live.js +48 -0
  100. package/vendor/l5x/lib/model/aois.d.ts +39 -0
  101. package/vendor/l5x/lib/model/aois.js +112 -0
  102. package/vendor/l5x/lib/model/errors.d.ts +15 -0
  103. package/vendor/l5x/lib/model/errors.js +18 -0
  104. package/vendor/l5x/lib/model/index.d.ts +20 -0
  105. package/vendor/l5x/lib/model/index.js +19 -0
  106. package/vendor/l5x/lib/model/module.d.ts +37 -0
  107. package/vendor/l5x/lib/model/module.js +54 -0
  108. package/vendor/l5x/lib/model/modules.d.ts +24 -0
  109. package/vendor/l5x/lib/model/modules.js +55 -0
  110. package/vendor/l5x/lib/model/parse.d.ts +32 -0
  111. package/vendor/l5x/lib/model/parse.js +116 -0
  112. package/vendor/l5x/lib/model/project.d.ts +15 -0
  113. package/vendor/l5x/lib/model/project.js +31 -0
  114. package/vendor/l5x/lib/model/routines.d.ts +71 -0
  115. package/vendor/l5x/lib/model/routines.js +183 -0
  116. package/vendor/l5x/lib/model/tags.d.ts +51 -0
  117. package/vendor/l5x/lib/model/tags.js +119 -0
  118. package/vendor/l5x/lib/model/types.d.ts +304 -0
  119. package/vendor/l5x/lib/model/types.js +12 -0
  120. package/vendor/l5x/lib/model/udts.d.ts +34 -0
  121. package/vendor/l5x/lib/model/udts.js +81 -0
  122. package/vendor/l5x/lib/model/xml.d.ts +139 -0
  123. package/vendor/l5x/lib/model/xml.js +291 -0
  124. package/vendor/l5x/lib/model/xref.d.ts +28 -0
  125. package/vendor/l5x/lib/model/xref.js +154 -0
  126. package/vendor/l5x/lib/session.d.ts +119 -0
  127. package/vendor/l5x/lib/session.js +236 -0
  128. package/vendor/l5x/lib/skill.d.ts +30 -0
  129. package/vendor/l5x/lib/skill.js +44 -0
  130. package/vendor/l5x/lib/studio-state.d.ts +77 -0
  131. package/vendor/l5x/lib/studio-state.js +79 -0
  132. package/vendor/l5x/lib/studio.d.ts +29 -0
  133. package/vendor/l5x/lib/studio.js +40 -0
  134. package/vendor/l5x/lib/tools/checks.d.ts +19 -0
  135. package/vendor/l5x/lib/tools/checks.js +140 -0
  136. package/vendor/l5x/lib/tools/common.d.ts +41 -0
  137. package/vendor/l5x/lib/tools/common.js +84 -0
  138. package/vendor/l5x/lib/tools/controller.d.ts +21 -0
  139. package/vendor/l5x/lib/tools/controller.js +170 -0
  140. package/vendor/l5x/lib/tools/drafts.d.ts +23 -0
  141. package/vendor/l5x/lib/tools/drafts.js +270 -0
  142. package/vendor/l5x/lib/tools/live.d.ts +21 -0
  143. package/vendor/l5x/lib/tools/live.js +175 -0
  144. package/vendor/l5x/lib/tools/read.d.ts +19 -0
  145. package/vendor/l5x/lib/tools/read.js +245 -0
  146. package/vendor/l5x/lib/tools/references.d.ts +20 -0
  147. package/vendor/l5x/lib/tools/references.js +269 -0
  148. package/vendor/l5x/lib/tools/studio.d.ts +21 -0
  149. package/vendor/l5x/lib/tools/studio.js +193 -0
  150. package/vendor/l5x/poll-worker.mjs +77 -0
  151. package/vendor/l5x/studio-bridge/NuGet.Config +1 -0
  152. package/vendor/l5x/studio-bridge/Program.cs +304 -0
  153. package/vendor/l5x/studio-bridge/StudioBridge.csproj +11 -0
  154. package/vendor/mwi/README.md +60 -0
  155. package/vendor/mwi/SKILL.md +87 -0
  156. package/vendor/mwi/bridge/README.md +8 -0
  157. package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -0
  158. package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -0
  159. package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -0
  160. package/vendor/mwi/bridge/start_bridge.cmd +21 -0
  161. package/vendor/mwi/bridge/watch_dialogs.cmd +13 -0
  162. package/vendor/mwi/bridge/watch_dialogs.ps1 +153 -0
  163. package/vendor/mwi/code/engine/motionworks_iec_mcp/__init__.py +14 -0
  164. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  165. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
  166. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
  167. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
  168. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
  169. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
  170. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
  171. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
  172. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
  173. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
  174. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
  175. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
  176. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
  177. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
  178. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
  179. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
  180. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
  181. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
  182. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
  183. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
  184. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
  185. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
  186. package/vendor/mwi/code/engine/motionworks_iec_mcp/block_interfaces.py +275 -0
  187. package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -0
  188. package/vendor/mwi/code/engine/motionworks_iec_mcp/capture.py +448 -0
  189. package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -0
  190. package/vendor/mwi/code/engine/motionworks_iec_mcp/com.py +268 -0
  191. package/vendor/mwi/code/engine/motionworks_iec_mcp/datatypes.py +204 -0
  192. package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -0
  193. package/vendor/mwi/code/engine/motionworks_iec_mcp/descriptions.py +30 -0
  194. package/vendor/mwi/code/engine/motionworks_iec_mcp/eip.py +292 -0
  195. package/vendor/mwi/code/engine/motionworks_iec_mcp/errors.py +36 -0
  196. package/vendor/mwi/code/engine/motionworks_iec_mcp/graphical_listing.py +208 -0
  197. package/vendor/mwi/code/engine/motionworks_iec_mcp/grid.py +227 -0
  198. package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +317 -0
  199. package/vendor/mwi/code/engine/motionworks_iec_mcp/iec.py +317 -0
  200. package/vendor/mwi/code/engine/motionworks_iec_mcp/installed_help.py +151 -0
  201. package/vendor/mwi/code/engine/motionworks_iec_mcp/knowledge.py +218 -0
  202. package/vendor/mwi/code/engine/motionworks_iec_mcp/libraries.py +250 -0
  203. package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -0
  204. package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -0
  205. package/vendor/mwi/code/engine/motionworks_iec_mcp/navigation.py +44 -0
  206. package/vendor/mwi/code/engine/motionworks_iec_mcp/pou_writer.py +1547 -0
  207. package/vendor/mwi/code/engine/motionworks_iec_mcp/program_checks.py +360 -0
  208. package/vendor/mwi/code/engine/motionworks_iec_mcp/project.py +704 -0
  209. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -0
  210. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -0
  211. package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -0
  212. package/vendor/mwi/code/engine/motionworks_iec_mcp/server.py +1913 -0
  213. package/vendor/mwi/code/engine/motionworks_iec_mcp/snapshot.py +138 -0
  214. package/vendor/mwi/code/engine/motionworks_iec_mcp/st_comments.py +25 -0
  215. package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +179 -0
  216. package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +676 -0
  217. package/vendor/mwi/code/engine/motionworks_iec_mcp/structure.py +98 -0
  218. package/vendor/mwi/code/engine/motionworks_iec_mcp/task_bindings.py +68 -0
  219. package/vendor/mwi/code/engine/motionworks_iec_mcp/tasks.py +162 -0
  220. package/vendor/mwi/code/engine/motionworks_iec_mcp/transaction.py +157 -0
  221. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree.py +563 -0
  222. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -0
  223. package/vendor/mwi/code/engine/motionworks_iec_mcp/uia.py +465 -0
  224. package/vendor/mwi/code/engine/motionworks_iec_mcp/validation.py +41 -0
  225. package/vendor/mwi/code/engine/motionworks_iec_mcp/variable_edit.py +183 -0
  226. package/vendor/mwi/code/engine/motionworks_iec_mcp/variables.py +472 -0
  227. package/vendor/mwi/code/engine/motionworks_iec_mcp/workflow.py +130 -0
  228. package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +666 -0
  229. package/vendor/mwi/code/mw_code.py +1258 -0
  230. package/vendor/mwi/legacy/edit-session.js +50 -0
  231. package/vendor/mwi/legacy/fb-insertion.js +74 -0
  232. package/vendor/mwi/legacy/native-code.js +83 -0
  233. package/vendor/mwi/legacy/native-groups.js +75 -0
  234. package/vendor/mwi/legacy/native-structure.js +143 -0
  235. package/vendor/mwi/legacy/native-variables.js +77 -0
  236. package/vendor/mwi/legacy/pou-conversion.js +47 -0
  237. package/vendor/mwi/legacy/pou-package.js +66 -0
  238. package/vendor/mwi/lib/core/bridge.d.ts +71 -0
  239. package/vendor/mwi/lib/core/bridge.js +53 -0
  240. package/vendor/mwi/lib/core/code-engine.d.ts +47 -0
  241. package/vendor/mwi/lib/core/code-engine.js +109 -0
  242. package/vendor/mwi/lib/core/errors.d.ts +37 -0
  243. package/vendor/mwi/lib/core/errors.js +47 -0
  244. package/vendor/mwi/lib/core/params.d.ts +52 -0
  245. package/vendor/mwi/lib/core/params.js +46 -0
  246. package/vendor/mwi/lib/core/ps-bridge.d.ts +62 -0
  247. package/vendor/mwi/lib/core/ps-bridge.js +127 -0
  248. package/vendor/mwi/lib/core/result.d.ts +45 -0
  249. package/vendor/mwi/lib/core/result.js +20 -0
  250. package/vendor/mwi/lib/core/tool.d.ts +80 -0
  251. package/vendor/mwi/lib/core/tool.js +55 -0
  252. package/vendor/mwi/lib/edit/compare.d.ts +33 -0
  253. package/vendor/mwi/lib/edit/compare.js +70 -0
  254. package/vendor/mwi/lib/edit.d.ts +20 -0
  255. package/vendor/mwi/lib/edit.js +32 -0
  256. package/vendor/mwi/lib/index.d.ts +21 -0
  257. package/vendor/mwi/lib/index.js +33 -0
  258. package/vendor/mwi/lib/read/service.d.ts +117 -0
  259. package/vendor/mwi/lib/read/service.js +229 -0
  260. package/vendor/mwi/lib/read.d.ts +21 -0
  261. package/vendor/mwi/lib/read.js +33 -0
  262. package/vendor/mwi/lib/session/project.d.ts +34 -0
  263. package/vendor/mwi/lib/session/project.js +54 -0
  264. package/vendor/mwi/lib/session/service.d.ts +167 -0
  265. package/vendor/mwi/lib/session/service.js +566 -0
  266. package/vendor/mwi/lib/session/stage.d.ts +84 -0
  267. package/vendor/mwi/lib/session/stage.js +187 -0
  268. package/vendor/mwi/lib/session/workspace.d.ts +52 -0
  269. package/vendor/mwi/lib/session/workspace.js +90 -0
  270. package/vendor/mwi/lib/skill.d.ts +36 -0
  271. package/vendor/mwi/lib/skill.js +56 -0
  272. package/vendor/mwi/lib/tools/edit.d.ts +20 -0
  273. package/vendor/mwi/lib/tools/edit.js +286 -0
  274. package/vendor/mwi/lib/tools/read.d.ts +19 -0
  275. package/vendor/mwi/lib/tools/read.js +161 -0
  276. package/vendor/mwi/lib/tools/session.d.ts +20 -0
  277. package/vendor/mwi/lib/tools/session.js +166 -0
  278. package/vendor/mwi/lib/tools/verify.d.ts +19 -0
  279. package/vendor/mwi/lib/tools/verify.js +85 -0
  280. package/vendor/mwi/lib/verify.d.ts +17 -0
  281. package/vendor/mwi/lib/verify.js +29 -0
@@ -0,0 +1,465 @@
1
+ """Look at and interact with Windows GUI elements, via UI Automation.
2
+
3
+ This gives the agent hands to go with the eyes in :mod:`motionworks_iec_mcp.capture`.
4
+ It exists because automating MotionWorks needs it: the IDE shows a **"Use Trial"**
5
+ dialog on startup, and until that is dismissed the IDE never loads a project, so every
6
+ COM call fails. Clicking that button is not something COM can do.
7
+
8
+ Two rules shaped the design, and both come from testing rather than theory:
9
+
10
+ * **Prefer the accessibility pattern over synthetic input.** Tapping a control through
11
+ ``InvokePattern`` is addressed by name, so it cannot miss the target the way a
12
+ coordinate does, and it does not depend on which window happens to be on top.
13
+ Raw mouse/keyboard input is the fallback, not the first choice.
14
+ * **Verify the target before acting.** Every lookup returns the element's control type
15
+ and rectangle, so a caller can confirm it found a *button* before clicking it.
16
+
17
+ These functions act on the live desktop, so they are deliberately explicit about what
18
+ they matched. Nothing here clicks by blind coordinate unless asked to.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ import time
25
+
26
+
27
+ class UiaError(RuntimeError):
28
+ """A UI element could not be found or acted on."""
29
+
30
+
31
+ def _automation():
32
+ """The UI Automation namespaces, imported lazily.
33
+
34
+ ``uiautomation`` is not required: everything here goes through ``comtypes``-free
35
+ .NET interop exposed by PowerShell, so the only requirement is Windows itself.
36
+ """
37
+ return None
38
+
39
+
40
+ #: PowerShell helper. UI Automation is reached through .NET, which is already present;
41
+ #: this avoids a third-party dependency for a core capability. The script writes JSON
42
+ #: to a file because capturing a child's stdout through a pipe truncates here.
43
+ _HELPER = r"""
44
+ param(
45
+ [string]$Action = 'list',
46
+ [string]$Out = '',
47
+ [string]$Match = '',
48
+ [string]$ControlType = '',
49
+ [int]$Handle = 0,
50
+ [string]$Text = ''
51
+ )
52
+
53
+ Add-Type -AssemblyName UIAutomationClient
54
+ Add-Type -AssemblyName UIAutomationTypes
55
+
56
+ function Get-Root {
57
+ param([int]$H)
58
+ $auto = [System.Windows.Automation.AutomationElement]
59
+ if ($H -ne 0) {
60
+ return $auto::FromHandle([IntPtr]$H)
61
+ }
62
+ return $auto::RootElement
63
+ }
64
+
65
+ function Describe-Element {
66
+ param($e)
67
+ $r = $e.Current.BoundingRectangle
68
+ [pscustomobject]@{
69
+ name = $e.Current.Name
70
+ type = $e.Current.ControlType.ProgrammaticName.Replace('ControlType.','')
71
+ automationId= $e.Current.AutomationId
72
+ enabled = $e.Current.IsEnabled
73
+ offscreen = $e.Current.IsOffscreen
74
+ left = [int]$r.Left
75
+ top = [int]$r.Top
76
+ width = [int]$r.Width
77
+ height = [int]$r.Height
78
+ }
79
+ }
80
+
81
+ $results = New-Object System.Collections.Generic.List[object]
82
+ $note = ''
83
+
84
+ try {
85
+ $root = Get-Root -H $Handle
86
+
87
+ if ($Action -eq 'click' -or $Action -eq 'invoke') {
88
+ # Find by exact name first, then fall back to a substring match.
89
+ $target = $null
90
+ foreach ($scope in @([System.Windows.Automation.TreeScope]::Descendants)) { }
91
+ $all = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants,
92
+ [System.Windows.Automation.Condition]::TrueCondition)
93
+ $candidates = @()
94
+ foreach ($e in $all) {
95
+ $n = $e.Current.Name
96
+ if (-not $n) { continue }
97
+ if ($n -eq $Match) { $candidates = @($e); break }
98
+ if ($Match -and $n.ToLower().Contains($Match.ToLower())) { $candidates += $e }
99
+ }
100
+ if (-not $candidates -or $candidates.Count -eq 0) {
101
+ throw "no element named or containing '$Match'"
102
+ }
103
+ $target = $candidates[0]
104
+ $ok = $false
105
+ try {
106
+ $p = $target.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)
107
+ $p.Invoke()
108
+ $ok = $true
109
+ $note = 'invoked via InvokePattern'
110
+ } catch { }
111
+ if (-not $ok) {
112
+ $note = 'no InvokePattern; reported rectangle for a coordinate click'
113
+ }
114
+ $results.Add((Describe-Element $target))
115
+ if (-not $ok) { $note += '' }
116
+ }
117
+ else {
118
+ $all = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants,
119
+ [System.Windows.Automation.Condition]::TrueCondition)
120
+ foreach ($e in $all) {
121
+ $n = $e.Current.Name
122
+ if (-not $n) { continue }
123
+ $t = $e.Current.ControlType.ProgrammaticName.Replace('ControlType.','')
124
+ if ($ControlType -and $t -ne $ControlType) { continue }
125
+ $results.Add((Describe-Element $e))
126
+ }
127
+ }
128
+ $note = if ($note) { $note } else { "found $($results.Count) element(s)" }
129
+ } catch {
130
+ $note = "error: $($_.Exception.Message)"
131
+ }
132
+
133
+ $payload = [pscustomobject]@{ note = $note; elements = $results }
134
+ $json = $payload | ConvertTo-Json -Depth 6 -Compress
135
+ if ($Out) { Set-Content -LiteralPath $Out -Value $json -Encoding UTF8 } else { $json }
136
+ """
137
+
138
+
139
+ def _run_helper(
140
+ action: str,
141
+ match: str = "",
142
+ control_type: str = "",
143
+ handle: int = 0,
144
+ timeout_s: float = 60.0,
145
+ ) -> dict[str, object]:
146
+ """Run the UI Automation helper and return its parsed result."""
147
+ import json
148
+ import subprocess
149
+ import tempfile
150
+ from pathlib import Path
151
+
152
+ with tempfile.TemporaryDirectory() as tmp:
153
+ script = Path(tmp) / "uia.ps1"
154
+ listing = Path(tmp) / "uia.json"
155
+ script.write_text(_HELPER, encoding="ascii")
156
+ command = [
157
+ "powershell.exe",
158
+ "-NoLogo",
159
+ "-NoProfile",
160
+ "-NonInteractive",
161
+ "-ExecutionPolicy",
162
+ "Bypass",
163
+ "-File",
164
+ str(script),
165
+ "-Action",
166
+ action,
167
+ "-Out",
168
+ str(listing),
169
+ "-Match",
170
+ match,
171
+ "-ControlType",
172
+ control_type,
173
+ "-Handle",
174
+ str(handle),
175
+ ]
176
+ try:
177
+ subprocess.run(
178
+ command,
179
+ stdout=subprocess.DEVNULL,
180
+ stderr=subprocess.DEVNULL,
181
+ timeout=timeout_s + 60,
182
+ creationflags=0x08000000,
183
+ )
184
+ except Exception as exc: # noqa: BLE001
185
+ raise UiaError(f"could not run the UI Automation helper: {exc}") from exc
186
+ try:
187
+ return json.loads(listing.read_text(encoding="utf-8-sig"))
188
+ except (OSError, json.JSONDecodeError) as exc:
189
+ raise UiaError(f"the helper produced no usable result: {exc}") from exc
190
+
191
+
192
+ def elements(
193
+ title_contains: str | None = None,
194
+ handle: int | None = None,
195
+ control_type: str = "",
196
+ ) -> list[dict[str, object]]:
197
+ """Every named UI element inside a window (or the whole desktop).
198
+
199
+ ``control_type`` filters to one kind, e.g. ``"Button"`` -- which is how you find
200
+ something clickable without reading through every label.
201
+ """
202
+ hwnd = handle or 0
203
+ if title_contains and not hwnd:
204
+ from . import capture
205
+
206
+ window = capture.find_window(title_contains=title_contains)
207
+ if window is None:
208
+ raise UiaError(f"no visible window matching {title_contains!r}")
209
+ hwnd = int(window["handle"])
210
+ result = _run_helper("list", control_type=control_type, handle=hwnd)
211
+ return list(result.get("elements") or [])
212
+
213
+
214
+ def find_button(name_contains: str, title_contains: str | None = None):
215
+ """The first control whose name contains ``name_contains``.
216
+
217
+ Despite the name this does **not** filter to ``ControlType.Button``, and that is
218
+ the whole point. Measured on the MotionWorks trial dialog, every clickable control
219
+ reports itself as a **``Pane``**, not a Button -- which is exactly why the original
220
+ trial-dismissal code, which searched for a Button named "Use Trial", never found
221
+ anything and the IDE sat waiting forever. Matching by name alone finds it.
222
+ """
223
+ for element in elements(title_contains=title_contains):
224
+ if name_contains.casefold() in str(element.get("name", "")).casefold():
225
+ return element
226
+ return None
227
+
228
+
229
+ def click_element(
230
+ name: str, title_contains: str | None = None, handle: int | None = None
231
+ ) -> dict[str, object]:
232
+ """Click a control by name, preferring the accessibility ``InvokePattern``.
233
+
234
+ Addresses the control by name rather than by coordinate where possible. When a
235
+ control exposes no invoke pattern -- as the MotionWorks dialog's ``Pane`` elements
236
+ do not -- it falls back to a **synthetic click at the element's own centre**, and
237
+ the result says which route was taken so the caller can tell them apart.
238
+ """
239
+ hwnd = handle or 0
240
+ if title_contains and not hwnd:
241
+ from . import capture
242
+
243
+ window = capture.find_window(title_contains=title_contains)
244
+ if window is None:
245
+ raise UiaError(f"no visible window matching {title_contains!r}")
246
+ hwnd = int(window["handle"])
247
+ result = _run_helper("click", match=name, handle=hwnd)
248
+ matched = list(result.get("elements") or [])
249
+ if not matched:
250
+ raise UiaError(str(result.get("note", "nothing matched")))
251
+ element = matched[0]
252
+ note = str(result.get("note", ""))
253
+ if "InvokePattern" in note:
254
+ element["invoked"] = True
255
+ element["note"] = note
256
+ return element
257
+
258
+ # No invoke pattern: click the centre of the rectangle UIA reported.
259
+ left, top = int(element["left"]), int(element["top"])
260
+ width, height = int(element["width"]), int(element["height"])
261
+ if width <= 0 or height <= 0:
262
+ raise UiaError(
263
+ f"found {element.get('name')!r} but it has no size, so it cannot be "
264
+ f"clicked"
265
+ )
266
+ x, y = left + width // 2, top + height // 2
267
+ detail = click_point(x, y)
268
+ element["invoked"] = True
269
+ element["note"] = (
270
+ f"no InvokePattern, so clicked at the element centre ({x},{y}) via SendInput"
271
+ )
272
+ element["clicked_at"] = detail.get("clicked_at", (x, y))
273
+ return element
274
+
275
+
276
+ def _ensure_dpi_aware() -> None:
277
+ """Make this process per-monitor DPI aware, once.
278
+
279
+ Without this, a click at a coordinate that UI Automation reported fails outright:
280
+ on this machine the trial dialog sits on a monitor with **negative x**, and an
281
+ unaware process asking for ``(-3227, 542)`` gets
282
+ ``SetCursorPos: (18, 'There are no more files.')``. The coordinates UIA returns
283
+ are physical pixels, so the process must be aware to use them.
284
+ """
285
+ global _DPI_SET
286
+
287
+ if _DPI_SET:
288
+ return
289
+ _DPI_SET = True
290
+ try:
291
+ import ctypes
292
+
293
+ # -4 = DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (Windows 10 1703+)
294
+ if not ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)):
295
+ # -2 = PER_MONITOR_AWARE, for older builds
296
+ ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-2))
297
+ except Exception: # noqa: BLE001 - best effort; the fallback below still tries
298
+ try:
299
+ import ctypes
300
+
301
+ ctypes.windll.shcore.SetProcessDpiAwareness(2)
302
+ except Exception: # noqa: BLE001
303
+ pass
304
+
305
+
306
+ _DPI_SET = False
307
+
308
+
309
+ def click_point(x: int, y: int) -> dict[str, object]:
310
+ """Synthesise a left click at an absolute desktop coordinate.
311
+
312
+ Coordinates are physical pixels across the whole virtual desktop, matching the
313
+ rectangles reported by :func:`elements` and
314
+ :func:`motionworks_iec_mcp.capture.list_windows`.
315
+
316
+ Uses ``SendInput`` with ``MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK`` rather
317
+ than ``SetCursorPos`` plus ``mouse_event``: the latter rejects coordinates on a
318
+ monitor left of the primary one, which is where the IDE's dialog sits here.
319
+ """
320
+ if sys.platform != "win32":
321
+ raise UiaError("synthetic input is only implemented on Windows")
322
+ import ctypes
323
+ from ctypes import wintypes
324
+
325
+ _ensure_dpi_aware()
326
+
327
+ user32 = ctypes.windll.user32
328
+ virtual_left = user32.GetSystemMetrics(76) # SM_XVIRTUALSCREEN
329
+ virtual_top = user32.GetSystemMetrics(77)
330
+ virtual_width = user32.GetSystemMetrics(78)
331
+ virtual_height = user32.GetSystemMetrics(79)
332
+ if virtual_width <= 0 or virtual_height <= 0:
333
+ raise UiaError("could not read the virtual desktop size")
334
+
335
+ # SendInput takes coordinates normalised to 0..65535 across the virtual desktop.
336
+ normalised_x = int((int(x) - virtual_left) * 65535 / max(1, virtual_width - 1))
337
+ normalised_y = int((int(y) - virtual_top) * 65535 / max(1, virtual_height - 1))
338
+
339
+ class MOUSEINPUT(ctypes.Structure):
340
+ _fields_ = [
341
+ ("dx", wintypes.LONG),
342
+ ("dy", wintypes.LONG),
343
+ ("mouseData", wintypes.DWORD),
344
+ ("dwFlags", wintypes.DWORD),
345
+ ("time", wintypes.DWORD),
346
+ ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),
347
+ ]
348
+
349
+ class INPUT(ctypes.Structure):
350
+ class _UNION(ctypes.Union):
351
+ _fields_ = [("mi", MOUSEINPUT)]
352
+
353
+ _anonymous_ = ("u",)
354
+ _fields_ = [("type", wintypes.DWORD), ("u", _UNION)]
355
+
356
+ MOUSEEVENTF_MOVE = 0x0001
357
+ MOUSEEVENTF_LEFTDOWN = 0x0002
358
+ MOUSEEVENTF_LEFTUP = 0x0004
359
+ MOUSEEVENTF_ABSOLUTE = 0x8000
360
+ MOUSEEVENTF_VIRTUALDESK = 0x4000
361
+ flags = (
362
+ MOUSEEVENTF_MOVE
363
+ | MOUSEEVENTF_ABSOLUTE
364
+ | MOUSEEVENTF_VIRTUALDESK
365
+ | MOUSEEVENTF_LEFTDOWN
366
+ | MOUSEEVENTF_LEFTUP
367
+ )
368
+
369
+ events = (INPUT * 1)()
370
+ events[0].type = 0 # INPUT_MOUSE
371
+ events[0].mi = MOUSEINPUT(
372
+ normalised_x, normalised_y, 0, flags, 0, None
373
+ )
374
+ sent = user32.SendInput(1, ctypes.byref(events), ctypes.sizeof(INPUT))
375
+ if sent != 1:
376
+ raise UiaError(
377
+ f"SendInput sent {sent} of 1 event; clicking at ({x},{y}) failed"
378
+ )
379
+ return {"clicked_at": (int(x), int(y)), "sent": int(sent)}
380
+
381
+
382
+ def type_text(text: str, interval: float = 0.01) -> None:
383
+ """Type text into whatever window has focus."""
384
+ if sys.platform != "win32":
385
+ raise UiaError("synthetic input is only implemented on Windows")
386
+ import win32api
387
+ import win32con
388
+
389
+ for character in text:
390
+ code = win32api.VkKeyScan(character)
391
+ if code == -1:
392
+ continue
393
+ virtual_key = code & 0xFF
394
+ shift = bool(code >> 8 & 1)
395
+ keys = [virtual_key]
396
+ if shift:
397
+ win32api.keybd_event(win32con.VK_SHIFT, 0, 0, 0)
398
+ win32api.keybd_event(virtual_key, 0, 0, 0)
399
+ win32api.keybd_event(virtual_key, 0, win32con.KEYEVENTF_KEYUP, 0)
400
+ if shift:
401
+ win32api.keybd_event(win32con.VK_SHIFT, 0, win32con.KEYEVENTF_KEYUP, 0)
402
+ time.sleep(interval)
403
+
404
+
405
+ def dismiss_trial(title_contains: str = "Motionworks", timeout: float = 45.0):
406
+ """Click **Use Trial** if the trial dialog is showing, and say what happened.
407
+
408
+ This is the single thing that stood between the IDE and every COM call working.
409
+ While this dialog is up, MotionWorks never loads a project, so ``OpenProject`` and
410
+ ``IsProjectOpen`` both return ``Internal error`` -- which looks like a broken
411
+ install but is only an unanswered prompt.
412
+
413
+ Returns ``(dismissed, detail)``. Polls, because the dialog can appear a few seconds
414
+ after the process starts, and a single attempt at a fixed moment misses it.
415
+ """
416
+ deadline = time.monotonic() + timeout
417
+ seen = False
418
+ last = ""
419
+ while time.monotonic() < deadline:
420
+ from . import capture
421
+
422
+ # Exclude browsers. A Chrome tab can be titled with the application name and
423
+ # is far larger than the dialog, so the "largest match" rule would pick it and
424
+ # the dialog would never be found -- which is exactly what happened.
425
+ window = capture.find_window(
426
+ title_contains=title_contains,
427
+ exclude=("chrome", "edge", "firefox", "brave", "opera"),
428
+ )
429
+ windows = [window] if window else []
430
+ if windows:
431
+ handle = int(windows[0]["handle"])
432
+ try:
433
+ for element in elements(handle=handle):
434
+ name = str(element.get("name", ""))
435
+ if "use trial" in name.casefold():
436
+ seen = True
437
+ clicked = click_element(handle=handle, name="Use Trial")
438
+ return True, (
439
+ f"clicked {clicked.get('name')!r}: {clicked.get('note')}"
440
+ )
441
+ except UiaError as exc:
442
+ last = str(exc)
443
+ time.sleep(1.5)
444
+ if seen:
445
+ return False, f"the trial dialog was found but could not be clicked: {last}"
446
+ return False, (
447
+ f"no window matching {title_contains!r} appeared within {timeout:.0f}s"
448
+ )
449
+
450
+
451
+ def describe(elements_list: list[dict[str, object]], limit: int = 40) -> str:
452
+ """A readable listing of UI elements, for a tool reply."""
453
+ if not elements_list:
454
+ return "No named UI elements found."
455
+ lines = [f"{min(len(elements_list), limit)} of {len(elements_list)} element(s):"]
456
+ for element in elements_list[:limit]:
457
+ name = str(element.get("name", ""))[:60]
458
+ kind = element.get("type", "?")
459
+ left, top = element.get("left"), element.get("top")
460
+ width, height = element.get("width"), element.get("height")
461
+ lines.append(
462
+ f" [{kind:<12}] {name!r:<64} at ({left},{top}) {width}x{height}"
463
+ + ("" if element.get("enabled", True) else " DISABLED")
464
+ )
465
+ return "\n".join(lines)
@@ -0,0 +1,41 @@
1
+ """Offline consistency evidence; never a substitute for IDE acceptance."""
2
+ from pathlib import Path
3
+ from .cfb import CompoundFile
4
+ from .grid import validate_pair
5
+ from .tree import parse_document
6
+ from .variable_edit import check_addresses
7
+
8
+
9
+ def validate(project, *, containers=None):
10
+ root = Path(project)
11
+ if root.suffix.lower() == '.mwt': root = root.with_suffix('')
12
+ checked, errors = [], []
13
+ sources = list(root.rglob('src.st1')) if containers is None else containers
14
+ for source in sources:
15
+ source = Path(source)
16
+ label = source.relative_to(root).as_posix()
17
+ try:
18
+ cfb = CompoundFile(source)
19
+ names = cfb.stream_names()
20
+ for name in names:
21
+ # Read every stream to check allocation/readability too.
22
+ raw = cfb.read_stream(name)
23
+ if name.upper().endswith('.VB'):
24
+ grid = next((n for n in names if n.casefold() == (name[:-3]+'.VGR').casefold()), None)
25
+ if grid is None: raise ValueError(f'Missing grid for {name}')
26
+ result = validate_pair(raw.decode('latin1'), cfb.read_stream(grid))
27
+ check_addresses(raw.decode('latin1'))
28
+ checked.append({'file': label, 'stream': name, **result})
29
+ elif name == 'PROJECT.TRE':
30
+ document = parse_document(raw.decode('latin1'))
31
+ nodes = [node for node, _ in document.walk_with_ancestors()]
32
+ if len(nodes) != int(document.lines[1]):
33
+ raise ValueError('PROJECT.TRE total does not match parsed nodes')
34
+ ids = [node.node_id for node in nodes]
35
+ if len(set(ids)) != len(ids): raise ValueError('Duplicate PROJECT.TRE node IDs')
36
+ checked.append({'file': label, 'stream': name, 'nodes': len(nodes)})
37
+ except Exception as exc:
38
+ errors.append({'file': label, 'error': str(exc)})
39
+ if not sources: errors.append({'error': 'No native containers found'})
40
+ return {'ok': not errors, 'verification': 'offline_only', 'checked': checked, 'errors': errors,
41
+ 'not_verified': ['IDE Rebuild', 'Make', 'save/close/reopen', 'controller behavior']}
@@ -0,0 +1,183 @@
1
+ """Plan paired declaration/worksheet edits using compatible native donors."""
2
+ from __future__ import annotations
3
+ import re
4
+ from pathlib import Path
5
+ from . import grid as G, declarations as D
6
+ from .cfb import CompoundFile
7
+ from .errors import UnsupportedFormat
8
+ from .variables import parse_declarations
9
+
10
+
11
+ def _streams(project_root, pou):
12
+ from .writer import _declaration_target
13
+ source, vb, before, text = _declaration_target(project_root, pou)
14
+ cfb = CompoundFile(source)
15
+ vgr = vb[:-3] + '.VGR'
16
+ if vgr not in cfb.stream_names():
17
+ raise UnsupportedFormat(f'Missing paired worksheet {vgr}; refusing a text-only edit')
18
+ raw = cfb.read_stream(vgr)
19
+ # The pair's pre-existing disagreements are returned rather than raised: a project that
20
+ # already carries one record the vendor tooling accepts must not block work on another
21
+ # variable. The edit is still proven not to introduce a new one in _plan.
22
+ return source, vb, before, text, vgr, raw, G.pair_findings(text, raw)
23
+
24
+
25
+ def _address_span(address, type_name):
26
+ if not address: return None
27
+ match = re.fullmatch(r'%([IQM])([XBWDL])(\d+)(?:\.(\d+))?', address.upper())
28
+ if not match: raise UnsupportedFormat(f'Unrecognized direct address {address!r}')
29
+ area, width, offset, bit = match.groups()
30
+ if width == 'X':
31
+ if bit is None or not 0 <= int(bit) <= 7:
32
+ raise UnsupportedFormat('A bit address needs a bit number from 0 to 7')
33
+ start, bits = int(offset) * 8 + int(bit), 1
34
+ else:
35
+ if bit is not None: raise UnsupportedFormat('Non-bit address has a bit suffix')
36
+ start, bits = int(offset) * 8, {'B': 8, 'W': 16, 'D': 32, 'L': 64}[width]
37
+ warning = D.check_address_type(address, type_name)
38
+ if warning: raise UnsupportedFormat(warning)
39
+ return area, start, start + bits
40
+
41
+
42
+ def check_addresses(text):
43
+ spans = []
44
+ for variable in parse_declarations(text).variables:
45
+ # Runtime system memory uses segment.offset, unlike I/O byte.bit syntax.
46
+ # Preserve those existing bindings; never reinterpret their offsets as bits.
47
+ if variable.address and re.fullmatch(r'%M(?:[BWDL]\d+\.\d+|X\d+\.\d+\.[0-7])', variable.address, re.I):
48
+ continue
49
+ span = _address_span(variable.address, variable.type_name)
50
+ if span is None: continue
51
+ for other_name, other in spans:
52
+ if span[0] == other[0] and span[1] < other[2] and other[1] < span[2]:
53
+ raise UnsupportedFormat(f'Direct addresses overlap: {variable.name} and {other_name}')
54
+ spans.append((variable.name, span))
55
+
56
+
57
+ def _plan(source, vb, before, updated, vgr, raw, notes, preexisting=()):
58
+ from .writer import WritePlan
59
+ tolerated = set(preexisting)
60
+ introduced = [finding for finding in G.pair_findings(updated, raw) if finding not in tolerated]
61
+ if introduced:
62
+ raise UnsupportedFormat(introduced[0])
63
+ if tolerated:
64
+ notes = list(notes) + [
65
+ 'Pre-existing declaration/grid disagreement left untouched (not introduced by this edit): '
66
+ + '; '.join(sorted(tolerated))]
67
+ check_addresses(updated)
68
+ return WritePlan(target=source, stream=vb, before=before, after=updated.encode('latin1'),
69
+ extra_streams={vgr: raw}, notes=notes)
70
+
71
+
72
+ def _other_pou_names(project_root):
73
+ """Every POU name in the project that owns a worksheet, for an automatic donor search."""
74
+ base = Path(project_root) / 'POE'
75
+ if not base.is_dir():
76
+ return []
77
+ return sorted(child.name for child in base.iterdir()
78
+ if child.is_dir() and (child / 'src.st1').is_file())
79
+
80
+
81
+ def _compatible(records, usages, type_name, address, donor):
82
+ return [r for r in records if r['usage'] in usages
83
+ and r['type'].casefold() == type_name.casefold()
84
+ and bool(r['address']) == bool(address)
85
+ and (donor is None or r['name'].casefold() == donor.casefold())]
86
+
87
+
88
+ def add(project_root, pou_name, name, type_name, section='VAR', address=None,
89
+ initial_value=None, description=None, donor=None, donor_pou=None):
90
+ source, vb, before, text, vgr, raw, preexisting = _streams(project_root, pou_name)
91
+ if address: _address_span(address, type_name)
92
+ section = 'VAR_GLOBAL' if pou_name is None else section.upper()
93
+ usages = {'VAR': (1, 0x40001), 'VAR_EXTERNAL': (5,), 'VAR_GLOBAL': (6, 22)}.get(section)
94
+ if usages is None: raise UnsupportedFormat(f'No proven worksheet usage for {section}')
95
+ donor_text, donor_raw = text, raw
96
+ borrowed = donor_pou is not None
97
+ if donor_pou is not None:
98
+ if pou_name is None: raise UnsupportedFormat('Global donors must come from the same resource')
99
+ _, _, _, donor_text, _, donor_raw, _ = _streams(project_root, donor_pou)
100
+ donors = _compatible(G.parse(donor_raw), usages, type_name, address, donor)
101
+ if not donors and donor_pou is None and donor is None:
102
+ # A POU that declares none of this type - an empty clone, or one whose only locals are
103
+ # function-block instances - has no donor of its own. Borrow one from another POU in the
104
+ # same project rather than refusing: it is the same project, so its worksheets encode the
105
+ # same layout. A donor of a DIFFERENT type is deliberately not accepted here; that was
106
+ # tried and produced a record the project could no longer parse.
107
+ for candidate in _other_pou_names(project_root):
108
+ if candidate.casefold() == (pou_name or '').casefold():
109
+ continue
110
+ try:
111
+ _, _, _, candidate_text, _, candidate_raw, _ = _streams(project_root, candidate)
112
+ except UnsupportedFormat:
113
+ continue
114
+ matches = _compatible(G.parse(candidate_raw), usages, type_name, address, None)
115
+ if matches:
116
+ donor_text, donor_raw, donors, borrowed = candidate_text, candidate_raw, matches, True
117
+ break
118
+ if not donors: raise UnsupportedFormat('No native donor matches the requested type, usage and address layout')
119
+ if donor is None and len({(r['fields'][1:4], r['tail']) for r in donors}) != 1:
120
+ raise UnsupportedFormat('Native donor is ambiguous; specify donor by variable name')
121
+ chosen = donors[0]
122
+ table = parse_declarations(donor_text)
123
+ declaration = next(v for v in table.variables if v.name.casefold() == chosen['name'].casefold())
124
+ # Inherit the donor initializer explicitly in both stores, never silently.
125
+ if initial_value is None:
126
+ initial_value = chosen['initial_value'] or None
127
+ new_grid, handle = G.add(raw, name, type_name, usage=chosen['usage'], address=address or '',
128
+ initial_value=initial_value, donor_name=chosen['name'],
129
+ donor_grid=donor_raw if borrowed else None, group=chosen['group'])
130
+ updated, notes = D.add_variable(text, name, type_name, section=section, address=address,
131
+ initial_value=initial_value, description=description,
132
+ group=declaration.group if not borrowed else None)
133
+ if borrowed and donor_pou is None:
134
+ notes = list(notes) + [
135
+ f'Borrowed the native record layout of {chosen["name"]} ({type_name}) from another POU '
136
+ 'in this project, because the target POU declares no donor of that type']
137
+ plan = _plan(source, vb, before, updated, vgr, new_grid,
138
+ notes + [f'Cloned native donor {chosen["name"]}; initializer={initial_value!r}; handle={handle}; native trailer preserved'],
139
+ preexisting=preexisting)
140
+ from .descriptions import attach
141
+ if description is not None:
142
+ return attach(plan, vgr, name, description)
143
+ # A new variable must not inherit another variable's description ID.
144
+ import struct
145
+ new_raw = bytearray(plan.extra_streams[vgr])
146
+ item = G.find(G.parse(bytes(new_raw)), name)
147
+ identifier = struct.unpack_from('<I', new_raw, item['end'] - 8)[0]
148
+ if identifier not in (0, 0xffffffff):
149
+ struct.pack_into('<I', new_raw, item['end'] - 8, 0)
150
+ plan.extra_streams[vgr] = bytes(new_raw)
151
+ return plan
152
+
153
+
154
+ def edit(project_root, pou_name, name, new_name=None, type_name=None, address=None,
155
+ initial_value=None, description=None, clear_address=False, force=False, donor=None):
156
+ from .writer import _reference_check
157
+ source, vb, before, text, vgr, raw, preexisting = _streams(project_root, pou_name)
158
+ if address:
159
+ current = G.find(G.parse(raw), name)
160
+ _address_span(address, type_name or current['type'])
161
+ if new_name and new_name.casefold() != name.casefold():
162
+ references = _reference_check(project_root, name, None, include_named_pou=pou_name)
163
+ if references:
164
+ raise UnsupportedFormat(f'Rename would leave references in {references}; remove those references before renaming')
165
+ updated, notes = D.edit_variable(text, name, new_name=new_name, type_name=type_name,
166
+ address=address, initial_value=initial_value, description=description, clear_address=clear_address)
167
+ new_grid, info = G.edit(raw, name, new_name=new_name, type_name=type_name,
168
+ address='' if clear_address else address, initial_value=initial_value, donor_name=donor)
169
+ plan = _plan(source, vb, before, updated, vgr, new_grid, notes + [str(info)], preexisting=preexisting)
170
+ if description is not None:
171
+ from .descriptions import attach
172
+ return attach(plan, vgr, new_name or name, description)
173
+ return plan
174
+
175
+
176
+ def delete(project_root, pou_name, name, force=False):
177
+ from .writer import _reference_check
178
+ source, vb, before, text, vgr, raw, preexisting = _streams(project_root, pou_name)
179
+ references = _reference_check(project_root, name, None, include_named_pou=pou_name)
180
+ if references: raise UnsupportedFormat(f'Cannot delete {name}: referenced in {references}')
181
+ updated, notes = D.delete_variable(text, name)
182
+ new_grid, info = G.delete(raw, name)
183
+ return _plan(source, vb, before, updated, vgr, new_grid, notes + [str(info)], preexisting=preexisting)