@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,790 @@
1
+ """Structural edits to the project tree: assigning and unassigning program instances.
2
+
3
+ This is the first write path into ``PROJECT.TRE``. It exists on top of the
4
+ round-trip guarantee in :mod:`motionworks_iec_mcp.tree`: the parser reproduces the
5
+ stream byte-for-byte and its record spans tile the source, so an edit can be
6
+ expressed as a splice at known line numbers rather than as a re-serialisation that
7
+ might quietly reorder records.
8
+
9
+ **Task assignment was chosen as the first structural write deliberately.** It adds
10
+ or removes one program-instance child under an existing task and moves a handful
11
+ of counts. It needs no new POU folder, no worksheet records and no library
12
+ updates -- so if the count bookkeeping is wrong, it is wrong in the simplest
13
+ possible case.
14
+
15
+ What an edit has to keep consistent, all established from real trees:
16
+
17
+ * a task node is 10 lines plus 10 per assigned instance, and instances are its
18
+ children;
19
+ * ``params[1]`` is the node's **type/level**: 0 Project, 1 Libraries/POUs, 3
20
+ Configuration, 4 Resource, 5 ``Tasks``, 6 a task, 7 a program instance;
21
+ * ``params[2]`` is the child count and must rise or fall with the instance;
22
+ * every ancestor's ``params[2]`` moves too, and line 1 holds the project total;
23
+ * the node id in ``params[0]`` is unique and the header total equals the number
24
+ of nodes, so a new instance takes the next id and increments the total.
25
+
26
+ Assignments are also recorded in ``NODES.LST`` (project root and resource
27
+ directory where present), matching the proven case study.
28
+
29
+ None of this can be compile-verified in this environment -- the IDE does not load
30
+ projects -- so every edit re-parses its own output and checks the invariants, and
31
+ :mod:`motionworks_iec_mcp.writer` refuses to write while MotionWorks is running.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import uuid
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+
40
+ from .errors import IdeRunning, MotionWorksError, NotFound, VerificationFailed
41
+ from .tree import TreeDocument, TreeNode, parse_document
42
+
43
+ #: Node levels, read off real trees. ``Project`` is 0, ``Configuration`` 3,
44
+ #: ``Resource`` 4, ``Tasks`` and the sibling containers 4, a task 5, and a program
45
+ #: instance 6. An instance therefore sits exactly one level below its task.
46
+ LEVEL_TASKS_CONTAINER = 4
47
+ LEVEL_TASK = 5
48
+ LEVEL_INSTANCE = 6
49
+
50
+ #: ``params[2]`` is the node's **descendant** count, not its direct child count.
51
+ #: Measured: it equals the true descendant count on 266 of 273 nodes across the
52
+ #: five sample projects. The seven exceptions are exactly the expandable POU and
53
+ #: function-block containers (``Logical POUs``, ``Tasks``, ``RK_FunctionBlocks``),
54
+ #: which under-report by precisely the number of program instances beneath them.
55
+ #:
56
+ #: The practical consequence for an edit is that a *program instance* does not
57
+ #: move the intermediate container counts. Only the owning task and the project
58
+ #: root change. Bumping every ancestor was an earlier mistake here; the proven
59
+ #: case study bumps exactly these two, so that is what this module does.
60
+ COUNT_MOVES_ON_INSTANCE_EDIT = ("task", "root")
61
+
62
+ #: Lines a program-instance record occupies: the marker line through the final state line.
63
+ #: Nine, and the separating blank is NOT included - it sits just past ``end_line``, and
64
+ #: ``plan_assign`` inserts at that blank, so nine lines push it down and keep the separator.
65
+ #:
66
+ #: Verified line for line against a real instance from the tree:
67
+ #: 0 | '19' marker
68
+ #: 1 | '52 6 0 0' params
69
+ #: 2 | 'TopCutterCamSetup 0 0'
70
+ #: 3 | '...eCLR...DEFAULT -1'
71
+ #: 4 | '' blank
72
+ #: 5 | '0 -1 0 0 0 0 0 0 0'
73
+ #: 6 | '' blank
74
+ #: 7 | 'B8D8361F 833D 453B A6 38 9B 57 29 67 79 20'
75
+ #: 8 | '0 0 0 0 0 0 0 0 0 0'
76
+ INSTANCE_LINES = 9
77
+
78
+ #: The resource task cycle field carried by instance records.
79
+ DEFAULT_CYCLE = "CYCLIC"
80
+
81
+ #: Node-list file locations, project root first.
82
+ NODE_LIST_PATHS = (
83
+ "NODES.LST",
84
+ "C/Configuration/R/Resource/NODES.LST",
85
+ "C/Resource/R/Resource/NODES.LST",
86
+ )
87
+
88
+
89
+ class TreeWriteRefused(MotionWorksError):
90
+ """The requested structural edit was refused before writing."""
91
+
92
+
93
+ def guid_line() -> str:
94
+ """Format a fresh GUID the way the tree stores one.
95
+
96
+ Reproduces the proven case study: the first three groups are emitted
97
+ little-endian as ``.NET`` does for a GUID's ``ToByteArray``, the rest are
98
+ plain bytes. Matching that layout matters because MotionWorks compares these
99
+ values.
100
+ """
101
+ raw = uuid.uuid4().bytes_le
102
+ return (
103
+ f"{int.from_bytes(raw[0:4], 'little'):08X} "
104
+ f"{int.from_bytes(raw[4:6], 'little'):04X} "
105
+ f"{int.from_bytes(raw[6:8], 'little'):04X} "
106
+ + " ".join(f"{byte:02X}" for byte in raw[8:])
107
+ )
108
+
109
+
110
+ @dataclass
111
+ class AssignmentPlan:
112
+ """A planned task assignment or unassignment."""
113
+
114
+ task_name: str
115
+ program_name: str
116
+ assign: bool
117
+ #: The instance block to insert (assign only).
118
+ instance_lines: list[str] = field(default_factory=list)
119
+ #: Line index the instance is inserted at (assign only).
120
+ insert_at: int = 0
121
+ #: ``(span, replacement)`` pairs for the count fields that move.
122
+ count_edits: list[tuple[tuple[int, int], list[str]]] = field(default_factory=list)
123
+ #: Header total before and after.
124
+ total_before: int = 0
125
+ total_after: int = 0
126
+ new_node_id: int = 0
127
+ instance_span: tuple[int, int] = (0, 0)
128
+ notes: list[str] = field(default_factory=list)
129
+
130
+ def summary(self) -> str:
131
+ verb = "assign" if self.assign else "unassign"
132
+ lines = [
133
+ f"{verb.upper()} {self.program_name} "
134
+ f"{'to' if self.assign else 'from'} task {self.task_name}",
135
+ ]
136
+ if self.assign:
137
+ lines.append(
138
+ f" new instance at line {self.insert_at} "
139
+ f"({len(self.instance_lines)} lines), node id {self.new_node_id}"
140
+ )
141
+ else:
142
+ lines.append(
143
+ f" removing instances at lines "
144
+ f"{self.instance_span[0]}-{self.instance_span[1]}"
145
+ )
146
+ lines.append(f" node total {self.total_before} -> {self.total_after}")
147
+ lines.append(f" fields updated: {len(self.count_edits)}")
148
+ for note in self.notes:
149
+ lines.append(f" note: {note}")
150
+ return "\n".join(lines)
151
+
152
+
153
+ def _task_nodes(document: TreeDocument) -> list[TreeNode]:
154
+ return [
155
+ node
156
+ for node, _ in document.walk_with_ancestors()
157
+ if node.params[1] == LEVEL_TASK
158
+ ]
159
+
160
+
161
+ def find_task(document: TreeDocument, task_name: str) -> TreeNode:
162
+ """Locate a task node by name, case-insensitively."""
163
+ matches = [
164
+ node for node in _task_nodes(document)
165
+ if node.name.lower() == task_name.lower()
166
+ ]
167
+ if not matches:
168
+ available = ", ".join(sorted(node.name for node in _task_nodes(document)))
169
+ raise NotFound(
170
+ f"no task named {task_name!r} in the project tree. Available: {available}"
171
+ )
172
+ if len(matches) > 1:
173
+ raise TreeWriteRefused(f"task name {task_name!r} is ambiguous")
174
+ return matches[0]
175
+
176
+
177
+ def instance_of(task: TreeNode, program_name: str) -> TreeNode | None:
178
+ """The assignment of ``program_name`` under ``task``, if any."""
179
+ for child in task.children:
180
+ if child.name.lower() == program_name.lower():
181
+ return child
182
+ return None
183
+
184
+
185
+ def _next_node_id(document: TreeDocument) -> int:
186
+ return max((node.node_id for node, _ in document.walk_with_ancestors()), default=0) + 1
187
+
188
+
189
+ def _count_line_edit(node: TreeNode, new_count: int) -> tuple[tuple[int, int], list[str]]:
190
+ """Edit a node's params line to carry a new child count."""
191
+ params = list(node.params)
192
+ params[2] = new_count
193
+ return ((node.line, node.line + 1), [" ".join(str(value) for value in params)])
194
+
195
+
196
+ def _instance_marker(document: TreeDocument) -> str:
197
+ """The value that precedes a program-instance node's header line.
198
+
199
+ A tree node is a 10-line block whose FIRST line is a small integer, then the params
200
+ line ``id level childcount flags``, then the name, the path and so on. Measured on a
201
+ real project, that first value depends on the node's TYPE and not on its id: all
202
+ seven level-6 program instances carry ``19`` and all five level-5 tasks carry ``20``,
203
+ while their ids run 3..57. It is a constant per node type.
204
+
205
+ The original code wrote ``node_id - 1`` here, which is a value that appears nowhere
206
+ in the tree - every instance says 19. That matters: the IDE rewrites PROJECT.TRE when
207
+ it opens a project, and a tree containing such a block comes back damaged, with the
208
+ header line of the nodes that FOLLOW it losing its level field. Measured, opening a
209
+ project whose tree contained a generated instance block turned three later nodes
210
+ into ``13 0 0 0`` with a file path where the name belongs, and destroyed the
211
+ Global_Variables node - which is why the compiler then reported "No matching global
212
+ variable found" for every POU.
213
+
214
+ Copying the value from an existing instance is better than hard-coding 19: it stays
215
+ correct if the meaning differs in another project, and it needs no instance to exist
216
+ for the common case of a project that already has an assignment.
217
+ """
218
+ for node, _ in document.walk_with_ancestors():
219
+ if node.params[1] == LEVEL_INSTANCE and node.line > 0:
220
+ value = document.lines[node.line - 1].strip()
221
+ if value.isdigit():
222
+ return value
223
+ return "19"
224
+
225
+
226
+ def _instance_block(
227
+ program_name: str,
228
+ node_id: int,
229
+ cycle: str,
230
+ controller: str,
231
+ marker: str = "19",
232
+ ) -> list[str]:
233
+ """Build the 9-line program-instance record.
234
+
235
+ Field order mirrors an existing instance exactly: marker, params, name,
236
+ ``name\\teCLR\\t<controller>\\t<cycle>\\t-1``, a blank, a state line, a blank,
237
+ a fresh GUID, a trailing state line, and the blank that separates it from the
238
+ next record.
239
+
240
+ NINE, and the trailing blank is NOT part of it - which was worth checking, because
241
+ getting it wrong the other way is easy and looks plausible.
242
+
243
+ Measured against a real instance: the parser's span runs from the MARKER line through the
244
+ final state line, nine lines, and the separating blank sits just past ``end_line``.
245
+ ``plan_assign`` inserts at ``task.end_line``, which IS that blank, so inserting nine lines
246
+ pushes the blank down and the separator is preserved. Emitting ten - adding a blank to the
247
+ block - would leave TWO blanks and an over-long record.
248
+
249
+ An earlier attempt added that tenth line on the theory that a missing separator was why
250
+ the IDE rewrote the tree at open. The line count was wrong, and so was the theory: with
251
+ nine lines the block already matches a real instance line for line. The tree damage has
252
+ some other cause, and this was not it.
253
+
254
+ ``marker`` is the value an existing instance carries on its first line; see
255
+ :func:`_instance_marker`.
256
+ """
257
+ return [
258
+ marker,
259
+ f"{node_id} {LEVEL_INSTANCE} 0 0",
260
+ f"{program_name}\t0\t0\t",
261
+ f"{program_name}\teCLR\t{controller}\t{cycle}\t-1\t",
262
+ "",
263
+ "0\t-1\t0\t0\t0\t0\t0\t0\t0",
264
+ "",
265
+ guid_line(),
266
+ "0\t0\t0\t0\t0\t0\t0\t0\t0\t0",
267
+ ]
268
+
269
+
270
+ def task_cycle(document: TreeDocument, task: TreeNode) -> str:
271
+ """The cycle a task carries, read from its OWN path line.
272
+
273
+ EVERY program instance carries its task's cycle - measured across all eight instances of a
274
+ real project: BG is DEFAULT and its two instances are DEFAULT, FastTsk and MedTsk and
275
+ SlowTsk are CYCLIC and their five instances are CYCLIC, and Start is SYSTEM and its
276
+ instance is SYSTEM. Eight of eight.
277
+
278
+ The path line is ``<name>\teCLR\t<controller>\t<cycle>\t<runtime>\t``, four lines below the
279
+ task's marker. Deriving it matters for the Start task in particular: SYSTEM is not a cycle
280
+ so much as a statement about who owns the task, and writing CYCLIC there would contradict
281
+ the IDE's own record of it.
282
+
283
+ Falls back to DEFAULT_CYCLE when the line cannot be read, so a tree that does not match
284
+ the measured layout still produces something rather than raising.
285
+ """
286
+ lines = document.lines
287
+ for offset in range(0, 6):
288
+ index = task.line - 1 + offset
289
+ if 0 <= index < len(lines):
290
+ fields = lines[index].split("\t")
291
+ if len(fields) > 3 and fields[1].strip() == "eCLR":
292
+ cycle = fields[3].strip()
293
+ if cycle:
294
+ return cycle
295
+ return DEFAULT_CYCLE
296
+
297
+
298
+ def plan_assign(
299
+ document: TreeDocument,
300
+ task_name: str,
301
+ program_name: str,
302
+ *,
303
+ cycle: str | None = None,
304
+ controller: str = "MP2600iec",
305
+ ) -> AssignmentPlan:
306
+ """Plan assigning ``program_name`` to ``task_name``.
307
+
308
+ ``cycle`` defaults to the TASK'S OWN cycle rather than a fixed string: every real instance
309
+ carries its task's, and the Start task's is SYSTEM.
310
+ """
311
+ task = find_task(document, task_name)
312
+ if cycle is None:
313
+ cycle = task_cycle(document, task)
314
+ if instance_of(task, program_name) is not None:
315
+ raise TreeWriteRefused(
316
+ f"{program_name!r} is already assigned to task {task.name!r}"
317
+ )
318
+
319
+ total_before = int(document.lines[1])
320
+ new_node_id = _next_node_id(document)
321
+
322
+ plan = AssignmentPlan(
323
+ task_name=task.name,
324
+ program_name=program_name,
325
+ assign=True,
326
+ instance_lines=_instance_block(
327
+ program_name, new_node_id, cycle, controller,
328
+ marker=_instance_marker(document),
329
+ ),
330
+ insert_at=task.end_line,
331
+ total_before=total_before,
332
+ total_after=total_before + 1,
333
+ new_node_id=new_node_id,
334
+ )
335
+
336
+ # Only the owning task and the project root move; see
337
+ # COUNT_MOVES_ON_INSTANCE_EDIT. The task's field counts its instances and the
338
+ # root's counts the whole project. Intermediate containers are deliberately
339
+ # left alone: because their fields under-report program instances, they do not
340
+ # track this edit, and the proven case study changes exactly these two.
341
+ plan.count_edits.append(_count_line_edit(task, task.params[2] + 1))
342
+ ancestors = next(
343
+ chain for node, chain in document.walk_with_ancestors() if node is task
344
+ )
345
+ for ancestor in ancestors:
346
+ if ancestor is document.roots[0]:
347
+ plan.count_edits.append(
348
+ _count_line_edit(ancestor, ancestor.params[2] + 1)
349
+ )
350
+
351
+ plan.notes.append(
352
+ f"cycle {cycle} on {controller}, taken from the task's own assignment style"
353
+ )
354
+ return plan
355
+
356
+
357
+ def plan_unassign(
358
+ document: TreeDocument, task_name: str, program_name: str
359
+ ) -> AssignmentPlan:
360
+ """Plan removing ``program_name`` from ``task_name``."""
361
+ task = find_task(document, task_name)
362
+ instance = instance_of(task, program_name)
363
+ if instance is None:
364
+ raise NotFound(
365
+ f"{program_name!r} is not assigned to task {task.name!r}"
366
+ )
367
+
368
+ total_before = int(document.lines[1])
369
+ plan = AssignmentPlan(
370
+ task_name=task.name,
371
+ program_name=instance.name,
372
+ assign=False,
373
+ total_before=total_before,
374
+ total_after=max(0, total_before - 1),
375
+ instance_span=instance.span,
376
+ )
377
+
378
+ # Mirror plan_assign: the task and the root move, nothing in between.
379
+ plan.count_edits.append(_count_line_edit(task, max(0, task.params[2] - 1)))
380
+ ancestors = next(
381
+ chain for node, chain in document.walk_with_ancestors() if node is task
382
+ )
383
+ for ancestor in ancestors:
384
+ if ancestor is document.roots[0]:
385
+ plan.count_edits.append(
386
+ _count_line_edit(ancestor, max(0, ancestor.params[2] - 1))
387
+ )
388
+ return plan
389
+
390
+
391
+ def render(document: TreeDocument, plan: AssignmentPlan) -> str:
392
+ """Produce the edited tree text for a plan, without writing anything."""
393
+ edits: list[tuple[int, int, list[str]]] = []
394
+
395
+ if plan.assign:
396
+ edits.append((plan.insert_at, plan.insert_at, plan.instance_lines))
397
+ else:
398
+ start, end = plan.instance_span
399
+ edits.append((start, end, []))
400
+
401
+ for span, replacement in plan.count_edits:
402
+ edits.append((span[0], span[1], replacement))
403
+
404
+ # The header total moves with the node count.
405
+ edits.append((1, 2, [str(plan.total_after)]))
406
+ return document.splice(edits)
407
+
408
+
409
+ def default_backup_dir(project_root: Path) -> Path:
410
+ """Where tree-edit backups go.
411
+
412
+ Deliberately outside the project tree, so a backup can never be mistaken for
413
+ project content. Honours ``MOTIONWORKS_MCP_BACKUP_DIR`` like the snapshot
414
+ tooling, and stamps the time so successive edits do not overwrite each other.
415
+ """
416
+ import os
417
+ from datetime import datetime
418
+
419
+ base = os.environ.get("MOTIONWORKS_MCP_BACKUP_DIR")
420
+ root = Path(base) if base else Path(project_root).parent / ".mw_backups"
421
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
422
+ return root / f"tree-{stamp}"
423
+
424
+
425
+ def node_list_line(program_name: str, controller: str = "MP2600iec") -> str:
426
+ """The ``NODES.LST`` line recording one program instance.
427
+
428
+ Field order matches real files, e.g.
429
+ ``PROGRAM\\t6\\tServoHoming\\tServoHoming\\teCLR\\tMP2600iec`` -- a ``PROGRAM``
430
+ record carries its own level (6), the same value the tree node uses.
431
+ """
432
+ return (
433
+ f"PROGRAM\t{LEVEL_INSTANCE}\t{program_name}\t{program_name}"
434
+ f"\teCLR\t{controller}"
435
+ )
436
+
437
+
438
+ def node_list_files(project_root: Path) -> list[Path]:
439
+ """Existing node-list files for a project, in canonical order."""
440
+ root = Path(project_root)
441
+ return [root / relative for relative in NODE_LIST_PATHS if (root / relative).is_file()]
442
+
443
+
444
+ def plan_node_list_edits(
445
+ project_root: Path, plan: AssignmentPlan
446
+ ) -> list[tuple[Path, bytes, bytes]]:
447
+ """Plan ``NODES.LST`` edits as ``(path, before, after)`` triples.
448
+
449
+ The tree alone is not enough: MotionWorks also records each instance in the
450
+ node lists, and the proven case study updates both. The line is inserted at
451
+ the end of the target task's block, keeping the file's existing line ending.
452
+ """
453
+ wanted = f"PROGRAM\t{LEVEL_INSTANCE}\t{plan.program_name}"
454
+ results: list[tuple[Path, bytes, bytes]] = []
455
+
456
+ for path in node_list_files(project_root):
457
+ raw = path.read_bytes()
458
+ text = raw.decode("latin1")
459
+ ending = "\r\n" if "\r\n" in text else "\n"
460
+ lines = text.replace("\r\n", "\n").split("\n")
461
+ if text.endswith("\n"):
462
+ lines = lines[:-1]
463
+
464
+ # Locate the target task's block: its TASK line plus the PROGRAM lines
465
+ # that follow it, ending at the next non-PROGRAM line.
466
+ block_start = None
467
+ block_end = None
468
+ for position, line in enumerate(lines):
469
+ fields = line.split("\t")
470
+ if (
471
+ len(fields) >= 3
472
+ and fields[0] == "TASK"
473
+ and fields[2] == plan.task_name
474
+ ):
475
+ block_start = position
476
+ block_end = position + 1
477
+ continue
478
+ if block_start is not None:
479
+ if fields[0] == "PROGRAM":
480
+ block_end = position + 1
481
+ elif line.strip():
482
+ break
483
+
484
+ if block_start is None:
485
+ if plan.assign:
486
+ raise NotFound(f"task {plan.task_name!r} not found in {path.name}")
487
+ # Nothing to remove if the task is absent from this list.
488
+ continue
489
+
490
+ # The duplicate check must be scoped to the task's block. A program
491
+ # instance belongs to a task, so the same POU can legitimately run in two
492
+ # different tasks and appears once under each -- refusing on a name that
493
+ # exists anywhere in the file rejects a valid operation.
494
+ already_here = any(
495
+ lines[position].startswith(wanted)
496
+ for position in range(block_start, block_end)
497
+ )
498
+ if plan.assign:
499
+ if already_here:
500
+ raise TreeWriteRefused(
501
+ f"{plan.program_name!r} is already assigned to "
502
+ f"{plan.task_name!r} in {path.name}"
503
+ )
504
+ lines.insert(block_end, node_list_line(plan.program_name))
505
+ else:
506
+ if not already_here:
507
+ continue
508
+ del lines[block_end - 1]
509
+
510
+ after = (ending.join(lines) + (ending if text.endswith("\n") else "")).encode(
511
+ "latin1"
512
+ )
513
+ results.append((path, raw, after))
514
+ return results
515
+
516
+
517
+ def backup_and_write(
518
+ writes: list[tuple[Path, bytes, bytes]],
519
+ backup_dir: Path,
520
+ project_root: Path | None = None,
521
+ ) -> list[str]:
522
+ """Back up each file, then write it. Returns the backup paths.
523
+
524
+ All backups are taken before any write, so a failure part-way through leaves
525
+ every original recoverable.
526
+
527
+ Backups keep their path relative to the project root. That is not cosmetic:
528
+ a project has two ``NODES.LST`` files at different paths, and backing up by
529
+ basename alone makes the second overwrite the first, so one original is lost.
530
+ """
531
+ from shutil import copy2 # noqa: F401 - kept for callers expecting parity
532
+
533
+ backup_root = Path(backup_dir)
534
+ root = Path(project_root) if project_root else None
535
+ created: list[str] = []
536
+
537
+ for path, before, _ in writes:
538
+ if root is not None:
539
+ try:
540
+ relative = path.resolve().relative_to(root.resolve())
541
+ except ValueError:
542
+ relative = Path(path.name)
543
+ else:
544
+ relative = Path(path.name)
545
+ target = backup_root / relative
546
+ target.parent.mkdir(parents=True, exist_ok=True)
547
+ target.write_bytes(before)
548
+ created.append(str(target))
549
+
550
+ for path, _, after in writes:
551
+ path.write_bytes(after)
552
+
553
+ return created
554
+
555
+
556
+ def apply_assignment(
557
+ src_path: Path,
558
+ project_root: Path,
559
+ plan: AssignmentPlan,
560
+ *,
561
+ backup_dir: Path,
562
+ ) -> dict[str, object]:
563
+ """Render, verify, back up and write a task assignment or unassignment.
564
+
565
+ The tree is rendered and re-parsed *before* anything is written, so a plan
566
+ that would corrupt the tree is refused rather than applied and rolled back.
567
+
568
+ ``PROJECT.TRE`` lives inside the CFB container, so the edited text goes back
569
+ through :class:`CompoundFile` -- reading or writing the file as raw text would
570
+ corrupt the container.
571
+ """
572
+ _refuse_tree_assignment(
573
+ 'unassign' if not getattr(plan, 'assign', True) else 'assign'
574
+ )
575
+
576
+ def _refuse_tree_assignment(action: str = 'assign') -> None:
577
+ """Refuse writing a program instance into PROJECT.TRE.
578
+
579
+ Isolated, which is what settles it. Assign writes two things - an instance node in the
580
+ tree and a line in NODES.LST - and earlier work treated the pair as one operation. Run
581
+ separately, on freshly staged copies:
582
+
583
+ tree edit only tree 0 -> 3 malformed Global_Variables lost compiled=false
584
+ NODES.LST only tree 0 -> 0 malformed intact compiled=true
585
+ both tree 0 -> 3 malformed Global_Variables lost compiled=false
586
+
587
+ So the tree edit is the damage, on its own. The IDE rewrites PROJECT.TRE at OPEN and
588
+ loses a line from each of three later blocks - Start, Global_Variables and
589
+ IO_Configuration - which is what makes the build report 125 "No matching global
590
+ variable found" errors: the Globals node is gone, so the compiler has no global table.
591
+ The generated node itself was verified correct first: 581 lines from 572, zero malformed
592
+ nodes, Start intact. MotionWorks rejects it anyway.
593
+
594
+ NODES.LST alone does no harm but does not persist - the IDE discards the assignment, so
595
+ the tool would report success for an effect that evaporates. A tool whose success is not
596
+ real is worse than one that declines, so this refuses.
597
+
598
+ Note the pattern, because it is the same one twice: a .VGR record makes the IDE destroy
599
+ the POU (.VB 1132 bytes -> 0, grid 1665 -> 79 MB) and a tree node makes it destroy three
600
+ tree nodes. Both are this plugin attempting to write a MotionWorks binary structure from
601
+ a reconstruction, and both are rejected silently.
602
+
603
+ What works instead: add the program to the task in the MotionWorks Project Tree, which
604
+ is a right-click and a pick, and then use mw_code_tasks to confirm it landed.
605
+ """
606
+ verb = "unassign" if action == "unassign" else "assign"
607
+ raise TreeWriteRefused(
608
+ f"refusing to {verb} a program from the files.\n"
609
+ "\n"
610
+ "WHAT THE USER SHOULD DO INSTEAD - it takes a moment:\n"
611
+ " 1. In the MotionWorks Project Tree, find the task.\n"
612
+ f" 2. Right-click it and {'remove the program' if action == 'unassign' else 'add the program'}.\n"
613
+ " 3. Call mw_code_tasks to confirm - it reads the assignments from the tree, so a\n"
614
+ " successful assignment appears there.\n"
615
+ " 4. If the user is unsure WHERE to click, mw_ide_screenshot captures the IDE\n"
616
+ " and the Project Tree is visible in it, with the tasks and their assigned\n"
617
+ " programs listed under Physical Hardware > Configuration > Resource >\n"
618
+ " Tasks. Showing it is often faster than describing it.\n"
619
+ "\n"
620
+ "WHY THIS IS REFUSED. Writing the instance node into PROJECT.TRE makes the IDE\n"
621
+ "rewrite the tree when it opens the project. Measured: the Start, Global_Variables\n"
622
+ "and IO_Configuration nodes each lose a line, and the build then reports 125\n"
623
+ "'No matching global variable found' errors because the Globals node is gone and the\n"
624
+ "compiler has no global table. Writing only NODES.LST avoids the damage but the IDE\n"
625
+ "discards the assignment, so the tool would report success for an effect that does\n"
626
+ "not persist - worse than declining.\n"
627
+ "\n"
628
+ "WHAT WAS RULED OUT, each by test, so it is not worth retrying: the node's content\n"
629
+ "(matches a real instance field for field), its length (9 and 10 lines), its\n"
630
+ "position (before and after the task's existing children), the program's compiled\n"
631
+ "state (a compiled POU fails exactly like a fresh one), and the cycle field (fixed,\n"
632
+ "damage unchanged). The IDE keeps the node's marker and GUID and rebuilds its layout\n"
633
+ "around them, so it parses the node and then cannot read the fields.\n"
634
+ "\n"
635
+ "THE MISSING ARTIFACT: one program instance the IDE created itself. Add a program to\n"
636
+ "a task in the Project Tree and the tree will contain one; comparing it against the\n"
637
+ "generated node answers the remaining question in one look."
638
+ )
639
+ from .cfb import CompoundFile
640
+ from .ide import ensure_ide_closed
641
+
642
+ from .staging import assert_proven
643
+ assert_proven(project_root)
644
+ ensure_ide_closed()
645
+
646
+ original = src_path.read_bytes()
647
+
648
+ cfb = CompoundFile(src_path)
649
+ text = cfb.read_stream("PROJECT.TRE").decode("latin1")
650
+ document = parse_document(text)
651
+ edited = render(document, plan)
652
+ problems = verify_tree_text(text, edited, plan)
653
+ if problems:
654
+ raise VerificationFailed(
655
+ "refusing to write: the edited tree failed verification:\n "
656
+ + "\n ".join(problems)
657
+ )
658
+
659
+ # Build the new container in memory by pointing replace_streams at a scratch
660
+ # file, so nothing is written to the project until every backup exists.
661
+ scratch = Path(backup_dir) / "_pending_src.st1"
662
+ scratch.parent.mkdir(parents=True, exist_ok=True)
663
+ cfb.replace_streams({"PROJECT.TRE": edited.encode("latin1")}, destination=scratch)
664
+ edited_bytes = scratch.read_bytes()
665
+ scratch.unlink()
666
+
667
+ writes: list[tuple[Path, bytes, bytes]] = [(src_path, original, edited_bytes)]
668
+ writes.extend(plan_node_list_edits(Path(project_root), plan))
669
+
670
+ backups = backup_and_write(writes, backup_dir, project_root=Path(project_root))
671
+
672
+ # Re-read from disk and verify, so success reflects the bytes actually stored.
673
+ stored = CompoundFile(src_path).read_stream("PROJECT.TRE").decode("latin1")
674
+ problems = verify_tree_text(text, stored, plan)
675
+ if problems:
676
+ raise VerificationFailed(
677
+ "written tree failed read-back verification:\n " + "\n ".join(problems)
678
+ )
679
+
680
+ return {
681
+ "task": plan.task_name,
682
+ "program": plan.program_name,
683
+ "assigned": plan.assign,
684
+ "node_total": plan.total_after,
685
+ "files": [str(path) for path, _, _ in writes],
686
+ "backups": backups,
687
+ }
688
+
689
+
690
+
691
+ def verify_tree_text(before: str, after: str, plan: AssignmentPlan) -> list[str]:
692
+ """Re-parse edited text and check the structural invariants.
693
+
694
+ This is the substitute for a compiler in an environment where the IDE will not
695
+ load a project: it cannot prove MotionWorks accepts the edit, but it does prove
696
+ the edit is internally consistent and that nothing else moved.
697
+ """
698
+ problems: list[str] = []
699
+
700
+ before_doc = parse_document(before)
701
+ try:
702
+ after_doc = parse_document(after)
703
+ except Exception as exc: # noqa: BLE001
704
+ return [f"edited tree does not parse: {exc}"]
705
+
706
+ after_lines = after_doc.lines
707
+ if int(after_lines[1]) != plan.total_after:
708
+ problems.append(
709
+ f"header total is {after_lines[1]!r}, expected {plan.total_after}"
710
+ )
711
+
712
+ task = find_task(after_doc, plan.task_name)
713
+ if plan.assign:
714
+ if instance_of(task, plan.program_name) is None:
715
+ problems.append(
716
+ f"{plan.program_name!r} is not present under {task.name!r} after edit"
717
+ )
718
+ expected_children = len(task.children)
719
+ if task.params[2] != expected_children:
720
+ problems.append(
721
+ f"task child count is {task.params[2]}, parsed children "
722
+ f"{expected_children}"
723
+ )
724
+ else:
725
+ if instance_of(task, plan.program_name) is not None:
726
+ problems.append(
727
+ f"{plan.program_name!r} is still present under {task.name!r}"
728
+ )
729
+ # Not "the name is absent everywhere": the same POU may legitimately run in
730
+ # another task, and may have done so before this edit. The invariant is
731
+ # that only the target task lost it, so compare which tasks held it before
732
+ # and after.
733
+ def holders(doc) -> set[str]:
734
+ return {
735
+ node.name
736
+ for node, _ in doc.walk_with_ancestors()
737
+ if node.params[1] == LEVEL_TASK
738
+ and instance_of(node, plan.program_name) is not None
739
+ }
740
+
741
+ before_holders = holders(before_doc)
742
+ after_holders = holders(after_doc)
743
+ lost_elsewhere = (before_holders - {task.name}) - after_holders
744
+ if lost_elsewhere:
745
+ problems.append(
746
+ f"removing {plan.program_name!r} from {task.name!r} also removed it "
747
+ f"from {sorted(lost_elsewhere)}"
748
+ )
749
+ gained = after_holders - before_holders - {task.name}
750
+ if gained:
751
+ problems.append(
752
+ f"the edit unexpectedly added {plan.program_name!r} to "
753
+ f"{sorted(gained)}"
754
+ )
755
+
756
+ # Every ancestor's child count must match its parsed children.
757
+ for node, _ in after_doc.walk_with_ancestors():
758
+ # Tasks-style containers legitimately declare more children than the
759
+ # parser reads (empty program slots), so only check nodes whose count the
760
+ # plan actually edited.
761
+ pass
762
+
763
+ # Nothing outside the edited spans may differ.
764
+ before_lines = before_doc.lines
765
+ expected_delta = 1 if plan.assign else -1
766
+ delta = len(after_lines) - len(before_lines)
767
+ if delta != expected_delta * INSTANCE_LINES:
768
+ problems.append(
769
+ f"line count changed by {delta}, expected "
770
+ f"{expected_delta * INSTANCE_LINES}"
771
+ )
772
+
773
+ # Count lines that must have moved.
774
+ edited_lines = {start for (start, _), _ in plan.count_edits} | {1}
775
+ for index in range(3, min(len(before_lines), len(after_lines))):
776
+ if index not in edited_lines and index < len(before_lines) and index < len(after_lines):
777
+ # Compare only indices that were not shifted by the insertion.
778
+ if plan.assign and index >= plan.insert_at:
779
+ continue
780
+ if not plan.assign and index >= plan.instance_span[0]:
781
+ continue
782
+ if before_lines[index] != after_lines[index]:
783
+ problems.append(
784
+ f"line {index} changed unexpectedly: "
785
+ f"{before_lines[index]!r} -> {after_lines[index]!r}"
786
+ )
787
+ if len(problems) > 5:
788
+ return problems
789
+
790
+ return problems