@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,1547 @@
1
+ """Plan POU creation: produce a reviewable patch, without writing.
2
+
3
+ Creating a POU adds four nodes to ``PROJECT.TRE`` -- the POU container plus its
4
+ ``T`` (comments), ``V`` (variables) and body worksheets. Their record shape is
5
+ fully known, but **not every field in them is derivable**, and this module says so
6
+ rather than inventing values.
7
+
8
+ Fields that can be derived with confidence:
9
+
10
+ * the record layout (line count and field order), read from a real POU;
11
+ * node ids, which run in one global increasing sequence;
12
+ * the four worksheet GUIDs, which are fresh per POU and appear both in the tree and
13
+ in ``NodeProperties.xml``;
14
+ * the name, path and level fields.
15
+
16
+ Fields that **cannot**, and are reported as unresolved:
17
+
18
+ * the worksheet state lines at the container and body, which carry opaque
19
+ handle-like tokens (``dbf1a023``, ``9442c853``) that differ between POUs in ways
20
+ unrelated to the POU's identity, so copying a template's values would be a guess;
21
+ * the trailing state line of a body, which differs by body kind -- an ``.STB`` body
22
+ has ``0\\t0\\t0\\t70...`` where a ``.GB`` body has ``1\\t80000000\\t2803f...``;
23
+ * the first line of each record, a small integer (bodies are 11 or 23) whose
24
+ meaning is not established.
25
+
26
+ The proven case study writes literal values for these. It was validated on a
27
+ project whose template had a plainer record, so its literals do not transfer here,
28
+ and guessing them risks a project MotionWorks cannot open. Reporting them is the
29
+ useful step: the patch can be reviewed and the missing values supplied.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ import shutil
36
+ import struct
37
+ import tempfile
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+
41
+ from .errors import IdeRunning, MotionWorksError, NotFound, VerificationFailed
42
+ from .tree import TreeDocument, TreeNode, parse_document
43
+ from .tree_writer import guid_line
44
+
45
+ #: Levels in a real tree: a POU container is 2, its worksheets 3.
46
+ POU_CONTAINER_LEVEL = 2
47
+ POU_WORKSHEET_LEVEL = 3
48
+
49
+ #: Nodes one POU contributes: container + T + V + body.
50
+ POU_NODE_COUNT = 4
51
+
52
+ #: Record layout, read off real POU nodes. A node is a count line, a params line,
53
+ #: a name, a path, then a blank/state/blank/GUID/state group. The blank line that
54
+ #: separates one record from the next is *not* part of either.
55
+ CONTAINER_LINES = 10 # count, params, name, path, blank, state, blank, GUID, state, marker
56
+ WORKSHEET_LINES = 9 # count, params, name, path, blank, state, blank, GUID, state
57
+
58
+ #: Field offsets within a record, so nothing has to count blanks by hand.
59
+ F_COUNT, F_PARAMS, F_NAME, F_PATH = 0, 1, 2, 3
60
+ F_STATE, F_GUID = 5, 7
61
+ F_TRAILING = 8
62
+ F_CONTAINER_MARKER = 9
63
+
64
+ #: The first line of each record is a small integer that is **fixed per kind**,
65
+ #: measured across all five projects rather than assumed:
66
+ #:
67
+ #: * POU containers are 7 in all 29 POUs;
68
+ #: * ``T`` worksheets are 42 in all 29;
69
+ #: * ``V`` worksheets are 8 in all 29;
70
+ #: * ``.STB`` bodies are 23 in all 15.
71
+ #:
72
+ #: Only the ``.GB`` body varies (11 in ten cases, 12 in four), so a graphical body
73
+ #: is the one case where this cannot be derived and must be supplied.
74
+ #:
75
+ #: The container value applies to **POUs only**, and a POU is identified
76
+ #: *structurally*: it has exactly three worksheets named ``<name>T``, ``<name>V``
77
+ #: and ``<name>``. Level is not enough -- libraries are level 2 with 31 or 3, and
78
+ #: RotaryKnife's ``RK_FunctionBlocks`` group is level 2 under ``Logical POUs`` with
79
+ #: 32. Both exceptions were found by checking the rule against real data rather
80
+ #: than assuming it generalised.
81
+ CONTAINER_COUNT_LINE = 7
82
+ WORKSHEET_COUNT_LINE = {"T": 42, "V": 8}
83
+ #: Keyed by extension *without* the leading dot, matching the caller's form.
84
+ BODY_COUNT_LINE = {"STB": 23}
85
+
86
+
87
+ def is_pou_container(node: TreeNode) -> bool:
88
+ """True when a node is a POU: three worksheets named ``<name>T/V/``.
89
+
90
+ Identifying a POU structurally rather than by level or count line is what makes
91
+ this reliable -- libraries and RotaryKnife's function-block group are also
92
+ level-2 containers but are not POUs.
93
+ """
94
+ if len(node.children) != 3:
95
+ return False
96
+ names = [child.name for child in node.children]
97
+ return sorted(names) == sorted([f"{node.name}T", f"{node.name}V", node.name])
98
+
99
+
100
+ def count_line_for(kind: str, body_suffix: str) -> int | None:
101
+ """The record's first line, or ``None`` when it varies and must be supplied."""
102
+ if kind in WORKSHEET_COUNT_LINE:
103
+ return WORKSHEET_COUNT_LINE[kind]
104
+ return BODY_COUNT_LINE.get(body_suffix.upper())
105
+
106
+
107
+ class PouPlanError(MotionWorksError):
108
+ """The plan could not be produced."""
109
+
110
+
111
+ @dataclass
112
+ class FieldNote:
113
+ """One field's provenance: derived, templated, or needing a value."""
114
+
115
+ record: str
116
+ line_index: int
117
+ name: str
118
+ status: str # "derived" | "from-template" | "unresolved"
119
+ detail: str = ""
120
+
121
+
122
+ @dataclass
123
+ class PouCreationPlan:
124
+ """A reviewable description of the POU that would be created."""
125
+
126
+ pou_name: str
127
+ template_name: str
128
+ project_root: Path
129
+ node_ids: list[int]
130
+ guids: list[str]
131
+ insert_at: int
132
+ records: list[str]
133
+ files: list[str]
134
+ notes: list[FieldNote] = field(default_factory=list)
135
+ warnings: list[str] = field(default_factory=list)
136
+
137
+ def unresolved(self) -> list[FieldNote]:
138
+ return [n for n in self.notes if n.status == "unresolved"]
139
+
140
+ def summary(self) -> str:
141
+ lines = [
142
+ "PLAN ONLY - nothing written.",
143
+ f"Create POU {self.pou_name!r} by cloning template {self.template_name!r}",
144
+ f" project : {self.project_root}",
145
+ f" node ids : {self.node_ids}",
146
+ f" insert at : tree line {self.insert_at} (before Physical Hardware)",
147
+ f" node total : +{POU_NODE_COUNT}",
148
+ "",
149
+ f" Files that would be created ({len(self.files)}):",
150
+ ]
151
+ lines.extend(f" {name}" for name in self.files)
152
+ lines.append("")
153
+ lines.append(
154
+ f" Tree records that would be inserted ({len(self.records)} lines; "
155
+ f"unresolved values are blank):"
156
+ )
157
+ lines.extend(f" {record!r}" for record in self.records)
158
+
159
+ derived = [n for n in self.notes if n.status == "derived"]
160
+ templated = [n for n in self.notes if n.status == "from-template"]
161
+ unresolved = self.unresolved()
162
+
163
+ lines.append("")
164
+ lines.append(f" Fields derived with confidence ({len(derived)}):")
165
+ for note in derived:
166
+ lines.append(
167
+ f" {note.record} [{note.line_index}] {note.name}: {note.detail}"
168
+ )
169
+
170
+ if templated:
171
+ lines.append("")
172
+ lines.append(f" Fields taken from the template ({len(templated)}):")
173
+ for note in templated:
174
+ lines.append(
175
+ f" {note.record} [{note.line_index}] {note.name}: {note.detail}"
176
+ )
177
+
178
+ lines.append("")
179
+ if unresolved:
180
+ lines.append(
181
+ f" UNRESOLVED ({len(unresolved)}) - must be supplied before writing:"
182
+ )
183
+ for note in unresolved:
184
+ lines.append(
185
+ f" {note.record} [{note.line_index}] {note.name}: {note.detail}"
186
+ )
187
+ else:
188
+ lines.append(" No unresolved fields.")
189
+ for warning in self.warnings:
190
+ lines.append(f" WARNING: {warning}")
191
+ return "\n".join(lines)
192
+
193
+
194
+ @dataclass
195
+ class PouDeletionPlan:
196
+ """A planned POU deletion."""
197
+
198
+ pou_name: str
199
+ project_root: Path
200
+ #: ``(start, end)`` line span of the POU's own records, removed entirely.
201
+ span: tuple[int, int]
202
+ #: Task instance blocks to remove: ``(task_name, span)``.
203
+ instances: list[tuple[str, tuple[int, int]]] = field(default_factory=list)
204
+ #: Count fields to decrement: ``(span, replacement)``.
205
+ count_edits: list[tuple[tuple[int, int], list[str]]] = field(default_factory=list)
206
+ total_before: int = 0
207
+ total_after: int = 0
208
+ #: Other POUs whose bodies name this one.
209
+ referenced_by: list[str] = field(default_factory=list)
210
+ #: Tasks this POU is currently assigned to.
211
+ assigned_to: list[str] = field(default_factory=list)
212
+ notes: list[str] = field(default_factory=list)
213
+
214
+ def summary(self) -> str:
215
+ lines = [
216
+ f"DELETE POU {self.pou_name}",
217
+ f" project : {self.project_root}",
218
+ f" tree records: lines {self.span[0]}..{self.span[1]} "
219
+ f"({self.span[1] - self.span[0]} lines removed)",
220
+ f" node total : {self.total_before} -> {self.total_after}",
221
+ ]
222
+ if self.assigned_to:
223
+ lines.append(
224
+ f" assigned to : {', '.join(self.assigned_to)} "
225
+ f"({len(self.instances)} instance block(s) removed)"
226
+ )
227
+ else:
228
+ lines.append(" assigned to : nothing")
229
+ if self.referenced_by:
230
+ lines.append(
231
+ f" REFERENCED BY: {', '.join(self.referenced_by)} - deleting it "
232
+ f"leaves a dangling call"
233
+ )
234
+ lines.extend(f" note: {note}" for note in self.notes)
235
+ return "\n".join(lines)
236
+
237
+
238
+ def find_pou_references(project_root: Path, pou_name: str) -> list[str]:
239
+ """Other POUs whose ST bodies name ``pou_name``.
240
+
241
+ The name is matched as a whole word, so deleting a POU another POU calls can be
242
+ refused rather than leaving a dangling reference -- the same guard variable
243
+ deletion uses.
244
+ """
245
+ from . import project as P
246
+ from .variables import CompressedStreamError
247
+
248
+ found: list[str] = []
249
+ project = P.Project(root=Path(project_root))
250
+ pous = project.pous() if callable(project.pous) else project.pous
251
+ pattern = re.compile(r"\b" + re.escape(pou_name) + r"\b")
252
+ for pou in pous:
253
+ if pou.name == pou_name:
254
+ continue
255
+ try:
256
+ body = pou.st_body()
257
+ except CompressedStreamError:
258
+ continue
259
+ except Exception: # noqa: BLE001 - an unreadable POU cannot be checked
260
+ continue
261
+ if body and pattern.search(body):
262
+ found.append(pou.name)
263
+ return sorted(found)
264
+
265
+
266
+ def plan_pou_deletion(
267
+ project_root: Path, pou_name: str, *, force: bool = False
268
+ ) -> PouDeletionPlan:
269
+ """Plan deleting a POU, its task assignments and its registry entries.
270
+
271
+ Refuses when another POU calls the one being deleted, unless ``force`` is set.
272
+ The tree edit itself is safe either way, but the project would no longer
273
+ compile, and a silent dangling call is worse than a refusal.
274
+ """
275
+ from .cfb import CompoundFile
276
+
277
+ project_root = Path(project_root)
278
+ src = project_root / "src.st1"
279
+ if not src.is_file():
280
+ raise NotFound(f"no src.st1 under {project_root}")
281
+ document = parse_document(
282
+ CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
283
+ )
284
+ target = _pou_container(document, pou_name)
285
+ if target is None:
286
+ raise NotFound(f"no POU named {pou_name!r} in the project tree")
287
+ if not is_pou_container(target):
288
+ raise PouPlanError(
289
+ f"{pou_name!r} is not a well-formed POU container; refusing to delete"
290
+ )
291
+
292
+ total_before = int(document.lines[1])
293
+
294
+ # Extend the removal over the blank line that TERMINATES the POU's records.
295
+ #
296
+ # TreeNode.span stops one line short of that blank - the same off-by-one that
297
+ # made creation drop the separators between records. Removing exactly the span
298
+ # therefore leaves a stray blank behind, and the tree no longer round-trips:
299
+ # measured, create-then-delete left PROJECT.TRE one line longer than the
300
+ # original and the project then refused to open with
301
+ # "Internal error in 'OpenProject'". Create and delete have to consume the same
302
+ # lines or the pair is not an inverse.
303
+ delete_span = target.span
304
+ if delete_span[1] < len(document.lines) and document.lines[delete_span[1]].strip() == "":
305
+ delete_span = (delete_span[0], delete_span[1] + 1)
306
+
307
+ plan = PouDeletionPlan(
308
+ pou_name=pou_name,
309
+ project_root=project_root,
310
+ span=delete_span,
311
+ total_before=total_before,
312
+ total_after=total_before - POU_NODE_COUNT,
313
+ )
314
+
315
+ # A task instance is a child of a task named after the POU.
316
+ instance_ancestor_decrements: dict[int, tuple[TreeNode, int]] = {}
317
+ for node, ancestors in document.walk_with_ancestors():
318
+ if node.params[1] != 5:
319
+ continue
320
+ for child in node.children:
321
+ if child.name == pou_name:
322
+ if child.children:
323
+ raise PouPlanError('Task instance has nested records; deletion layout is unsupported')
324
+ plan.instances.append((node.name, child.span))
325
+ plan.assigned_to.append(node.name)
326
+ # Measured after live COM assignment: Physical Hardware,
327
+ # Configuration, Resource, Tasks and task counts all include
328
+ # the instance. The parser may nest Hardware under Logical
329
+ # POUs, so use the physical branch boundary, not that artifact.
330
+ physical_branch = False
331
+ for ancestor in [*ancestors, node]:
332
+ physical_branch = physical_branch or ancestor.name == 'Physical Hardware'
333
+ if physical_branch:
334
+ prior = instance_ancestor_decrements.get(ancestor.line, (ancestor, 0))
335
+ instance_ancestor_decrements[ancestor.line] = (ancestor, prior[1] + 1)
336
+
337
+ plan.total_after -= len(plan.instances)
338
+
339
+ # The logical container loses four POU nodes. The root additionally loses
340
+ # every removed task instance; physical-branch ancestor decrements follow.
341
+ container = next(
342
+ node
343
+ for node, _ in document.walk_with_ancestors()
344
+ if node.name == "Logical POUs"
345
+ )
346
+ plan.count_edits.append(
347
+ (
348
+ (container.line, container.line + 1),
349
+ [_params_with(container.params, 2, container.params[2] - POU_NODE_COUNT)],
350
+ )
351
+ )
352
+ root = document.roots[0]
353
+ plan.count_edits.append(
354
+ (
355
+ (root.line, root.line + 1),
356
+ [_params_with(root.params, 2, root.params[2] - POU_NODE_COUNT - len(plan.instances))],
357
+ )
358
+ )
359
+ # Aggregate instance decrements once per physical ancestor/task.
360
+ for task, removed_instances in instance_ancestor_decrements.values():
361
+ plan.count_edits.append(
362
+ (
363
+ (task.line, task.line + 1),
364
+ [_params_with(task.params, 2, task.params[2] - removed_instances)],
365
+ )
366
+ )
367
+
368
+ plan.referenced_by = find_pou_references(project_root, pou_name)
369
+ if plan.referenced_by and not force:
370
+ raise PouPlanError(
371
+ f"refusing to delete {pou_name!r}: it is called by "
372
+ f"{', '.join(plan.referenced_by)}. Remove those calls first, or pass "
373
+ f"force=True to delete anyway and accept that the project will not "
374
+ f"compile."
375
+ )
376
+ if plan.referenced_by:
377
+ plan.notes.append(
378
+ f"forced despite being called by {', '.join(plan.referenced_by)}"
379
+ )
380
+ return plan
381
+
382
+
383
+ def _params_with(params: tuple[int, int, int, int], index: int, value: int) -> str:
384
+ values = list(params)
385
+ values[index] = max(0, value)
386
+ return " ".join(str(v) for v in values)
387
+
388
+
389
+ def render_pou_deletion(document: TreeDocument, plan: PouDeletionPlan) -> str:
390
+ """Remove the POU's records and its task instances, and adjust the counts."""
391
+ edits: list[tuple[int, int, list[str]]] = [
392
+ (plan.span[0], plan.span[1], []),
393
+ ]
394
+ for _, span in plan.instances:
395
+ edits.append((span[0], span[1], []))
396
+ for span, replacement in plan.count_edits:
397
+ edits.append((span[0], span[1], replacement))
398
+ edits.append((1, 2, [str(plan.total_after)]))
399
+ return document.splice(edits)
400
+
401
+
402
+ def _verify_deletion(
403
+ document: TreeDocument, plan: PouDeletionPlan, previous_total: int
404
+ ) -> list[str]:
405
+ """Check the invariants a POU deletion must satisfy."""
406
+ problems: list[str] = []
407
+ if _pou_container(document, plan.pou_name) is not None:
408
+ problems.append(f"{plan.pou_name!r} is still in the tree")
409
+ for node, _ in document.walk_with_ancestors():
410
+ if node.params[1] == 5 and any(
411
+ child.name == plan.pou_name for child in node.children
412
+ ):
413
+ problems.append(f"a task instance of {plan.pou_name!r} remains")
414
+ break
415
+ expected_total = previous_total - POU_NODE_COUNT - len(plan.instances)
416
+ if int(document.lines[1]) != expected_total:
417
+ problems.append(
418
+ f"node total is {document.lines[1]}, expected "
419
+ f"{expected_total}"
420
+ )
421
+ remaining = [
422
+ node.name
423
+ for node, _ in document.walk_with_ancestors()
424
+ if node.params[1] == POU_CONTAINER_LEVEL and is_pou_container(node)
425
+ ]
426
+ if not remaining:
427
+ problems.append("the deletion removed every POU")
428
+ return problems
429
+
430
+
431
+ def apply_pou_deletion(
432
+ plan: PouDeletionPlan,
433
+ *,
434
+ backup_dir: Path,
435
+ archive_dir: Path | None = None,
436
+ ) -> dict[str, object]:
437
+ """Delete a POU: archive its directory, splice the tree, update the registries.
438
+
439
+ The deleted POU directory is **moved to an archive**, not removed, so the
440
+ deletion is recoverable without depending on the backup copies -- the case study
441
+ does the same.
442
+
443
+ As with creation, the tree edit is rendered and verified in a scratch container
444
+ before anything is written.
445
+ """
446
+ from .cfb import CompoundFile
447
+ from .ide import ensure_ide_closed
448
+ from .tree_writer import backup_and_write
449
+
450
+ from .staging import assert_proven
451
+ assert_proven(plan.project_root)
452
+ ensure_ide_closed()
453
+
454
+ project_root = Path(plan.project_root)
455
+ src = project_root / "src.st1"
456
+ original = src.read_bytes()
457
+ document = parse_document(
458
+ CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
459
+ )
460
+ previous_total = int(document.lines[1])
461
+
462
+ rendered = render_pou_deletion(document, plan)
463
+ with tempfile.NamedTemporaryFile(
464
+ dir=src.parent, delete=False, suffix=".verify"
465
+ ) as handle:
466
+ scratch = Path(handle.name)
467
+ try:
468
+ CompoundFile(src).replace_streams(
469
+ {"PROJECT.TRE": rendered.encode("latin1")}, destination=scratch
470
+ )
471
+ check = parse_document(
472
+ CompoundFile(scratch).read_stream("PROJECT.TRE").decode("latin1")
473
+ )
474
+ problems = _verify_deletion(check, plan, previous_total)
475
+ if problems:
476
+ raise PouPlanError(
477
+ "refusing to delete the POU: the edited tree failed verification:\n "
478
+ + "\n ".join(problems)
479
+ )
480
+ finally:
481
+ scratch.unlink(missing_ok=True)
482
+
483
+ pou_dir = _pou_root(project_root) / plan.pou_name
484
+ archive = (archive_dir or (backup_dir / "archived-pous")) / plan.pou_name
485
+ if archive.exists():
486
+ raise PouPlanError(f"the archive location already exists: {archive}")
487
+
488
+ writes: list[tuple[Path, bytes, bytes]] = [
489
+ (src, original, _edited_container_bytes(src, {"PROJECT.TRE": rendered.encode("latin1"),
490
+ "PRMVIEWALL.DAT": _view_change(src, plan.pou_name, getattr(plan, "template_name", None))}))
491
+ ]
492
+ writes.extend(_deletion_registry_writes(project_root, plan))
493
+
494
+ moved = False
495
+ try:
496
+ archive.parent.mkdir(parents=True, exist_ok=True)
497
+ if pou_dir.is_dir():
498
+ shutil.move(str(pou_dir), str(archive))
499
+ moved = True
500
+ backups = backup_and_write(writes, backup_dir, project_root=project_root)
501
+ except Exception:
502
+ # Put the directory back so a failed deletion leaves the project as it was.
503
+ if moved and archive.is_dir() and not pou_dir.exists():
504
+ shutil.move(str(archive), str(pou_dir))
505
+ raise
506
+
507
+ stored = parse_document(
508
+ CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
509
+ )
510
+ problems = _verify_deletion(stored, plan, previous_total)
511
+ if problems:
512
+ raise VerificationFailed(
513
+ "the deleted POU failed read-back verification:\n " + "\n ".join(problems)
514
+ )
515
+
516
+ return {
517
+ "pou": plan.pou_name,
518
+ "node_total": int(stored.lines[1]),
519
+ "archived_to": str(archive) if moved else None,
520
+ "files_written": [str(path) for path, _, _ in writes],
521
+ "backups": backups,
522
+ }
523
+
524
+
525
+ def _deletion_registry_writes(
526
+ project_root: Path, plan: PouDeletionPlan
527
+ ) -> list[tuple[Path, bytes, bytes]]:
528
+ """Plan removing the POU from ``LIST.POU``, the node lists and ``PROJECT.INF``."""
529
+ from datetime import datetime
530
+
531
+ writes: list[tuple[Path, bytes, bytes]] = []
532
+
533
+ candidates = [project_root / "LIST.POU"]
534
+ for relative in (
535
+ "NODES.LST",
536
+ "C/Configuration/R/Resource/NODES.LST",
537
+ "C/Resource/R/Resource/NODES.LST",
538
+ ):
539
+ candidates.append(project_root / relative)
540
+
541
+ for path in candidates:
542
+ if not path.is_file():
543
+ continue
544
+ raw = path.read_bytes()
545
+ lines = raw.splitlines(keepends=True)
546
+ pattern = re.compile(
547
+ r"\b" + re.escape(plan.pou_name) + r"\b"
548
+ )
549
+ matched = [i for i, line in enumerate(lines) if pattern.search(line.decode("latin1"))]
550
+ if not matched:
551
+ continue
552
+ # Every matching line must be a record for this POU; anything else means the
553
+ # name is used for something we do not understand, so leave the file alone.
554
+ for index in sorted(matched, reverse=True):
555
+ del lines[index]
556
+ writes.append((path, raw, b"".join(lines)))
557
+
558
+ project_inf = project_root / "PROJECT.INF"
559
+ if project_inf.is_file():
560
+ raw = project_inf.read_bytes()
561
+ text = raw.decode("latin1")
562
+ stamp = datetime.now().strftime("%m/%d/%Y %I:%M:%S %p")
563
+ replaced = re.sub(r"(?m)^LastChange=.*$", f"LastChange={stamp}", text)
564
+ if replaced != text:
565
+ writes.append((project_inf, raw, replaced.encode("latin1")))
566
+
567
+ return writes
568
+
569
+
570
+ @dataclass
571
+ class TransplantPlan:
572
+ """A planned LD body transplantation from a donor POU."""
573
+
574
+ donor_name: str
575
+ target_name: str
576
+ project_root: Path
577
+ #: The streams to write into the target, as ``name -> payload``.
578
+ payloads: dict[str, bytes] = field(default_factory=dict)
579
+ #: Streams in the target that will be replaced: ``name -> before``.
580
+ before: dict[str, bytes] = field(default_factory=dict)
581
+ #: Variables whose grid usage was converted from external to local.
582
+ localized: int = 0
583
+ #: Function-block instances, which keep their usage value.
584
+ fb_instances: int = 0
585
+ declared_variables: int = 0
586
+ notes: list[str] = field(default_factory=list)
587
+
588
+ @property
589
+ def changed(self) -> bool:
590
+ return self.payloads != self.before
591
+
592
+ def summary(self) -> str:
593
+ lines = [
594
+ f"TRANSPLANT LD body: {self.donor_name} -> {self.target_name}",
595
+ f" project : {self.project_root}",
596
+ f" variables : {self.declared_variables} declared, "
597
+ f"{self.localized} external localized to local, "
598
+ f"{self.fb_instances} function-block instance(s) kept",
599
+ " streams written:",
600
+ ]
601
+ for name, payload in sorted(self.payloads.items()):
602
+ was = len(self.before.get(name, b""))
603
+ lines.append(f" {name:28} {was} -> {len(payload)} bytes")
604
+ lines.extend(f" note: {note}" for note in self.notes)
605
+ return "\n".join(lines)
606
+
607
+
608
+ def read_variable_grid_count(raw: bytes) -> int | None:
609
+ """The declared-variable count a ``.VGR`` stream claims, if it looks valid."""
610
+ if len(raw) < 12:
611
+ return None
612
+ count = struct.unpack_from("<I", raw, 8)[0]
613
+ # A plausible grid has a count far below its byte length; a compressed stream
614
+ # reports a value in the billions, which is how it is spotted.
615
+ return count if count <= len(raw) else None
616
+
617
+
618
+ def localize_variable_grid(raw: bytes) -> tuple[bytes, int, int]:
619
+ """Convert external variable records in a ``.VGR`` grid to local ones.
620
+
621
+ The record layout, from the proven case study: from offset 12 each record begins
622
+ with six little-endian ``uint32`` values -- handle, usage, group, flags,
623
+ worksheet row, final flags. ``usage`` is 1 for a local variable, 5 for an
624
+ external one, and ``0x00040001`` for a function-block instance.
625
+
626
+ A transplanted body must have its externals localized, because the donor's
627
+ external references belong to the donor's project. Function-block instances
628
+ keep their usage value.
629
+
630
+ Records are found by their field pattern rather than a fixed stride, which is
631
+ what defeated an earlier attempt at this format: the variable-length name and
632
+ type make the stride uneven. The count found must match the count the stream
633
+ declares, so a compressed or otherwise unexpected grid is refused rather than
634
+ rewritten.
635
+ """
636
+ if len(raw) < 12:
637
+ raise PouPlanError("the variable grid is too short to parse")
638
+ expected = struct.unpack_from("<I", raw, 8)[0]
639
+ data = bytearray(raw)
640
+ offsets: list[int] = []
641
+ for offset in range(12, max(12, len(data) - 23)):
642
+ handle, usage, group, flags, row, final_flags = struct.unpack_from(
643
+ "<6I", data, offset
644
+ )
645
+ if (
646
+ 1000 <= handle <= 10000
647
+ and usage in (1, 5, 0x00040001)
648
+ and group == 1
649
+ and flags == 0
650
+ and 1 <= row <= 999
651
+ and final_flags == 0
652
+ ):
653
+ offsets.append(offset)
654
+
655
+ if len(offsets) != expected:
656
+ raise PouPlanError(
657
+ f"the variable grid holds {len(offsets)} variable record(s) but declares "
658
+ f"{expected}; refusing to rewrite a grid whose layout is not the expected "
659
+ f"one (a compressed stream looks like this)"
660
+ )
661
+
662
+ localized = 0
663
+ fb_instances = 0
664
+ marker_moved = 0
665
+ for offset in offsets:
666
+ usage = struct.unpack_from("<I", data, offset + 4)[0]
667
+ if usage == 5:
668
+ struct.pack_into("<I", data, offset + 4, 1)
669
+ # An external record says so twice: header usage 5, and 0xFFFFFFFF as the third
670
+ # word of its trailing run. Moving only the header left each converted record
671
+ # contradicting itself, and the clone then stalled the build and blew its grid up
672
+ # to 79 MB on the next append. Both halves move together.
673
+ tail = _record_tail_offset(data, offset)
674
+ if tail is not None and struct.unpack_from("<I", data, tail + 8)[0] == 0xFFFFFFFF:
675
+ struct.pack_into("<I", data, tail + 8, 0)
676
+ marker_moved += 1
677
+ localized += 1
678
+ elif usage == 0x00040001:
679
+ fb_instances += 1
680
+ if localized and marker_moved != localized:
681
+ raise PouPlanError(
682
+ f"localized {localized} external record(s) but moved only {marker_moved} external "
683
+ f"marker(s); refusing to leave a record whose header says LOCAL while its trailing "
684
+ f"run still says EXTERNAL"
685
+ )
686
+ return bytes(data), localized, fb_instances
687
+
688
+
689
+ def _record_tail_offset(data: bytes | bytearray, offset: int) -> int | None:
690
+ """Where a record's trailing run begins, or None if its strings do not parse.
691
+
692
+ Six uint32 of header, then four length-prefixed UTF-16LE strings - type, address, initial
693
+ value, name - and then the trailing run. Located per record because the strings vary in
694
+ length, which is why this format defeated a fixed stride.
695
+ """
696
+ try:
697
+ cursor = offset + 24
698
+ for _ in range(4):
699
+ length = struct.unpack_from("<I", data, cursor)[0]
700
+ if length % 2 or cursor + 4 + length > len(data):
701
+ return None
702
+ cursor += 4 + length
703
+ if cursor + 16 > len(data):
704
+ return None
705
+ return cursor
706
+ except struct.error:
707
+ return None
708
+
709
+
710
+ def _body_streams(pou) -> tuple[str | None, str | None, str | None]:
711
+ """``(graphical body, declarations, grid)`` stream names for a POU."""
712
+ names = pou.stream_names()
713
+ graphical = next((n for n in names if n.endswith(".GB")), None)
714
+ declarations = next((n for n in names if n.endswith("V.VB")), None)
715
+ grid = next((n for n in names if n.endswith("V.VGR")), None)
716
+ return graphical, declarations, grid
717
+
718
+
719
+ def plan_transplant(
720
+ project_root: Path, donor_name: str, target_name: str
721
+ ) -> TransplantPlan:
722
+ """Plan copying a donor's graphical (LD) body into another POU.
723
+
724
+ Only graphical bodies are transplanted. An ST body is replaced as text by
725
+ :func:`plan_st_body`; a graphical one is binary and cannot be authored from
726
+ scratch, which is why the supported route is a proven donor.
727
+ """
728
+ from . import project as P
729
+ from .cfb import CompoundFile
730
+ from .variables import CompressedStreamError
731
+
732
+ project_root = Path(project_root)
733
+ project = P.Project(root=project_root)
734
+ donor = project.pou(donor_name)
735
+ target = project.pou(target_name)
736
+ if donor_name == target_name:
737
+ raise PouPlanError("the donor and the target are the same POU")
738
+
739
+ donor_streams = _body_streams(donor)
740
+ target_streams = _body_streams(target)
741
+ if donor_streams[0] is None:
742
+ raise PouPlanError(
743
+ f"donor {donor_name!r} has no graphical body; only LD bodies are "
744
+ f"transplanted (use mw_set_st_body for structured text)"
745
+ )
746
+ if None in target_streams:
747
+ raise PouPlanError(
748
+ f"target {target_name!r} has no graphical body to replace"
749
+ )
750
+
751
+ try:
752
+ donor.declarations()
753
+ except CompressedStreamError as exc:
754
+ raise PouPlanError(
755
+ f"donor {donor_name!r} has a compressed declaration stream, which cannot "
756
+ f"be localized: {exc}"
757
+ ) from exc
758
+
759
+ donor_cfb = CompoundFile(donor.source_path)
760
+ source_body = donor_cfb.read_stream(donor_streams[0])
761
+ source_vb = donor_cfb.read_stream(donor_streams[1])
762
+ source_vgr = donor_cfb.read_stream(donor_streams[2])
763
+
764
+ from .grid import validate_pair, parse
765
+ validate_pair(source_vb.decode("latin1"), source_vgr)
766
+ localized_vb, localized_vgr = source_vb, source_vgr
767
+ localized = 0
768
+ fb_instances = sum(r["usage"] == 0x40001 for r in parse(source_vgr))
769
+
770
+ target_cfb = CompoundFile(target.source_path)
771
+ plan = TransplantPlan(
772
+ donor_name=donor_name,
773
+ target_name=target_name,
774
+ project_root=project_root,
775
+ payloads={
776
+ target_streams[0]: source_body,
777
+ target_streams[1]: localized_vb,
778
+ target_streams[2]: localized_vgr,
779
+ },
780
+ before={
781
+ target_streams[0]: target_cfb.read_stream(target_streams[0]),
782
+ target_streams[1]: target_cfb.read_stream(target_streams[1]),
783
+ target_streams[2]: target_cfb.read_stream(target_streams[2]),
784
+ },
785
+ localized=localized,
786
+ fb_instances=fb_instances,
787
+ )
788
+ plan.declared_variables = read_variable_grid_count(source_vgr) or 0
789
+ if not plan.changed:
790
+ plan.notes.append("the target already matches the donor")
791
+ plan.notes.append(
792
+ "Native declaration usages are preserved; donor and target share the staged project"
793
+ )
794
+ return plan
795
+
796
+
797
+ def apply_transplant(plan: TransplantPlan, *, backup_dir: Path) -> dict[str, object]:
798
+ """Write a transplantation, verifying every stream reads back first."""
799
+ from . import project as P
800
+ from .cfb import CompoundFile
801
+ from .ide import ensure_ide_closed
802
+ from .tree_writer import backup_and_write
803
+
804
+ from .staging import assert_proven
805
+ assert_proven(plan.project_root)
806
+ ensure_ide_closed()
807
+
808
+ project_root = Path(plan.project_root)
809
+ project = P.Project(root=plan.project_root)
810
+ target = project.pou(plan.target_name)
811
+ source = target.source_path
812
+ original = source.read_bytes()
813
+
814
+ # Build the new container in a scratch file and verify every stream there, so a
815
+ # container defect becomes a refusal rather than a damaged POU.
816
+ with tempfile.NamedTemporaryFile(
817
+ dir=source.parent, delete=False, suffix=".verify"
818
+ ) as handle:
819
+ scratch = Path(handle.name)
820
+ try:
821
+ CompoundFile(source).replace_streams(plan.payloads, destination=scratch)
822
+ staged = CompoundFile(scratch)
823
+ for name, payload in plan.payloads.items():
824
+ if staged.read_stream(name) != payload:
825
+ raise VerificationFailed(
826
+ f"refusing to transplant: the container did not reproduce "
827
+ f"{name!r}. Nothing was written."
828
+ )
829
+ finally:
830
+ scratch.unlink(missing_ok=True)
831
+
832
+ writes = [(source, original, _edited_container_bytes(source, plan.payloads))]
833
+ backups = backup_and_write(writes, backup_dir, project_root=plan.project_root)
834
+
835
+ stored = CompoundFile(source)
836
+ problems = [
837
+ name for name, payload in plan.payloads.items()
838
+ if stored.read_stream(name) != payload
839
+ ]
840
+ if problems:
841
+ raise VerificationFailed(
842
+ f"the transplanted POU failed read-back verification for {problems}"
843
+ )
844
+ return {
845
+ "donor": plan.donor_name,
846
+ "target": plan.target_name,
847
+ "streams": sorted(plan.payloads),
848
+ "localized": plan.localized,
849
+ "fb_instances": plan.fb_instances,
850
+ "files_written": [str(path) for path, _, _ in writes],
851
+ "backups": backups,
852
+ }
853
+
854
+
855
+ def apply_pou_creation(
856
+ plan: PouCreationPlan,
857
+ *,
858
+ backup_dir: Path,
859
+ project_root: Path | None = None,
860
+ ) -> dict[str, object]:
861
+ """Create a POU: clone the directory, splice the tree, update the registries.
862
+
863
+ The steps are the validated workflow from the case study:
864
+
865
+ 1. clone the template directory, renaming files that mention the template;
866
+ 2. rename the streams inside the new POU's ``src.st1``;
867
+ 3. give the four worksheet GUIDs fresh values, in ``NodeProperties.xml`` and in
868
+ the tree records (which must agree);
869
+ 4. insert four nodes into ``PROJECT.TRE`` and move the container, root and total
870
+ counts;
871
+ 5. register the POU in ``LIST.POU`` and ``PROJECT.INF``.
872
+
873
+ Nothing is written until the tree edit has been rendered and verified in a
874
+ scratch container -- the same discipline the other writers use, because a
875
+ verification that runs after the write cannot prevent damage.
876
+ """
877
+ from .cfb import CompoundFile
878
+ from .ide import ensure_ide_closed
879
+ from .tree_writer import backup_and_write
880
+
881
+ from .staging import assert_proven
882
+ assert_proven(project_root or plan.project_root)
883
+ ensure_ide_closed()
884
+
885
+ project_root = Path(project_root or plan.project_root)
886
+ if plan.unresolved():
887
+ raise PouPlanError(
888
+ "the plan still has unresolved fields, so nothing was written: "
889
+ + ", ".join(f"{n.record}.{n.name}" for n in plan.unresolved())
890
+ )
891
+
892
+ src = project_root / "src.st1"
893
+ template_dir = _pou_root(project_root) / plan.template_name
894
+ target_dir = _pou_root(project_root) / plan.pou_name
895
+ if target_dir.exists():
896
+ raise PouPlanError(f"{target_dir} already exists")
897
+
898
+ original = src.read_bytes()
899
+ document = parse_document(
900
+ CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
901
+ )
902
+ previous_total = int(document.lines[1])
903
+
904
+ # --- render the tree edit, and prove it in a scratch container first
905
+ rendered = _render_tree(document, plan)
906
+ with tempfile.NamedTemporaryFile(
907
+ dir=src.parent, delete=False, suffix=".verify"
908
+ ) as handle:
909
+ scratch = Path(handle.name)
910
+ try:
911
+ CompoundFile(src).replace_streams(
912
+ {"PROJECT.TRE": rendered.encode("latin1")}, destination=scratch
913
+ )
914
+ check = parse_document(
915
+ CompoundFile(scratch).read_stream("PROJECT.TRE").decode("latin1")
916
+ )
917
+ problems = _verify_tree(check, plan, previous_total)
918
+ if problems:
919
+ raise PouPlanError(
920
+ "refusing to create the POU: the edited tree failed verification:\n "
921
+ + "\n ".join(problems)
922
+ )
923
+ finally:
924
+ scratch.unlink(missing_ok=True)
925
+
926
+ # --- clone the directory, then write the tree and the registries
927
+ target_dir.mkdir(parents=True, exist_ok=False)
928
+ try:
929
+ created: list[str] = []
930
+ for path in sorted(template_dir.iterdir()):
931
+ if not path.is_file() or path.name == "tmp.sto":
932
+ continue
933
+ destination = target_dir / _rename_for(
934
+ path.name, plan.template_name, plan.pou_name
935
+ )
936
+ shutil.copy2(path, destination)
937
+ created.append(str(destination))
938
+
939
+ _rewrite_node_properties(target_dir, plan)
940
+ _rename_pou_streams(target_dir, plan)
941
+ # Preserve the donor's native local/external/FB semantics.
942
+ localization = {"preserved_native_declarations": True}
943
+
944
+ writes: list[tuple[Path, bytes, bytes]] = [
945
+ (src, original, _edited_container_bytes(src, {"PROJECT.TRE": rendered.encode("latin1"),
946
+ "PRMVIEWALL.DAT": _view_change(src, plan.pou_name, getattr(plan, "template_name", None))}))
947
+ ]
948
+ writes.extend(_registry_writes(project_root, plan))
949
+ backups = backup_and_write(writes, backup_dir, project_root=project_root)
950
+ except Exception:
951
+ # A half-created POU is worse than none: remove it so a retry is clean.
952
+ shutil.rmtree(target_dir, ignore_errors=True)
953
+ raise
954
+
955
+ stored = parse_document(
956
+ CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
957
+ )
958
+ problems = _verify_tree(stored, plan, previous_total)
959
+ if problems:
960
+ raise VerificationFailed(
961
+ "the created POU failed read-back verification:\n " + "\n ".join(problems)
962
+ )
963
+
964
+ return {
965
+ "pou": plan.pou_name,
966
+ "template": plan.template_name,
967
+ "node_ids": plan.node_ids,
968
+ "node_total": int(stored.lines[1]),
969
+ "files_created": created,
970
+ "files_written": [str(path) for path, _, _ in writes],
971
+ "backups": backups,
972
+ # Cloning preserves native declaration usages; unlike a transplant it
973
+ # must not silently turn global bindings into unrelated local storage.
974
+ "localization": localization,
975
+ }
976
+
977
+
978
+ def _view_change(source: Path, name: str, template: str | None) -> bytes:
979
+ from .cfb import CompoundFile
980
+ raw = CompoundFile(source).read_stream("PRMVIEWALL.DAT")
981
+ lines = raw.decode("latin1").splitlines(keepends=True)
982
+ target = "@POUGRP." + name
983
+ matches = [i for i, line in enumerate(lines) if line.rstrip("\r\n").casefold() == target.casefold()]
984
+ if template is None:
985
+ if len(matches) > 1: raise PouPlanError("Duplicate native POU view entries")
986
+ if matches: del lines[matches[0]]
987
+ else:
988
+ if matches: raise PouPlanError("POU view already contains the new name")
989
+ donors = [i for i, line in enumerate(lines) if line.rstrip("\r\n").casefold() == ("@POUGRP." + template).casefold()]
990
+ if not donors:
991
+ donors = [i for i, line in enumerate(lines) if line.rstrip("\r\n") == "@POUGRP"]
992
+ if len(donors) != 1: raise PouPlanError("Expected one native POU view anchor")
993
+ original = lines[donors[0]]
994
+ ending = "\r\n" if original.endswith("\r\n") else "\n"
995
+ lines.insert(donors[0]+1, target + ending)
996
+ return "".join(lines).encode("latin1")
997
+
998
+
999
+ def _edited_container_bytes(src: Path, replacements: dict[str, bytes] | str) -> bytes:
1000
+ """Build the edited container in memory, via a scratch destination.
1001
+
1002
+ Accepts either a ``{stream: payload}`` mapping or a bare ``PROJECT.TRE`` string,
1003
+ since both callers need the same scratch-container discipline.
1004
+ """
1005
+ from .cfb import CompoundFile
1006
+
1007
+ if isinstance(replacements, str):
1008
+ replacements = {"PROJECT.TRE": replacements.encode("latin1")}
1009
+
1010
+ with tempfile.NamedTemporaryFile(
1011
+ dir=src.parent, delete=False, suffix=".pending"
1012
+ ) as handle:
1013
+ scratch = Path(handle.name)
1014
+ try:
1015
+ CompoundFile(src).replace_streams(replacements, destination=scratch)
1016
+ return scratch.read_bytes()
1017
+ finally:
1018
+ scratch.unlink(missing_ok=True)
1019
+
1020
+
1021
+ def _render_tree(document: TreeDocument, plan: PouCreationPlan) -> str:
1022
+ """Insert the four records and move the container, root and total counts."""
1023
+ container = next(
1024
+ node
1025
+ for node, _ in document.walk_with_ancestors()
1026
+ if node.name == "Logical POUs"
1027
+ )
1028
+ container_params = list(container.params)
1029
+ container_params[2] = container.params[2] + POU_NODE_COUNT
1030
+
1031
+ root = document.roots[0]
1032
+ root_params = list(root.params)
1033
+ root_params[2] = root.params[2] + POU_NODE_COUNT
1034
+
1035
+ total = int(document.lines[1]) + POU_NODE_COUNT
1036
+ return document.splice(
1037
+ [
1038
+ (plan.insert_at, plan.insert_at, plan.records),
1039
+ (container.line, container.line + 1,
1040
+ [" ".join(str(v) for v in container_params)]),
1041
+ (root.line, root.line + 1, [" ".join(str(v) for v in root_params)]),
1042
+ (1, 2, [str(total)]),
1043
+ ]
1044
+ )
1045
+
1046
+
1047
+ def _verify_tree(
1048
+ document: TreeDocument, plan: PouCreationPlan, previous_total: int
1049
+ ) -> list[str]:
1050
+ """Check the invariants a POU creation must satisfy."""
1051
+ problems: list[str] = []
1052
+ created = _pou_container(document, plan.pou_name)
1053
+ if created is None:
1054
+ problems.append(f"{plan.pou_name!r} is not in the tree after the edit")
1055
+ return problems
1056
+ if not is_pou_container(created):
1057
+ problems.append(
1058
+ f"{plan.pou_name!r} is not a well-formed POU container: children "
1059
+ f"{[c.name for c in created.children]}"
1060
+ )
1061
+ if created.params[0] != plan.node_ids[0]:
1062
+ problems.append(
1063
+ f"container node id is {created.params[0]}, expected {plan.node_ids[0]}"
1064
+ )
1065
+ for expected, child in zip(plan.node_ids[1:], created.children):
1066
+ if child.params[0] != expected:
1067
+ problems.append(
1068
+ f"worksheet {child.name!r} has node id {child.params[0]}, "
1069
+ f"expected {expected}"
1070
+ )
1071
+ if int(document.lines[1]) != previous_total + POU_NODE_COUNT:
1072
+ problems.append(
1073
+ f"node total is {document.lines[1]}, expected "
1074
+ f"{previous_total + POU_NODE_COUNT}"
1075
+ )
1076
+ existing = [
1077
+ node.name
1078
+ for node, _ in document.walk_with_ancestors()
1079
+ if node.params[1] == POU_CONTAINER_LEVEL and is_pou_container(node)
1080
+ ]
1081
+ if len(existing) < 2:
1082
+ problems.append("the edit left fewer than two POUs in the tree")
1083
+ return problems
1084
+
1085
+
1086
+ def _rewrite_node_properties(target_dir: Path, plan: PouCreationPlan) -> None:
1087
+ """Replace the template's four worksheet GUIDs with the new POU's."""
1088
+ path = target_dir / "NodeProperties.xml"
1089
+ if not path.is_file():
1090
+ return
1091
+ text = path.read_bytes().decode("utf-16")
1092
+ template_dir = _pou_root(plan.project_root) / plan.template_name
1093
+ old_guids = _template_guids(template_dir)
1094
+ if len(old_guids) != len(plan.guids):
1095
+ raise PouPlanError(
1096
+ f"template records {len(old_guids)} GUID(s) but the plan has "
1097
+ f"{len(plan.guids)}; refusing to rewrite NodeProperties.xml"
1098
+ )
1099
+ for old, new in zip(old_guids, plan.guids):
1100
+ text = text.replace(old, tree_form_to_guid(new), 1)
1101
+ path.write_bytes(text.encode("utf-16"))
1102
+
1103
+
1104
+ def _localize_pou_variables(target_dir: Path) -> dict[str, int]:
1105
+ """Make a clone's declarations LOCAL instead of external.
1106
+
1107
+ A clone inherits the template's variable records verbatim, including any declared
1108
+ ``VAR_EXTERNAL`` and their ``usage == 5`` binary grid records. Those expect a
1109
+ matching ``VAR_GLOBAL`` belonging to the ORIGINAL project, which a clone has no
1110
+ claim on.
1111
+
1112
+ Nothing compiles an unassigned POU, so this stays invisible - and then the assign
1113
+ makes the compiler evaluate every one of them at once. Measured on a template that
1114
+ carries externals: create, declare variables, write the body and add a global were
1115
+ ALL clean (is_compiled=true), and the ASSIGN alone turned the project into 125
1116
+ "No matching global variable found" errors. The failing build then drops a task
1117
+ assignment, so the project ends up both uncompilable and missing a task - which is
1118
+ what "the first attempt corrupted the project" actually was.
1119
+
1120
+ The transplanted-body path already does exactly this conversion (``plan_transplant``
1121
+ renames ``VAR_EXTERNAL`` to ``VAR`` and localizes the grid); creation did not, and
1122
+ that difference was the entire bug.
1123
+
1124
+ A grid whose layout is not the expected one is left untouched rather than guessed
1125
+ at, and the counts are returned so the caller can report what happened.
1126
+ """
1127
+ from .cfb import CompoundFile
1128
+
1129
+ result = {"declarations_rewritten": 0, "grid_localized": 0, "fb_instances": 0, "grid_skipped": 0}
1130
+ src = target_dir / "src.st1"
1131
+ if not src.is_file():
1132
+ return result
1133
+
1134
+ cfb = CompoundFile(src)
1135
+ names = cfb.stream_names()
1136
+ declaration_stream = next((n for n in names if n.endswith("V.VB")), None)
1137
+ grid_stream = next((n for n in names if n.endswith("V.VGR")), None)
1138
+
1139
+ replacements: dict[str, bytes] = {}
1140
+
1141
+ if declaration_stream:
1142
+ raw = cfb.read_stream(declaration_stream)
1143
+ if b"VAR_EXTERNAL" in raw:
1144
+ result["declarations_rewritten"] = raw.count(b"VAR_EXTERNAL")
1145
+ replacements[declaration_stream] = raw.replace(b"VAR_EXTERNAL", b"VAR")
1146
+
1147
+ if grid_stream:
1148
+ raw = cfb.read_stream(grid_stream)
1149
+ try:
1150
+ localized_bytes, localized, fb_instances = localize_variable_grid(raw)
1151
+ result["grid_localized"] = localized
1152
+ result["fb_instances"] = fb_instances
1153
+ if localized:
1154
+ replacements[grid_stream] = localized_bytes
1155
+ except PouPlanError:
1156
+ # Refusing to rewrite an unexpected layout is the safe choice; report it
1157
+ # rather than silently leaving externals in place.
1158
+ result["grid_skipped"] = 1
1159
+
1160
+ if replacements:
1161
+ cfb.replace_streams(replacements)
1162
+ return result
1163
+
1164
+
1165
+ def _rename_pou_streams(target_dir: Path, plan: PouCreationPlan) -> None:
1166
+ """Rename the streams inside the new POU's ``src.st1`` to its own name."""
1167
+ from .cfb import CompoundFile
1168
+
1169
+ src = target_dir / "src.st1"
1170
+ if not src.is_file():
1171
+ return
1172
+ cfb = CompoundFile(src)
1173
+ renames = {
1174
+ stream: _rename_for(stream, plan.template_name, plan.pou_name)
1175
+ for stream in cfb.stream_names()
1176
+ if plan.template_name.lower() in stream.lower()
1177
+ }
1178
+ if renames:
1179
+ cfb.rename_streams(renames)
1180
+
1181
+
1182
+ def _registry_writes(
1183
+ project_root: Path, plan: PouCreationPlan
1184
+ ) -> list[tuple[Path, bytes, bytes]]:
1185
+ """Plan the ``LIST.POU`` and ``PROJECT.INF`` updates."""
1186
+ from datetime import datetime
1187
+
1188
+ writes: list[tuple[Path, bytes, bytes]] = []
1189
+
1190
+ list_pou = project_root / "LIST.POU"
1191
+ if list_pou.is_file():
1192
+ raw = list_pou.read_bytes()
1193
+ text = raw.decode("latin1")
1194
+ ending = "\r\n" if "\r\n" in text else "\n"
1195
+ line = f"PROGRAM\t{plan.pou_name}\t\t\tPT=1\t{plan.pou_name}\tHIDDEN=0\t"
1196
+ if f"PROGRAM\t{plan.pou_name}\t" in text:
1197
+ raise PouPlanError(f"{plan.pou_name!r} already appears in LIST.POU")
1198
+ after = text + ("" if text.endswith(("\n", "\r")) else ending) + line + ending
1199
+ writes.append((list_pou, raw, after.encode("latin1")))
1200
+
1201
+ project_inf = project_root / "PROJECT.INF"
1202
+ if project_inf.is_file():
1203
+ raw = project_inf.read_bytes()
1204
+ text = raw.decode("latin1")
1205
+ stamp = datetime.now().strftime("%m/%d/%Y %I:%M:%S %p")
1206
+ replaced = re.sub(r"(?m)^LastChange=.*$", f"LastChange={stamp}", text)
1207
+ if replaced != text:
1208
+ writes.append((project_inf, raw, replaced.encode("latin1")))
1209
+
1210
+ return writes
1211
+
1212
+
1213
+ def _pou_container(document: TreeDocument, name: str) -> TreeNode | None:
1214
+ for node, _ in document.walk_with_ancestors():
1215
+ if node.params[1] == POU_CONTAINER_LEVEL and node.name == name:
1216
+ return node
1217
+ return None
1218
+
1219
+
1220
+ def _physical_hardware(document: TreeDocument) -> TreeNode | None:
1221
+ """The node POU records are inserted before, as the case study does.
1222
+
1223
+ The parser's subtree bookkeeping nests this under ``Logical POUs``, so it is
1224
+ found by name and position rather than by walking parents.
1225
+ """
1226
+ for node, _ in document.walk_with_ancestors():
1227
+ record = node.path.split("\t")[0].replace("/", "\\")
1228
+ if node.name == "Physical Hardware" or record.endswith("\\HW"):
1229
+ return node
1230
+ return None
1231
+
1232
+
1233
+ def _pou_root(project_root: Path) -> Path:
1234
+ candidate = Path(project_root) / "POE"
1235
+ if not candidate.is_dir():
1236
+ raise NotFound(f"no POE directory under {project_root}")
1237
+ return candidate
1238
+
1239
+
1240
+ def _template_guids(directory: Path) -> list[str]:
1241
+ path = directory / "NodeProperties.xml"
1242
+ if not path.is_file():
1243
+ return []
1244
+ text = path.read_bytes().decode("utf-16")
1245
+ return re.findall(r'GUID="([0-9A-Fa-f-]{36})"', text)
1246
+
1247
+
1248
+ def guid_to_tree_form(dashed: str) -> str:
1249
+ """Convert ``XXXXXXXX-XXXX-...`` to the tree's space-separated form."""
1250
+ parts = dashed.split("-")
1251
+ if len(parts) != 5:
1252
+ raise PouPlanError(f"not a dashed GUID: {dashed!r}")
1253
+ tail = " ".join(parts[4][i : i + 2] for i in range(0, len(parts[4]), 2))
1254
+ return f"{parts[0]} {parts[1]} {parts[2]} {parts[3][:2]} {parts[3][2:]} {tail}"
1255
+
1256
+
1257
+ def tree_form_to_guid(spaced: str) -> str:
1258
+ """Convert the tree's spaced form back to ``XXXXXXXX-XXXX-...``.
1259
+
1260
+ The two representations are different strings, and ``NodeProperties.xml`` wants
1261
+ the dashed one. Handing a tree-form value to :func:`guid_to_tree_form` raises
1262
+ rather than writing a GUID the tree and the XML file would disagree about.
1263
+ """
1264
+ parts = spaced.split()
1265
+ if len(parts) != 11:
1266
+ raise PouPlanError(f"not a tree-form GUID: {spaced!r}")
1267
+ return (
1268
+ f"{parts[0]}-{parts[1]}-{parts[2]}-"
1269
+ f"{parts[3]}{parts[4]}-" + "".join(parts[5:])
1270
+ )
1271
+
1272
+
1273
+ def plan_pou_creation(
1274
+ project_root: Path, pou_name: str, template_name: str
1275
+ ) -> PouCreationPlan:
1276
+ """Produce a POU creation plan, marking every field that cannot be derived."""
1277
+ from .cfb import CompoundFile
1278
+ import re
1279
+ for value in (pou_name, template_name):
1280
+ if not re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*', value):
1281
+ raise PouPlanError('POU names must be IEC identifiers, not paths')
1282
+ import re
1283
+ for value in (pou_name, template_name):
1284
+ if not re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*', value):
1285
+ raise PouPlanError('POU names must be IEC identifiers, not paths')
1286
+
1287
+ project_root = Path(project_root)
1288
+ src = project_root / "src.st1"
1289
+ if not src.is_file():
1290
+ raise NotFound(f"no src.st1 under {project_root}")
1291
+ document = parse_document(
1292
+ CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
1293
+ )
1294
+
1295
+ if _pou_container(document, pou_name) is not None:
1296
+ raise PouPlanError(f"{pou_name!r} already exists in the project tree")
1297
+
1298
+ pou_root = _pou_root(project_root)
1299
+ template_dir = pou_root / template_name
1300
+ if not template_dir.is_dir():
1301
+ available = sorted(p.name for p in pou_root.iterdir() if p.is_dir())
1302
+ raise NotFound(f"no template POU {template_name!r}. Available: {available}")
1303
+
1304
+ template = _pou_container(document, template_name)
1305
+ if template is None:
1306
+ raise PouPlanError(
1307
+ f"template POU {template_name!r} has a directory but no tree node"
1308
+ )
1309
+ if not is_pou_container(template):
1310
+ raise PouPlanError(
1311
+ f"template {template_name!r} is not a POU container: it has "
1312
+ f"{len(template.children)} worksheet(s), expected exactly "
1313
+ f"<name>T, <name>V and <name>"
1314
+ )
1315
+
1316
+ warnings: list[str] = []
1317
+ template_guids = _template_guids(template_dir)
1318
+ if len(template_guids) != POU_NODE_COUNT:
1319
+ # Refuse here rather than during the clone: a template whose
1320
+ # NodeProperties.xml does not record exactly four worksheet GUIDs cannot be
1321
+ # given fresh ones that agree with the tree records, and failing before
1322
+ # anything is created leaves the project untouched. Measured: one sample
1323
+ # POU records 12.
1324
+ raise PouPlanError(
1325
+ f"template {template_name!r} records {len(template_guids)} GUID(s) in "
1326
+ f"NodeProperties.xml but a POU needs {POU_NODE_COUNT}; it cannot be "
1327
+ f"cloned safely. Do not remove historical records to force a clone. "
1328
+ f"Four-GUID template candidates (still require plan validation): "
1329
+ f"{[p.name for p in sorted(pou_root.iterdir()) if p.is_dir() and len(_template_guids(p)) == POU_NODE_COUNT]}"
1330
+ )
1331
+
1332
+ physical = _physical_hardware(document)
1333
+ insert_at = physical.start_line if physical else document.roots[0].end_line
1334
+
1335
+ highest = max(
1336
+ (node.node_id for node, _ in document.walk_with_ancestors()), default=0
1337
+ )
1338
+ node_ids = [highest + i for i in range(1, POU_NODE_COUNT + 1)]
1339
+ guids = [guid_line() for _ in range(POU_NODE_COUNT)]
1340
+
1341
+ def kind_of(child: TreeNode) -> str:
1342
+ return "T" if child.name.endswith("T") else (
1343
+ "V" if child.name.endswith("V") else ""
1344
+ )
1345
+
1346
+ body_child = next(child for child in template.children if kind_of(child) == "")
1347
+ body_suffix = Path(body_child.path.split("\t")[0]).suffix or ".STB"
1348
+
1349
+ records, notes = build_records(
1350
+ document,
1351
+ template,
1352
+ pou_name,
1353
+ node_ids,
1354
+ guids,
1355
+ body_child.path.split("\t")[0],
1356
+ )
1357
+
1358
+ target = pou_root / pou_name
1359
+ # Rename any file whose name mentions the template, in any case. Matching only
1360
+ # the exact and upper-cased forms leaves mixed-case names behind, and replacing
1361
+ # twice in a row can double-substitute.
1362
+ #
1363
+ # Files that do *not* mention the template are copied unchanged, but called out:
1364
+ # a POU directory holds a few files whose purpose is not established
1365
+ # (``Initialize.CCI`` in one template, ``EnableV.cfb`` in another), and whether
1366
+ # those are per-POU or shared with a group cannot be decided from the files.
1367
+ # Reporting them is honest; renaming them on a guess could break the clone.
1368
+ files = sorted(
1369
+ str(target / _rename_for(p.name, template_name, pou_name))
1370
+ for p in template_dir.iterdir()
1371
+ if p.is_file() and p.name != "tmp.sto"
1372
+ )
1373
+ # Files whose names are fixed for every POU, so not mentioning the template is
1374
+ # expected and not worth flagging.
1375
+ fixed_names = {
1376
+ "nodeproperties.xml",
1377
+ "poureserve.prs",
1378
+ "src.st1",
1379
+ "tmp.sto",
1380
+ }
1381
+ unnamed = sorted(
1382
+ p.name
1383
+ for p in template_dir.iterdir()
1384
+ if p.is_file()
1385
+ and p.name.lower() not in fixed_names
1386
+ and template_name.lower() not in p.name.lower()
1387
+ )
1388
+ if unnamed:
1389
+ warnings.append(
1390
+ "copied unchanged, since their names do not mention the template - "
1391
+ "check whether they are POU-specific and should be renamed: "
1392
+ + ", ".join(unnamed)
1393
+ )
1394
+ if not files:
1395
+ warnings.append("template directory has no cloneable files")
1396
+
1397
+ return PouCreationPlan(
1398
+ pou_name=pou_name,
1399
+ template_name=template_name,
1400
+ project_root=project_root,
1401
+ node_ids=node_ids,
1402
+ guids=guids,
1403
+ insert_at=insert_at,
1404
+ records=records,
1405
+ files=files,
1406
+ notes=notes,
1407
+ warnings=warnings,
1408
+ )
1409
+
1410
+
1411
+ def _rename_for(filename: str, template_name: str, pou_name: str) -> str:
1412
+ """Rename a template file for the new POU, case-insensitively and once.
1413
+
1414
+ A single regex substitution avoids the two failure modes of chained
1415
+ ``str.replace`` calls: leaving a mixed-case occurrence untouched, and
1416
+ substituting twice when the replacement itself contains the search text.
1417
+ """
1418
+ import re as _re
1419
+
1420
+ return _re.sub(_re.escape(template_name), pou_name, filename, flags=_re.IGNORECASE)
1421
+
1422
+
1423
+ def _template_record_lines(document: TreeDocument, node: TreeNode) -> list[str]:
1424
+ """The raw record lines a template node occupies, INCLUDING its trailing blank.
1425
+
1426
+ A blank line separates one tree record from the next, and that blank belongs to
1427
+ the record's extent: ``end_line`` points AT it. ``range(start, end)`` is
1428
+ exclusive, so the earlier version silently dropped it and the records cloned into
1429
+ a new POU ran together with no separator.
1430
+
1431
+ That produced a project which passed every internal check and then would not
1432
+ load. Measured directly: a fresh copy of the same project opens fine, a copy with
1433
+ one created POU returns "Internal error in 'OpenProject'", and diffing the two
1434
+ trees showed the inserted worksheet records missing exactly the blank lines every
1435
+ existing POU in the file has.
1436
+
1437
+ Reattaching the blank is safe for the container caller, which slices these lines
1438
+ with ``[: first_child - start_line]`` and so drops the extra line.
1439
+ """
1440
+ lines = [document.lines[i] for i in range(node.start_line, node.end_line)]
1441
+ following = node.end_line
1442
+ if 0 <= following < len(document.lines) and document.lines[following].strip() == "":
1443
+ lines.append(document.lines[following])
1444
+ return lines
1445
+
1446
+
1447
+ def build_records(
1448
+ document: TreeDocument,
1449
+ template: TreeNode,
1450
+ pou_name: str,
1451
+ node_ids: list[int],
1452
+ guids: list[str],
1453
+ body_stream: str,
1454
+ ) -> tuple[list[str], list[FieldNote]]:
1455
+ """Build the four tree records for a new POU by cloning the template's.
1456
+
1457
+ Every field is either derived -- node id, level, name, path, GUID, and the
1458
+ count line, all of which were measured across the whole sample set -- or copied
1459
+ **verbatim from the template**.
1460
+
1461
+ Copying is deliberate for the worksheet state lines. They carry opaque
1462
+ project-local handles (``dbf1a023``, ``9442c853``) that appear exactly once in
1463
+ the tree and are not derived from the POU name, the node GUID, or any other
1464
+ in-tree value; hashes of the name do not reproduce them, and the same POU name
1465
+ in two projects carries different handles. Inventing them is not possible from
1466
+ the files alone, so the template's values are reused and each one is reported as
1467
+ ``from-template`` rather than presented as derived.
1468
+
1469
+ ``body_stream`` is the template's body stream name (``Template.STB``); the new
1470
+ POU's body takes the same extension.
1471
+ """
1472
+ directory = f"POE\\{pou_name}"
1473
+ body_suffix = Path(body_stream).suffix or ".STB"
1474
+ extension = {"T": "TXT", "V": "VGR", "": body_suffix.lstrip(".")}
1475
+
1476
+ def kind_of(child: TreeNode) -> str:
1477
+ return "T" if child.name.endswith("T") else (
1478
+ "V" if child.name.endswith("V") else ""
1479
+ )
1480
+
1481
+ container_lines = _template_record_lines(document, template)
1482
+ # The container's own lines are everything before its first worksheet.
1483
+ first_child = min(child.start_line for child in template.children)
1484
+ container_own = [
1485
+ line for line in container_lines if line is not None
1486
+ ][: first_child - template.start_line]
1487
+
1488
+ records: list[str] = list(container_own)
1489
+ records[F_PARAMS] = f"{node_ids[0]} {POU_CONTAINER_LEVEL} {len(template.children)} 0"
1490
+ records[F_NAME] = f"{pou_name}\t0\t0\tConfiguration.Resource"
1491
+ records[F_PATH] = f"{directory}\t\t\t\t-1\t"
1492
+ records[F_GUID] = guids[0]
1493
+
1494
+ notes: list[FieldNote] = [
1495
+ FieldNote("container", F_PARAMS, "params", "derived",
1496
+ f"node id {node_ids[0]}, level {POU_CONTAINER_LEVEL}, "
1497
+ f"{len(template.children)} children"),
1498
+ FieldNote("container", F_NAME, "name", "derived", pou_name),
1499
+ FieldNote("container", F_PATH, "path", "derived", directory),
1500
+ FieldNote("container", F_GUID, "GUID", "derived", guids[0]),
1501
+ FieldNote("container", F_STATE, "state line", "from-template",
1502
+ f"copied from {template.name!r}: opaque project-local handles, not "
1503
+ f"derivable from any file"),
1504
+ ]
1505
+
1506
+ for offset, child in enumerate(template.children, start=1):
1507
+ kind = kind_of(child)
1508
+ worksheet_name = f"{pou_name}{kind}"
1509
+ filename = f"{worksheet_name}.{extension[kind]}"
1510
+ label = f"worksheet {kind or 'body'}"
1511
+
1512
+ record = _template_record_lines(document, child)
1513
+ record[F_PARAMS] = f"{node_ids[offset]} {POU_WORKSHEET_LEVEL} 0 0"
1514
+ record[F_NAME] = f"{worksheet_name}\t0\t0\tConfiguration.Resource"
1515
+ record[F_PATH] = f"{directory}\\{filename}\t\t\t\t-1\t"
1516
+ record[F_GUID] = guids[offset]
1517
+ records.extend(record)
1518
+
1519
+ notes += [
1520
+ FieldNote(label, F_COUNT, "count line", "from-template",
1521
+ f"copied from the template ({document.lines[child.start_line]!r})"),
1522
+ FieldNote(label, F_PARAMS, "params", "derived",
1523
+ f"node id {node_ids[offset]}, level {POU_WORKSHEET_LEVEL}"),
1524
+ FieldNote(label, F_NAME, "name", "derived", worksheet_name),
1525
+ FieldNote(label, F_PATH, "path", "derived", f"{directory}\\{filename}"),
1526
+ FieldNote(label, F_STATE, "state line", "from-template",
1527
+ "copied from the template; opaque handles, not derivable"),
1528
+ FieldNote(label, F_GUID, "GUID", "derived", guids[offset]),
1529
+ ]
1530
+ if len(record) > F_TRAILING:
1531
+ notes.append(
1532
+ FieldNote(label, F_TRAILING, "trailing state", "from-template",
1533
+ "copied from the template; differs by body kind and is not "
1534
+ "derivable")
1535
+ )
1536
+ return records, notes
1537
+
1538
+
1539
+ def _default_backup_dir(project_root: Path) -> Path:
1540
+ """Where POU-creation backups go, outside the project tree.
1541
+
1542
+ Reuses the tree writer's location and stamping so a project's backups stay in
1543
+ one place.
1544
+ """
1545
+ from .tree_writer import default_backup_dir
1546
+
1547
+ return default_backup_dir(project_root)