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