@aryacode/dsh-engineering 0.4.7 → 0.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +202 -0
- package/README.md +40 -26
- package/cordis.patch.yml +7 -6
- package/entries/config.js +62 -0
- package/entries/ftview.js +5 -4
- package/entries/l5x.js +47 -15
- package/entries/mwi.js +26 -6
- package/entries/optix.js +105 -0
- package/lib/client.js +335 -6
- package/package.json +11 -10
- package/vendor/ftview/SKILL.md +17 -0
- package/vendor/l5x/SKILL.md +17 -0
- package/vendor/l5x/studio-bridge/NuGet.Config +1 -1
- package/vendor/mwi/README.md +41 -18
- package/vendor/mwi/SKILL.md +39 -20
- package/vendor/mwi/bridge/mw_bridge.ps1 +2768 -2763
- package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -30
- package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -22
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.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__/staging.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -339
- package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -765
- package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -399
- package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +313 -313
- package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -520
- package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -133
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -672
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -86
- package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -208
- package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +134 -130
- package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +673 -673
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -790
- package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +642 -642
- package/vendor/mwi/lib/core/code-engine.d.ts +13 -0
- package/vendor/mwi/lib/core/code-engine.js +18 -1
- package/vendor/mwi/lib/edit.d.ts +1 -1
- package/vendor/mwi/lib/edit.js +1 -1
- package/vendor/mwi/lib/read/service.js +8 -1
- package/vendor/mwi/lib/session/backup.d.ts +72 -0
- package/vendor/mwi/lib/session/backup.js +257 -0
- package/vendor/mwi/lib/session/project.d.ts +4 -1
- package/vendor/mwi/lib/session/project.js +13 -2
- package/vendor/mwi/lib/session/service.d.ts +13 -6
- package/vendor/mwi/lib/session/service.js +127 -36
- package/vendor/mwi/lib/session/workspace.d.ts +10 -0
- package/vendor/mwi/lib/session/workspace.js +53 -3
- package/vendor/mwi/lib/skill.d.ts +2 -2
- package/vendor/mwi/lib/skill.js +2 -2
- package/vendor/mwi/lib/tools/edit.d.ts +3 -3
- package/vendor/mwi/lib/tools/edit.js +14 -15
- package/vendor/mwi/lib/tools/read.js +1 -1
- package/vendor/mwi/lib/tools/session.js +13 -9
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -81
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -38
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -67
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +26 -26
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -57
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -7
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -16
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +250 -250
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +9 -9
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -19
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -172
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -21
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -20
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -11
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -26
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -97
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -38
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -67
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +26 -26
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -57
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -7
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -444
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -81
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -163
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -1663
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -2471
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -218
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -524
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -690
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -1265
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +252 -252
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -19
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +9 -9
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -19
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -172
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -21
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -74
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -420
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -34
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -47
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -153
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -118
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -166
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -56
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -138
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -87
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -139
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -168
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -106
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -37
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -91
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -93
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -77
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -111
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -4
- 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__/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__/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
|
@@ -1,676 +1,676 @@
|
|
|
1
|
-
"""Static checks for MotionWorks IEC Structured Text bodies.
|
|
2
|
-
|
|
3
|
-
This linter is deliberately conservative. It reports only *high-confidence*
|
|
4
|
-
problems and stays silent whenever the answer depends on information it cannot
|
|
5
|
-
see: library types, function-block pin diagrams, struct member layouts, device
|
|
6
|
-
I/O configuration, and the internals of other POUs.
|
|
7
|
-
|
|
8
|
-
That restraint is the whole design. An earlier revision flagged every member
|
|
9
|
-
name and function-block pin (``Axis1.AxisNum``, ``MC.Power``'s ``.Execute``,
|
|
10
|
-
``.Done``, ``.Error``) as an undefined symbol, which produced hundreds of
|
|
11
|
-
findings on correct production code. A noisy linter is worse than none, because
|
|
12
|
-
an agent will either ignore it or start "fixing" working logic.
|
|
13
|
-
|
|
14
|
-
Checks performed:
|
|
15
|
-
|
|
16
|
-
``undefined-assignment-target``
|
|
17
|
-
An assignment target (``Name := ...``) is declared nowhere reachable. This
|
|
18
|
-
is the highest-value check: it is exactly the mistake an agent makes when it
|
|
19
|
-
invents a variable name, and an assignment target is never a member access
|
|
20
|
-
unless written as ``Struct.Member := ...``, which is excluded.
|
|
21
|
-
|
|
22
|
-
``unused-local``
|
|
23
|
-
A plain ``VAR`` local is never referenced in the executable region.
|
|
24
|
-
|
|
25
|
-
``unassigned-fb-output``
|
|
26
|
-
A function-block instance is called but none of its outputs is read, so the
|
|
27
|
-
result is discarded. Reported only when the instance has no output-style
|
|
28
|
-
member access at all.
|
|
29
|
-
|
|
30
|
-
``unterminated-statement``
|
|
31
|
-
A logical statement does not end with ``;``. Statements are joined across
|
|
32
|
-
continuation lines first, so multi-line assignments are not misreported.
|
|
33
|
-
|
|
34
|
-
Scope note: graphical (``.GB``) bodies are not text and cannot be analysed.
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
from __future__ import annotations
|
|
38
|
-
|
|
39
|
-
import re
|
|
40
|
-
from dataclasses import dataclass, field
|
|
41
|
-
|
|
42
|
-
#: IEC 61131-3 keywords and operators that must never be reported as symbols.
|
|
43
|
-
KEYWORDS = {
|
|
44
|
-
"IF", "THEN", "ELSIF", "ELSE", "END_IF", "CASE", "OF", "END_CASE",
|
|
45
|
-
"FOR", "TO", "BY", "DO", "END_FOR", "WHILE", "END_WHILE", "REPEAT",
|
|
46
|
-
"UNTIL", "END_REPEAT", "RETURN", "EXIT", "CONTINUE", "VAR", "VAR_INPUT",
|
|
47
|
-
"VAR_OUTPUT", "VAR_IN_OUT", "VAR_GLOBAL", "VAR_EXTERNAL", "VAR_TEMP",
|
|
48
|
-
"END_VAR", "PROGRAM", "FUNCTION", "FUNCTION_BLOCK", "END_PROGRAM",
|
|
49
|
-
"END_FUNCTION", "END_FUNCTION_BLOCK", "TYPE", "END_TYPE", "STRUCT",
|
|
50
|
-
"END_STRUCT", "AT", "RETAIN", "NON_RETAIN", "CONSTANT", "ARRAY", "AND",
|
|
51
|
-
"OR", "XOR", "NOT", "MOD", "TRUE", "FALSE", "NULL",
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
ELEMENTARY_TYPES = {
|
|
55
|
-
"BOOL", "BYTE", "WORD", "DWORD", "LWORD", "SINT", "USINT", "INT", "UINT",
|
|
56
|
-
"DINT", "UDINT", "LINT", "ULINT", "REAL", "LREAL", "STRING", "WSTRING",
|
|
57
|
-
"TIME", "LTIME", "DATE", "LDATE", "TOD", "LTOD", "DT", "LDT",
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
61
|
-
_BLOCK_COMMENT_RE = re.compile(r"\(\*.*?\*\)", re.DOTALL)
|
|
62
|
-
_LINE_COMMENT_RE = re.compile(r"//[^\n]*")
|
|
63
|
-
_STRING_RE = re.compile(r"'[^'\n]*'")
|
|
64
|
-
_TYPED_LITERAL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)#")
|
|
65
|
-
#: Identifiers that are the *root* of an expression: not preceded by a dot
|
|
66
|
-
#: (so not a member name) and not followed by a dot (so not a struct reference).
|
|
67
|
-
_ROOT_IDENT_RE = re.compile(r"(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)\b(?!\s*\.)")
|
|
68
|
-
#: An assignment target at the start of a statement, optionally address-prefixed.
|
|
69
|
-
#: Retained for reference; statement-level extraction is done by
|
|
70
|
-
#: :func:`assignment_targets`, which tracks parenthesis depth.
|
|
71
|
-
_MEMBER_NAMES_RE = re.compile(r"\.\s*([A-Za-z_][A-Za-z0-9_]*)")
|
|
72
|
-
#: The root of a member access, e.g. ``Axis1`` in ``Axis1.AxisNum``. Deliberately
|
|
73
|
-
#: separate from _ROOT_IDENT_RE, which skips these.
|
|
74
|
-
_MEMBER_ROOT_RE = re.compile(r"(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)\s*\.")
|
|
75
|
-
_CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
76
|
-
_END_VAR_RE = re.compile(r"\bEND_VAR\b", re.IGNORECASE)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
@dataclass
|
|
80
|
-
class Finding:
|
|
81
|
-
"""One lint result."""
|
|
82
|
-
|
|
83
|
-
severity: str # "error" | "warning" | "info"
|
|
84
|
-
code: str
|
|
85
|
-
message: str
|
|
86
|
-
line: int | None = None
|
|
87
|
-
hint: str | None = None
|
|
88
|
-
|
|
89
|
-
def format(self) -> str:
|
|
90
|
-
location = f"line {self.line}: " if self.line else ""
|
|
91
|
-
text = f"[{self.severity.upper()}] {location}{self.message}"
|
|
92
|
-
if self.hint:
|
|
93
|
-
text += f"\n hint: {self.hint}"
|
|
94
|
-
return text
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
@dataclass
|
|
98
|
-
class LintResult:
|
|
99
|
-
findings: list[Finding] = field(default_factory=list)
|
|
100
|
-
used_symbols: set[str] = field(default_factory=set)
|
|
101
|
-
referenced_members: set[str] = field(default_factory=set)
|
|
102
|
-
|
|
103
|
-
@property
|
|
104
|
-
def errors(self) -> list[Finding]:
|
|
105
|
-
return [f for f in self.findings if f.severity == "error"]
|
|
106
|
-
|
|
107
|
-
@property
|
|
108
|
-
def warnings(self) -> list[Finding]:
|
|
109
|
-
return [f for f in self.findings if f.severity == "warning"]
|
|
110
|
-
|
|
111
|
-
@property
|
|
112
|
-
def ok(self) -> bool:
|
|
113
|
-
return not self.errors
|
|
114
|
-
|
|
115
|
-
def add(self, severity: str, code: str, message: str, line: int | None = None,
|
|
116
|
-
hint: str | None = None) -> None:
|
|
117
|
-
self.findings.append(
|
|
118
|
-
Finding(severity=severity, code=code, message=message, line=line, hint=hint)
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def strip_comments_and_strings(text: str) -> str:
|
|
123
|
-
"""Remove comments and string literals, preserving line structure.
|
|
124
|
-
|
|
125
|
-
Line count is preserved so reported line numbers stay accurate.
|
|
126
|
-
"""
|
|
127
|
-
|
|
128
|
-
def blank(match: re.Match[str]) -> str:
|
|
129
|
-
return re.sub(r"[^\n]", " ", match.group(0))
|
|
130
|
-
|
|
131
|
-
return _STRING_RE.sub(blank, _LINE_COMMENT_RE.sub(blank, _BLOCK_COMMENT_RE.sub(blank, text)))
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def split_code_region(body: str, clean: str) -> tuple[str, str, int]:
|
|
135
|
-
"""Split a POU into its declaration region and executable region.
|
|
136
|
-
|
|
137
|
-
Returns ``(declarations, code, code_start_line)``.
|
|
138
|
-
|
|
139
|
-
MotionWorks stores POU declarations in the separate ``<POU>V.VB`` stream,
|
|
140
|
-
so the ST body normally contains *only* executable code and there is no
|
|
141
|
-
``END_VAR`` to find. Some other IEC toolchains inline declarations before
|
|
142
|
-
the implementation; that form is handled too.
|
|
143
|
-
"""
|
|
144
|
-
matches = list(_END_VAR_RE.finditer(clean))
|
|
145
|
-
if not matches:
|
|
146
|
-
return "", clean, 1
|
|
147
|
-
last = matches[-1]
|
|
148
|
-
return clean[: last.start()], clean[last.end():], body.count("\n", 0, last.end()) + 1
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
#: A line that legitimately does not need a trailing semicolon because it opens
|
|
152
|
-
#: or closes a block.
|
|
153
|
-
_BLOCK_EDGE_RE = re.compile(
|
|
154
|
-
r"^(END_|ELSE\b|ELSIF\b|CASE\b|REPEAT$|UNTIL\b|TYPE\b|STRUCT\b|"
|
|
155
|
-
r"FOR\b|WHILE\b|IF\b)",
|
|
156
|
-
re.IGNORECASE,
|
|
157
|
-
)
|
|
158
|
-
#: A line whose ending means the statement carries on, so the missing ';' is
|
|
159
|
-
#: expected rather than a defect.
|
|
160
|
-
_CONTINUATION_TAIL_RE = re.compile(
|
|
161
|
-
r"(\:\=|[+\-*/(,=<>&|^]|\b(?:AND|OR|XOR|MOD|NOT|TO|BY))$",
|
|
162
|
-
re.IGNORECASE,
|
|
163
|
-
)
|
|
164
|
-
#: A line ending that completes a statement or a block header. The `:` branch
|
|
165
|
-
#: accepts any label-style colon, including a typed-literal CASE label such as
|
|
166
|
-
#: ``INT#10:``, while the lookahead excludes the `:` of `:=`, which marks an
|
|
167
|
-
#: assignment that continues onto the next line.
|
|
168
|
-
_TERMINAL_TAIL_RE = re.compile(
|
|
169
|
-
r"(;(?!\s*=)|:(?!\s*=)|\bTHEN$|\bDO$|\bREPEAT$|\bELSE$|"
|
|
170
|
-
r"\bEND_IF$|\bEND_CASE$|\bEND_WHILE$|\bEND_FOR$|\bEND_REPEAT$)",
|
|
171
|
-
re.IGNORECASE,
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def _next_significant_line(lines: list[str], offset: int) -> str:
|
|
176
|
-
"""Return the next non-blank line after ``offset``, or an empty string."""
|
|
177
|
-
for probe in lines[offset + 1:]:
|
|
178
|
-
text = probe.strip()
|
|
179
|
-
if text:
|
|
180
|
-
return text
|
|
181
|
-
return ""
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def unterminated_lines(code: str, start_line: int) -> list[tuple[int, str]]:
|
|
185
|
-
"""Find lines that look like they are missing a terminating ``;``.
|
|
186
|
-
|
|
187
|
-
MotionWorks ST deliberately spreads one statement over several lines::
|
|
188
|
-
|
|
189
|
-
EIP_ToCLX_CLXMasterHealthy :=
|
|
190
|
-
xCLXHeartbeatValidated
|
|
191
|
-
AND (NOT fbCLXHeartbeatTimeout.Q);
|
|
192
|
-
|
|
193
|
-
IF
|
|
194
|
-
xCLXMasterHealthyPrev
|
|
195
|
-
AND (NOT EIP_ToCLX_CLXMasterHealthy)
|
|
196
|
-
THEN
|
|
197
|
-
|
|
198
|
-
Only the *opening* line of such a statement could carry a semicolon, and
|
|
199
|
-
continuation lines never do. Detection therefore tracks whether the previous
|
|
200
|
-
line reached a terminal token (``;``, ``:``, ``THEN``, ``DO``, ``REPEAT``,
|
|
201
|
-
``ELSE``, or an ``END_*``):
|
|
202
|
-
|
|
203
|
-
* if it did, this line starts a fresh statement, so a missing terminator is a
|
|
204
|
-
real defect and the line is reported;
|
|
205
|
-
* if it did not, this line continues the previous statement and is skipped.
|
|
206
|
-
|
|
207
|
-
A statement is treated as continuing only when it *ends* with something that
|
|
208
|
-
cannot end a statement (an operator or a bare ``:=``), or when it sits inside
|
|
209
|
-
an unclosed parenthesis. Mid-line ``:=`` is deliberately not a continuation
|
|
210
|
-
signal, because ``X := value`` is a complete statement; treating it as open
|
|
211
|
-
would hide a genuinely dropped semicolon.
|
|
212
|
-
|
|
213
|
-
One special case is closed explicitly: a complete-looking assignment such as
|
|
214
|
-
``X := Y.Z`` immediately before a block edge (``IF``, ``END_IF``) must be
|
|
215
|
-
missing its semicolon, since an expression cannot continue into a block
|
|
216
|
-
keyword.
|
|
217
|
-
"""
|
|
218
|
-
findings: list[tuple[int, str]] = []
|
|
219
|
-
lines = code.splitlines()
|
|
220
|
-
depth = 0
|
|
221
|
-
previous_terminal = True # start of region behaves like a fresh statement
|
|
222
|
-
|
|
223
|
-
for offset, raw in enumerate(lines):
|
|
224
|
-
# Right-strip as well as strip: a line ending in ':=' followed by
|
|
225
|
-
# trailing whitespace would defeat the $-anchored checks below.
|
|
226
|
-
line = raw.strip().rstrip()
|
|
227
|
-
if not line:
|
|
228
|
-
# A blank line does not change whether we are mid-statement.
|
|
229
|
-
continue
|
|
230
|
-
|
|
231
|
-
depth_before = depth
|
|
232
|
-
depth += line.count("(") - line.count(")")
|
|
233
|
-
opens_group = depth_before != 0 or depth > 0
|
|
234
|
-
|
|
235
|
-
terminates = bool(_TERMINAL_TAIL_RE.search(line))
|
|
236
|
-
continues = bool(_CONTINUATION_TAIL_RE.search(line))
|
|
237
|
-
|
|
238
|
-
# `X := Y.Z` right before a block edge is a dropped semicolon.
|
|
239
|
-
if (
|
|
240
|
-
not terminates
|
|
241
|
-
and not continues
|
|
242
|
-
and not opens_group
|
|
243
|
-
and ":=" in line
|
|
244
|
-
and _BLOCK_EDGE_RE.match(_next_significant_line(lines, offset) or "")
|
|
245
|
-
):
|
|
246
|
-
findings.append((start_line + offset, line))
|
|
247
|
-
previous_terminal = True
|
|
248
|
-
continue
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
previous_terminal
|
|
252
|
-
and not terminates
|
|
253
|
-
and not opens_group
|
|
254
|
-
and not _BLOCK_EDGE_RE.match(line)
|
|
255
|
-
and not continues
|
|
256
|
-
):
|
|
257
|
-
findings.append((start_line + offset, line))
|
|
258
|
-
|
|
259
|
-
# `continues` means the statement is still open, so the next line is a
|
|
260
|
-
# continuation and must NOT be treated as starting a fresh statement.
|
|
261
|
-
# `opens_group` likewise leaves the statement open.
|
|
262
|
-
previous_terminal = (not continues) and (not opens_group) and terminates
|
|
263
|
-
|
|
264
|
-
return findings
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def _line_of(text: str, index: int) -> int:
|
|
268
|
-
return text.count("\n", 0, index) + 1
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def assignment_targets(code: str) -> list[tuple[int, str]]:
|
|
272
|
-
"""Find ``Name := `` at *statement* level, ignoring named FB parameters.
|
|
273
|
-
|
|
274
|
-
MotionWorks function blocks are called with named parameters::
|
|
275
|
-
|
|
276
|
-
MC_Power_1(Enable := TRUE, Axis := Axis1);
|
|
277
|
-
|
|
278
|
-
That ``Enable := TRUE`` sits inside parentheses and is an input pin, not a
|
|
279
|
-
variable assignment. Reporting it as an undefined symbol is exactly the
|
|
280
|
-
false positive that makes a linter useless, so only assignments at paren
|
|
281
|
-
depth zero are treated as statement targets.
|
|
282
|
-
"""
|
|
283
|
-
results: list[tuple[int, str]] = []
|
|
284
|
-
i = 0
|
|
285
|
-
length = len(code)
|
|
286
|
-
while i < length:
|
|
287
|
-
char = code[i]
|
|
288
|
-
if char == "(":
|
|
289
|
-
depth = 1
|
|
290
|
-
i += 1
|
|
291
|
-
# Skip the entire parenthesised group, including nested ones.
|
|
292
|
-
while i < length and depth:
|
|
293
|
-
if code[i] == "(":
|
|
294
|
-
depth += 1
|
|
295
|
-
elif code[i] == ")":
|
|
296
|
-
depth -= 1
|
|
297
|
-
i += 1
|
|
298
|
-
continue
|
|
299
|
-
if char == ":" and i + 1 < length and code[i + 1] == "=":
|
|
300
|
-
# Walk back over the optional lvalue to its first identifier.
|
|
301
|
-
j = i - 1
|
|
302
|
-
while j >= 0 and code[j] in " \t":
|
|
303
|
-
j -= 1
|
|
304
|
-
# Allow an array index on the lvalue.
|
|
305
|
-
if j >= 0 and code[j] == "]":
|
|
306
|
-
depth = 1
|
|
307
|
-
j -= 1
|
|
308
|
-
while j >= 0 and depth:
|
|
309
|
-
if code[j] == "]":
|
|
310
|
-
depth += 1
|
|
311
|
-
elif code[j] == "[":
|
|
312
|
-
depth -= 1
|
|
313
|
-
j -= 1
|
|
314
|
-
while j >= 0 and code[j] in " \t":
|
|
315
|
-
j -= 1
|
|
316
|
-
end = j
|
|
317
|
-
while j >= 0 and (code[j].isalnum() or code[j] == "_"):
|
|
318
|
-
j -= 1
|
|
319
|
-
start = j + 1
|
|
320
|
-
name = code[start : end + 1]
|
|
321
|
-
if name and (name[0].isalpha() or name[0] == "_"):
|
|
322
|
-
results.append((start, name))
|
|
323
|
-
i += 2
|
|
324
|
-
continue
|
|
325
|
-
i += 1
|
|
326
|
-
return results
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def lint(
|
|
330
|
-
body: str,
|
|
331
|
-
declared: set[str] | None = None,
|
|
332
|
-
known_symbols: set[str] | None = None,
|
|
333
|
-
fb_instance_types: set[str] | None = None,
|
|
334
|
-
local_vars: set[str] | None = None,
|
|
335
|
-
project_globals: set[str] | None = None,
|
|
336
|
-
) -> LintResult:
|
|
337
|
-
"""Lint one Structured Text body.
|
|
338
|
-
|
|
339
|
-
Args:
|
|
340
|
-
body: Raw ST source.
|
|
341
|
-
declared: Every name reachable in this POU (locals plus externals).
|
|
342
|
-
known_symbols: Project globals plus every type/library name that could
|
|
343
|
-
legitimately appear. Names here are never reported.
|
|
344
|
-
fb_instance_types: Names of declared function-block instances.
|
|
345
|
-
local_vars: Names declared in a plain ``VAR`` block, used for the
|
|
346
|
-
unused-local check. Externals are excluded: they are owned
|
|
347
|
-
elsewhere and may legitimately be unused here.
|
|
348
|
-
project_globals: Names of the project's VAR_GLOBAL declarations. A
|
|
349
|
-
global is reachable ONLY if this POU declares it as VAR_EXTERNAL,
|
|
350
|
-
with matching name and type. Compiler behavior depends on context.
|
|
351
|
-
"""
|
|
352
|
-
result = LintResult()
|
|
353
|
-
declared_u = {n.upper() for n in (declared or set())}
|
|
354
|
-
known_u = {n.upper() for n in (known_symbols or set())}
|
|
355
|
-
fb_instances = {n.upper() for n in (fb_instance_types or set())}
|
|
356
|
-
# Preserve original casing so messages quote the declared name, not an
|
|
357
|
-
# upper-cased comparison key.
|
|
358
|
-
declared_display = {n.upper(): n for n in (local_vars or set())}
|
|
359
|
-
locals_u = set(declared_display)
|
|
360
|
-
globals_u = {n.upper() for n in (project_globals or set())}
|
|
361
|
-
|
|
362
|
-
clean = strip_comments_and_strings(body)
|
|
363
|
-
if not clean.strip():
|
|
364
|
-
return result
|
|
365
|
-
|
|
366
|
-
_declarations, code, code_start = split_code_region(body, clean)
|
|
367
|
-
if not code.strip():
|
|
368
|
-
return result
|
|
369
|
-
|
|
370
|
-
# Member names (`.Done`, `.AxisNum`) are never symbols of this POU.
|
|
371
|
-
members = {m.group(1).upper() for m in _MEMBER_NAMES_RE.finditer(code)}
|
|
372
|
-
result.referenced_members = members
|
|
373
|
-
|
|
374
|
-
# Every root identifier in the executable region: a name that is neither
|
|
375
|
-
# preceded by a dot (so not a member) nor followed by a dot (so not a struct
|
|
376
|
-
# access).
|
|
377
|
-
roots = {m.group(1) for m in _ROOT_IDENT_RE.finditer(code)}
|
|
378
|
-
# Roots of member accesses, e.g. Axis1 in `Axis1.AxisNum`. These ARE uses of
|
|
379
|
-
# the variable, even though the root regex deliberately skips them.
|
|
380
|
-
member_roots = {m.group(1) for m in _MEMBER_ROOT_RE.finditer(code)}
|
|
381
|
-
invoked = {m.group(1).upper() for m in _CALL_RE.finditer(code)}
|
|
382
|
-
# A name used in any of these ways counts as used.
|
|
383
|
-
used = {r.upper() for r in roots} | {r.upper() for r in member_roots} | invoked
|
|
384
|
-
result.used_symbols = roots | member_roots
|
|
385
|
-
|
|
386
|
-
# ------------------------------------------------------------------
|
|
387
|
-
# High-confidence check: assignment targets that resolve to nothing.
|
|
388
|
-
# ------------------------------------------------------------------
|
|
389
|
-
for start, name in assignment_targets(code):
|
|
390
|
-
upper = name.upper()
|
|
391
|
-
if upper in KEYWORDS or upper in declared_u:
|
|
392
|
-
continue
|
|
393
|
-
if upper in globals_u:
|
|
394
|
-
# THE SILENT STALL. This name exists - it is a project global - so the
|
|
395
|
-
# old check let it through via known_u. But MotionWorks only reaches a
|
|
396
|
-
# global from a POU that declares it VAR_EXTERNAL, and when it does not
|
|
397
|
-
# it does not report an undeclared symbol: the build STALLS with
|
|
398
|
-
# is_compiled=false, is_modified=true and an EMPTY Errors pane, so the
|
|
399
|
-
# agent sees a hang and no reason. Measured with PLCMODE_ON, a system
|
|
400
|
-
# global present in the project from the start.
|
|
401
|
-
line = code_start + code.count("\n", 0, start)
|
|
402
|
-
result.add(
|
|
403
|
-
"error",
|
|
404
|
-
"global-not-declared-external",
|
|
405
|
-
f"{name!r} is a project global but this POU does not declare it as "
|
|
406
|
-
f"VAR_EXTERNAL with a matching resource-global name and type",
|
|
407
|
-
line,
|
|
408
|
-
hint=(
|
|
409
|
-
"Add it to this POU's VAR_EXTERNAL block, or use a variable this "
|
|
410
|
-
"POU already declares. Inspect mw_code_read_st and mw_code_globals; "
|
|
411
|
-
"use mw_code_reference for VAR_EXTERNAL scope guidance."
|
|
412
|
-
),
|
|
413
|
-
)
|
|
414
|
-
continue
|
|
415
|
-
if upper in known_u:
|
|
416
|
-
continue
|
|
417
|
-
if upper in ELEMENTARY_TYPES or upper in members:
|
|
418
|
-
continue
|
|
419
|
-
line = code_start + code.count("\n", 0, start)
|
|
420
|
-
result.add(
|
|
421
|
-
"error",
|
|
422
|
-
"undefined-assignment-target",
|
|
423
|
-
f"assignment to {name!r}, which is not declared in this POU, in the "
|
|
424
|
-
f"project's globals, or as a known library name",
|
|
425
|
-
line,
|
|
426
|
-
hint=(
|
|
1
|
+
"""Static checks for MotionWorks IEC Structured Text bodies.
|
|
2
|
+
|
|
3
|
+
This linter is deliberately conservative. It reports only *high-confidence*
|
|
4
|
+
problems and stays silent whenever the answer depends on information it cannot
|
|
5
|
+
see: library types, function-block pin diagrams, struct member layouts, device
|
|
6
|
+
I/O configuration, and the internals of other POUs.
|
|
7
|
+
|
|
8
|
+
That restraint is the whole design. An earlier revision flagged every member
|
|
9
|
+
name and function-block pin (``Axis1.AxisNum``, ``MC.Power``'s ``.Execute``,
|
|
10
|
+
``.Done``, ``.Error``) as an undefined symbol, which produced hundreds of
|
|
11
|
+
findings on correct production code. A noisy linter is worse than none, because
|
|
12
|
+
an agent will either ignore it or start "fixing" working logic.
|
|
13
|
+
|
|
14
|
+
Checks performed:
|
|
15
|
+
|
|
16
|
+
``undefined-assignment-target``
|
|
17
|
+
An assignment target (``Name := ...``) is declared nowhere reachable. This
|
|
18
|
+
is the highest-value check: it is exactly the mistake an agent makes when it
|
|
19
|
+
invents a variable name, and an assignment target is never a member access
|
|
20
|
+
unless written as ``Struct.Member := ...``, which is excluded.
|
|
21
|
+
|
|
22
|
+
``unused-local``
|
|
23
|
+
A plain ``VAR`` local is never referenced in the executable region.
|
|
24
|
+
|
|
25
|
+
``unassigned-fb-output``
|
|
26
|
+
A function-block instance is called but none of its outputs is read, so the
|
|
27
|
+
result is discarded. Reported only when the instance has no output-style
|
|
28
|
+
member access at all.
|
|
29
|
+
|
|
30
|
+
``unterminated-statement``
|
|
31
|
+
A logical statement does not end with ``;``. Statements are joined across
|
|
32
|
+
continuation lines first, so multi-line assignments are not misreported.
|
|
33
|
+
|
|
34
|
+
Scope note: graphical (``.GB``) bodies are not text and cannot be analysed.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
|
|
42
|
+
#: IEC 61131-3 keywords and operators that must never be reported as symbols.
|
|
43
|
+
KEYWORDS = {
|
|
44
|
+
"IF", "THEN", "ELSIF", "ELSE", "END_IF", "CASE", "OF", "END_CASE",
|
|
45
|
+
"FOR", "TO", "BY", "DO", "END_FOR", "WHILE", "END_WHILE", "REPEAT",
|
|
46
|
+
"UNTIL", "END_REPEAT", "RETURN", "EXIT", "CONTINUE", "VAR", "VAR_INPUT",
|
|
47
|
+
"VAR_OUTPUT", "VAR_IN_OUT", "VAR_GLOBAL", "VAR_EXTERNAL", "VAR_TEMP",
|
|
48
|
+
"END_VAR", "PROGRAM", "FUNCTION", "FUNCTION_BLOCK", "END_PROGRAM",
|
|
49
|
+
"END_FUNCTION", "END_FUNCTION_BLOCK", "TYPE", "END_TYPE", "STRUCT",
|
|
50
|
+
"END_STRUCT", "AT", "RETAIN", "NON_RETAIN", "CONSTANT", "ARRAY", "AND",
|
|
51
|
+
"OR", "XOR", "NOT", "MOD", "TRUE", "FALSE", "NULL",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ELEMENTARY_TYPES = {
|
|
55
|
+
"BOOL", "BYTE", "WORD", "DWORD", "LWORD", "SINT", "USINT", "INT", "UINT",
|
|
56
|
+
"DINT", "UDINT", "LINT", "ULINT", "REAL", "LREAL", "STRING", "WSTRING",
|
|
57
|
+
"TIME", "LTIME", "DATE", "LDATE", "TOD", "LTOD", "DT", "LDT",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
61
|
+
_BLOCK_COMMENT_RE = re.compile(r"\(\*.*?\*\)", re.DOTALL)
|
|
62
|
+
_LINE_COMMENT_RE = re.compile(r"//[^\n]*")
|
|
63
|
+
_STRING_RE = re.compile(r"'[^'\n]*'")
|
|
64
|
+
_TYPED_LITERAL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)#")
|
|
65
|
+
#: Identifiers that are the *root* of an expression: not preceded by a dot
|
|
66
|
+
#: (so not a member name) and not followed by a dot (so not a struct reference).
|
|
67
|
+
_ROOT_IDENT_RE = re.compile(r"(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)\b(?!\s*\.)")
|
|
68
|
+
#: An assignment target at the start of a statement, optionally address-prefixed.
|
|
69
|
+
#: Retained for reference; statement-level extraction is done by
|
|
70
|
+
#: :func:`assignment_targets`, which tracks parenthesis depth.
|
|
71
|
+
_MEMBER_NAMES_RE = re.compile(r"\.\s*([A-Za-z_][A-Za-z0-9_]*)")
|
|
72
|
+
#: The root of a member access, e.g. ``Axis1`` in ``Axis1.AxisNum``. Deliberately
|
|
73
|
+
#: separate from _ROOT_IDENT_RE, which skips these.
|
|
74
|
+
_MEMBER_ROOT_RE = re.compile(r"(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)\s*\.")
|
|
75
|
+
_CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
76
|
+
_END_VAR_RE = re.compile(r"\bEND_VAR\b", re.IGNORECASE)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Finding:
|
|
81
|
+
"""One lint result."""
|
|
82
|
+
|
|
83
|
+
severity: str # "error" | "warning" | "info"
|
|
84
|
+
code: str
|
|
85
|
+
message: str
|
|
86
|
+
line: int | None = None
|
|
87
|
+
hint: str | None = None
|
|
88
|
+
|
|
89
|
+
def format(self) -> str:
|
|
90
|
+
location = f"line {self.line}: " if self.line else ""
|
|
91
|
+
text = f"[{self.severity.upper()}] {location}{self.message}"
|
|
92
|
+
if self.hint:
|
|
93
|
+
text += f"\n hint: {self.hint}"
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class LintResult:
|
|
99
|
+
findings: list[Finding] = field(default_factory=list)
|
|
100
|
+
used_symbols: set[str] = field(default_factory=set)
|
|
101
|
+
referenced_members: set[str] = field(default_factory=set)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def errors(self) -> list[Finding]:
|
|
105
|
+
return [f for f in self.findings if f.severity == "error"]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def warnings(self) -> list[Finding]:
|
|
109
|
+
return [f for f in self.findings if f.severity == "warning"]
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def ok(self) -> bool:
|
|
113
|
+
return not self.errors
|
|
114
|
+
|
|
115
|
+
def add(self, severity: str, code: str, message: str, line: int | None = None,
|
|
116
|
+
hint: str | None = None) -> None:
|
|
117
|
+
self.findings.append(
|
|
118
|
+
Finding(severity=severity, code=code, message=message, line=line, hint=hint)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def strip_comments_and_strings(text: str) -> str:
|
|
123
|
+
"""Remove comments and string literals, preserving line structure.
|
|
124
|
+
|
|
125
|
+
Line count is preserved so reported line numbers stay accurate.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def blank(match: re.Match[str]) -> str:
|
|
129
|
+
return re.sub(r"[^\n]", " ", match.group(0))
|
|
130
|
+
|
|
131
|
+
return _STRING_RE.sub(blank, _LINE_COMMENT_RE.sub(blank, _BLOCK_COMMENT_RE.sub(blank, text)))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def split_code_region(body: str, clean: str) -> tuple[str, str, int]:
|
|
135
|
+
"""Split a POU into its declaration region and executable region.
|
|
136
|
+
|
|
137
|
+
Returns ``(declarations, code, code_start_line)``.
|
|
138
|
+
|
|
139
|
+
MotionWorks stores POU declarations in the separate ``<POU>V.VB`` stream,
|
|
140
|
+
so the ST body normally contains *only* executable code and there is no
|
|
141
|
+
``END_VAR`` to find. Some other IEC toolchains inline declarations before
|
|
142
|
+
the implementation; that form is handled too.
|
|
143
|
+
"""
|
|
144
|
+
matches = list(_END_VAR_RE.finditer(clean))
|
|
145
|
+
if not matches:
|
|
146
|
+
return "", clean, 1
|
|
147
|
+
last = matches[-1]
|
|
148
|
+
return clean[: last.start()], clean[last.end():], body.count("\n", 0, last.end()) + 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
#: A line that legitimately does not need a trailing semicolon because it opens
|
|
152
|
+
#: or closes a block.
|
|
153
|
+
_BLOCK_EDGE_RE = re.compile(
|
|
154
|
+
r"^(END_|ELSE\b|ELSIF\b|CASE\b|REPEAT$|UNTIL\b|TYPE\b|STRUCT\b|"
|
|
155
|
+
r"FOR\b|WHILE\b|IF\b)",
|
|
156
|
+
re.IGNORECASE,
|
|
157
|
+
)
|
|
158
|
+
#: A line whose ending means the statement carries on, so the missing ';' is
|
|
159
|
+
#: expected rather than a defect.
|
|
160
|
+
_CONTINUATION_TAIL_RE = re.compile(
|
|
161
|
+
r"(\:\=|[+\-*/(,=<>&|^]|\b(?:AND|OR|XOR|MOD|NOT|TO|BY))$",
|
|
162
|
+
re.IGNORECASE,
|
|
163
|
+
)
|
|
164
|
+
#: A line ending that completes a statement or a block header. The `:` branch
|
|
165
|
+
#: accepts any label-style colon, including a typed-literal CASE label such as
|
|
166
|
+
#: ``INT#10:``, while the lookahead excludes the `:` of `:=`, which marks an
|
|
167
|
+
#: assignment that continues onto the next line.
|
|
168
|
+
_TERMINAL_TAIL_RE = re.compile(
|
|
169
|
+
r"(;(?!\s*=)|:(?!\s*=)|\bTHEN$|\bDO$|\bREPEAT$|\bELSE$|"
|
|
170
|
+
r"\bEND_IF$|\bEND_CASE$|\bEND_WHILE$|\bEND_FOR$|\bEND_REPEAT$)",
|
|
171
|
+
re.IGNORECASE,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _next_significant_line(lines: list[str], offset: int) -> str:
|
|
176
|
+
"""Return the next non-blank line after ``offset``, or an empty string."""
|
|
177
|
+
for probe in lines[offset + 1:]:
|
|
178
|
+
text = probe.strip()
|
|
179
|
+
if text:
|
|
180
|
+
return text
|
|
181
|
+
return ""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def unterminated_lines(code: str, start_line: int) -> list[tuple[int, str]]:
|
|
185
|
+
"""Find lines that look like they are missing a terminating ``;``.
|
|
186
|
+
|
|
187
|
+
MotionWorks ST deliberately spreads one statement over several lines::
|
|
188
|
+
|
|
189
|
+
EIP_ToCLX_CLXMasterHealthy :=
|
|
190
|
+
xCLXHeartbeatValidated
|
|
191
|
+
AND (NOT fbCLXHeartbeatTimeout.Q);
|
|
192
|
+
|
|
193
|
+
IF
|
|
194
|
+
xCLXMasterHealthyPrev
|
|
195
|
+
AND (NOT EIP_ToCLX_CLXMasterHealthy)
|
|
196
|
+
THEN
|
|
197
|
+
|
|
198
|
+
Only the *opening* line of such a statement could carry a semicolon, and
|
|
199
|
+
continuation lines never do. Detection therefore tracks whether the previous
|
|
200
|
+
line reached a terminal token (``;``, ``:``, ``THEN``, ``DO``, ``REPEAT``,
|
|
201
|
+
``ELSE``, or an ``END_*``):
|
|
202
|
+
|
|
203
|
+
* if it did, this line starts a fresh statement, so a missing terminator is a
|
|
204
|
+
real defect and the line is reported;
|
|
205
|
+
* if it did not, this line continues the previous statement and is skipped.
|
|
206
|
+
|
|
207
|
+
A statement is treated as continuing only when it *ends* with something that
|
|
208
|
+
cannot end a statement (an operator or a bare ``:=``), or when it sits inside
|
|
209
|
+
an unclosed parenthesis. Mid-line ``:=`` is deliberately not a continuation
|
|
210
|
+
signal, because ``X := value`` is a complete statement; treating it as open
|
|
211
|
+
would hide a genuinely dropped semicolon.
|
|
212
|
+
|
|
213
|
+
One special case is closed explicitly: a complete-looking assignment such as
|
|
214
|
+
``X := Y.Z`` immediately before a block edge (``IF``, ``END_IF``) must be
|
|
215
|
+
missing its semicolon, since an expression cannot continue into a block
|
|
216
|
+
keyword.
|
|
217
|
+
"""
|
|
218
|
+
findings: list[tuple[int, str]] = []
|
|
219
|
+
lines = code.splitlines()
|
|
220
|
+
depth = 0
|
|
221
|
+
previous_terminal = True # start of region behaves like a fresh statement
|
|
222
|
+
|
|
223
|
+
for offset, raw in enumerate(lines):
|
|
224
|
+
# Right-strip as well as strip: a line ending in ':=' followed by
|
|
225
|
+
# trailing whitespace would defeat the $-anchored checks below.
|
|
226
|
+
line = raw.strip().rstrip()
|
|
227
|
+
if not line:
|
|
228
|
+
# A blank line does not change whether we are mid-statement.
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
depth_before = depth
|
|
232
|
+
depth += line.count("(") - line.count(")")
|
|
233
|
+
opens_group = depth_before != 0 or depth > 0
|
|
234
|
+
|
|
235
|
+
terminates = bool(_TERMINAL_TAIL_RE.search(line))
|
|
236
|
+
continues = bool(_CONTINUATION_TAIL_RE.search(line))
|
|
237
|
+
|
|
238
|
+
# `X := Y.Z` right before a block edge is a dropped semicolon.
|
|
239
|
+
if (
|
|
240
|
+
not terminates
|
|
241
|
+
and not continues
|
|
242
|
+
and not opens_group
|
|
243
|
+
and ":=" in line
|
|
244
|
+
and _BLOCK_EDGE_RE.match(_next_significant_line(lines, offset) or "")
|
|
245
|
+
):
|
|
246
|
+
findings.append((start_line + offset, line))
|
|
247
|
+
previous_terminal = True
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
if (
|
|
251
|
+
previous_terminal
|
|
252
|
+
and not terminates
|
|
253
|
+
and not opens_group
|
|
254
|
+
and not _BLOCK_EDGE_RE.match(line)
|
|
255
|
+
and not continues
|
|
256
|
+
):
|
|
257
|
+
findings.append((start_line + offset, line))
|
|
258
|
+
|
|
259
|
+
# `continues` means the statement is still open, so the next line is a
|
|
260
|
+
# continuation and must NOT be treated as starting a fresh statement.
|
|
261
|
+
# `opens_group` likewise leaves the statement open.
|
|
262
|
+
previous_terminal = (not continues) and (not opens_group) and terminates
|
|
263
|
+
|
|
264
|
+
return findings
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _line_of(text: str, index: int) -> int:
|
|
268
|
+
return text.count("\n", 0, index) + 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def assignment_targets(code: str) -> list[tuple[int, str]]:
|
|
272
|
+
"""Find ``Name := `` at *statement* level, ignoring named FB parameters.
|
|
273
|
+
|
|
274
|
+
MotionWorks function blocks are called with named parameters::
|
|
275
|
+
|
|
276
|
+
MC_Power_1(Enable := TRUE, Axis := Axis1);
|
|
277
|
+
|
|
278
|
+
That ``Enable := TRUE`` sits inside parentheses and is an input pin, not a
|
|
279
|
+
variable assignment. Reporting it as an undefined symbol is exactly the
|
|
280
|
+
false positive that makes a linter useless, so only assignments at paren
|
|
281
|
+
depth zero are treated as statement targets.
|
|
282
|
+
"""
|
|
283
|
+
results: list[tuple[int, str]] = []
|
|
284
|
+
i = 0
|
|
285
|
+
length = len(code)
|
|
286
|
+
while i < length:
|
|
287
|
+
char = code[i]
|
|
288
|
+
if char == "(":
|
|
289
|
+
depth = 1
|
|
290
|
+
i += 1
|
|
291
|
+
# Skip the entire parenthesised group, including nested ones.
|
|
292
|
+
while i < length and depth:
|
|
293
|
+
if code[i] == "(":
|
|
294
|
+
depth += 1
|
|
295
|
+
elif code[i] == ")":
|
|
296
|
+
depth -= 1
|
|
297
|
+
i += 1
|
|
298
|
+
continue
|
|
299
|
+
if char == ":" and i + 1 < length and code[i + 1] == "=":
|
|
300
|
+
# Walk back over the optional lvalue to its first identifier.
|
|
301
|
+
j = i - 1
|
|
302
|
+
while j >= 0 and code[j] in " \t":
|
|
303
|
+
j -= 1
|
|
304
|
+
# Allow an array index on the lvalue.
|
|
305
|
+
if j >= 0 and code[j] == "]":
|
|
306
|
+
depth = 1
|
|
307
|
+
j -= 1
|
|
308
|
+
while j >= 0 and depth:
|
|
309
|
+
if code[j] == "]":
|
|
310
|
+
depth += 1
|
|
311
|
+
elif code[j] == "[":
|
|
312
|
+
depth -= 1
|
|
313
|
+
j -= 1
|
|
314
|
+
while j >= 0 and code[j] in " \t":
|
|
315
|
+
j -= 1
|
|
316
|
+
end = j
|
|
317
|
+
while j >= 0 and (code[j].isalnum() or code[j] == "_"):
|
|
318
|
+
j -= 1
|
|
319
|
+
start = j + 1
|
|
320
|
+
name = code[start : end + 1]
|
|
321
|
+
if name and (name[0].isalpha() or name[0] == "_"):
|
|
322
|
+
results.append((start, name))
|
|
323
|
+
i += 2
|
|
324
|
+
continue
|
|
325
|
+
i += 1
|
|
326
|
+
return results
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def lint(
|
|
330
|
+
body: str,
|
|
331
|
+
declared: set[str] | None = None,
|
|
332
|
+
known_symbols: set[str] | None = None,
|
|
333
|
+
fb_instance_types: set[str] | None = None,
|
|
334
|
+
local_vars: set[str] | None = None,
|
|
335
|
+
project_globals: set[str] | None = None,
|
|
336
|
+
) -> LintResult:
|
|
337
|
+
"""Lint one Structured Text body.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
body: Raw ST source.
|
|
341
|
+
declared: Every name reachable in this POU (locals plus externals).
|
|
342
|
+
known_symbols: Project globals plus every type/library name that could
|
|
343
|
+
legitimately appear. Names here are never reported.
|
|
344
|
+
fb_instance_types: Names of declared function-block instances.
|
|
345
|
+
local_vars: Names declared in a plain ``VAR`` block, used for the
|
|
346
|
+
unused-local check. Externals are excluded: they are owned
|
|
347
|
+
elsewhere and may legitimately be unused here.
|
|
348
|
+
project_globals: Names of the project's VAR_GLOBAL declarations. A
|
|
349
|
+
global is reachable ONLY if this POU declares it as VAR_EXTERNAL,
|
|
350
|
+
with matching name and type. Compiler behavior depends on context.
|
|
351
|
+
"""
|
|
352
|
+
result = LintResult()
|
|
353
|
+
declared_u = {n.upper() for n in (declared or set())}
|
|
354
|
+
known_u = {n.upper() for n in (known_symbols or set())}
|
|
355
|
+
fb_instances = {n.upper() for n in (fb_instance_types or set())}
|
|
356
|
+
# Preserve original casing so messages quote the declared name, not an
|
|
357
|
+
# upper-cased comparison key.
|
|
358
|
+
declared_display = {n.upper(): n for n in (local_vars or set())}
|
|
359
|
+
locals_u = set(declared_display)
|
|
360
|
+
globals_u = {n.upper() for n in (project_globals or set())}
|
|
361
|
+
|
|
362
|
+
clean = strip_comments_and_strings(body)
|
|
363
|
+
if not clean.strip():
|
|
364
|
+
return result
|
|
365
|
+
|
|
366
|
+
_declarations, code, code_start = split_code_region(body, clean)
|
|
367
|
+
if not code.strip():
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
# Member names (`.Done`, `.AxisNum`) are never symbols of this POU.
|
|
371
|
+
members = {m.group(1).upper() for m in _MEMBER_NAMES_RE.finditer(code)}
|
|
372
|
+
result.referenced_members = members
|
|
373
|
+
|
|
374
|
+
# Every root identifier in the executable region: a name that is neither
|
|
375
|
+
# preceded by a dot (so not a member) nor followed by a dot (so not a struct
|
|
376
|
+
# access).
|
|
377
|
+
roots = {m.group(1) for m in _ROOT_IDENT_RE.finditer(code)}
|
|
378
|
+
# Roots of member accesses, e.g. Axis1 in `Axis1.AxisNum`. These ARE uses of
|
|
379
|
+
# the variable, even though the root regex deliberately skips them.
|
|
380
|
+
member_roots = {m.group(1) for m in _MEMBER_ROOT_RE.finditer(code)}
|
|
381
|
+
invoked = {m.group(1).upper() for m in _CALL_RE.finditer(code)}
|
|
382
|
+
# A name used in any of these ways counts as used.
|
|
383
|
+
used = {r.upper() for r in roots} | {r.upper() for r in member_roots} | invoked
|
|
384
|
+
result.used_symbols = roots | member_roots
|
|
385
|
+
|
|
386
|
+
# ------------------------------------------------------------------
|
|
387
|
+
# High-confidence check: assignment targets that resolve to nothing.
|
|
388
|
+
# ------------------------------------------------------------------
|
|
389
|
+
for start, name in assignment_targets(code):
|
|
390
|
+
upper = name.upper()
|
|
391
|
+
if upper in KEYWORDS or upper in declared_u:
|
|
392
|
+
continue
|
|
393
|
+
if upper in globals_u:
|
|
394
|
+
# THE SILENT STALL. This name exists - it is a project global - so the
|
|
395
|
+
# old check let it through via known_u. But MotionWorks only reaches a
|
|
396
|
+
# global from a POU that declares it VAR_EXTERNAL, and when it does not
|
|
397
|
+
# it does not report an undeclared symbol: the build STALLS with
|
|
398
|
+
# is_compiled=false, is_modified=true and an EMPTY Errors pane, so the
|
|
399
|
+
# agent sees a hang and no reason. Measured with PLCMODE_ON, a system
|
|
400
|
+
# global present in the project from the start.
|
|
401
|
+
line = code_start + code.count("\n", 0, start)
|
|
402
|
+
result.add(
|
|
403
|
+
"error",
|
|
404
|
+
"global-not-declared-external",
|
|
405
|
+
f"{name!r} is a project global but this POU does not declare it as "
|
|
406
|
+
f"VAR_EXTERNAL with a matching resource-global name and type",
|
|
407
|
+
line,
|
|
408
|
+
hint=(
|
|
409
|
+
"Add it to this POU's VAR_EXTERNAL block, or use a variable this "
|
|
410
|
+
"POU already declares. Inspect mw_code_read_st and mw_code_globals; "
|
|
411
|
+
"use mw_code_reference for VAR_EXTERNAL scope guidance."
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
continue
|
|
415
|
+
if upper in known_u:
|
|
416
|
+
continue
|
|
417
|
+
if upper in ELEMENTARY_TYPES or upper in members:
|
|
418
|
+
continue
|
|
419
|
+
line = code_start + code.count("\n", 0, start)
|
|
420
|
+
result.add(
|
|
421
|
+
"error",
|
|
422
|
+
"undefined-assignment-target",
|
|
423
|
+
f"assignment to {name!r}, which is not declared in this POU, in the "
|
|
424
|
+
f"project's globals, or as a known library name",
|
|
425
|
+
line,
|
|
426
|
+
hint=(
|
|
427
427
|
"Check the spelling and intended scope. Shared resource data uses "
|
|
428
428
|
"VAR_GLOBAL plus a matching VAR_EXTERNAL in the consuming POU. "
|
|
429
429
|
"Another POU's plain VAR does not establish a resource global."
|
|
430
|
-
),
|
|
431
|
-
)
|
|
432
|
-
|
|
433
|
-
# ------------------------------------------------------------------
|
|
434
|
-
# The silent stall, for READS as well as writes.
|
|
435
|
-
#
|
|
436
|
-
# The assignment check above catches `X := ...`. Reading an undeclared global stalls
|
|
437
|
-
# the build just as hard - `IF PLCMODE_ON THEN` never finishes either - so the same
|
|
438
|
-
# rule has to be applied to every reference, not only to assignment targets.
|
|
439
|
-
# ------------------------------------------------------------------
|
|
440
|
-
reported = {f.line for f in result.findings if f.code == "global-not-declared-external"}
|
|
441
|
-
for name in sorted(used, key=str.casefold):
|
|
442
|
-
if name not in globals_u or name in declared_u:
|
|
443
|
-
continue
|
|
444
|
-
if name in KEYWORDS or name in fb_instances:
|
|
445
|
-
continue
|
|
446
|
-
line = code_start + code.count("\n", 0, code.upper().find(name))
|
|
447
|
-
if line in reported:
|
|
448
|
-
continue
|
|
449
|
-
reported.add(line)
|
|
450
|
-
result.add(
|
|
451
|
-
"error",
|
|
452
|
-
"global-not-declared-external",
|
|
453
|
-
f"{name} is a project global but this POU does not declare it as "
|
|
454
|
-
f"VAR_EXTERNAL with a matching resource-global name and type",
|
|
455
|
-
line,
|
|
456
|
-
hint=(
|
|
457
|
-
"Add it to this POU's VAR_EXTERNAL block, or use a variable this POU "
|
|
458
|
-
"already declares. Compare resource globals and exact types; "
|
|
459
|
-
"use mw_code_reference for VAR_EXTERNAL scope guidance."
|
|
460
|
-
),
|
|
461
|
-
)
|
|
462
|
-
|
|
463
|
-
# ------------------------------------------------------------------
|
|
464
|
-
# Unused locals.
|
|
465
|
-
# ------------------------------------------------------------------
|
|
466
|
-
for name in sorted(locals_u, key=str.casefold):
|
|
467
|
-
if name in used:
|
|
468
|
-
continue
|
|
469
|
-
result.add(
|
|
470
|
-
"warning",
|
|
471
|
-
"unused-local",
|
|
472
|
-
f"local variable {declared_display.get(name, name)!r} is declared but "
|
|
473
|
-
f"never used in the body",
|
|
474
|
-
None,
|
|
475
|
-
hint="Remove it, or confirm it is reserved intentionally.",
|
|
476
|
-
)
|
|
477
|
-
|
|
478
|
-
# ------------------------------------------------------------------
|
|
479
|
-
# Function-block instances whose outputs are all discarded.
|
|
480
|
-
# ------------------------------------------------------------------
|
|
481
|
-
for name in sorted(fb_instances, key=str.casefold):
|
|
482
|
-
pattern = re.compile(rf"(?<![.\w]){re.escape(name)}\s*\(", re.IGNORECASE)
|
|
483
|
-
if not pattern.search(code):
|
|
484
|
-
continue
|
|
485
|
-
# Any `Inst.something` read is treated as using the block's result.
|
|
486
|
-
reads = re.findall(
|
|
487
|
-
rf"(?<![.\w]){re.escape(name)}\s*\.\s*([A-Za-z_]\w*)", code, re.IGNORECASE
|
|
488
|
-
)
|
|
489
|
-
if not reads:
|
|
490
|
-
result.add(
|
|
491
|
-
"warning",
|
|
492
|
-
"unassigned-fb-output",
|
|
493
|
-
f"function block {name!r} is called but none of its outputs is read",
|
|
494
|
-
None,
|
|
495
|
-
hint=(
|
|
496
|
-
"MotionWorks blocks expose results as outputs such as .Done, "
|
|
497
|
-
".Busy and .Error. Discarding them is legal but often a mistake."
|
|
498
|
-
),
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
# ------------------------------------------------------------------
|
|
502
|
-
# Statements missing their terminator.
|
|
503
|
-
# ------------------------------------------------------------------
|
|
504
|
-
for line_no, statement in unterminated_lines(code, code_start):
|
|
505
|
-
result.add(
|
|
506
|
-
"warning",
|
|
507
|
-
"unterminated-statement",
|
|
508
|
-
f"line does not end with ';': {statement[:70]!r}",
|
|
509
|
-
line_no,
|
|
510
|
-
)
|
|
511
|
-
|
|
512
|
-
return result
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def lint_pou(pou: object, known_symbols: set[str] | None = None,
|
|
516
|
-
project_globals: set[str] | None = None) -> LintResult:
|
|
517
|
-
"""Lint a POU by combining its declarations with its body.
|
|
518
|
-
|
|
519
|
-
``pou`` is a :class:`motionworks_iec_mcp.project.PouInfo`. Typed loosely to
|
|
520
|
-
avoid a circular import.
|
|
521
|
-
"""
|
|
522
|
-
body = pou.st_body() # type: ignore[attr-defined]
|
|
523
|
-
if body is None:
|
|
524
|
-
result = LintResult()
|
|
525
|
-
result.add(
|
|
526
|
-
"info",
|
|
527
|
-
"not-structured-text",
|
|
528
|
-
"POU body is graphical (LD/FBD), not Structured Text; nothing to lint",
|
|
529
|
-
)
|
|
530
|
-
return result
|
|
531
|
-
|
|
532
|
-
try:
|
|
533
|
-
table = pou.declarations() # type: ignore[attr-defined]
|
|
534
|
-
except Exception as exc: # noqa: BLE001
|
|
535
|
-
result = LintResult()
|
|
536
|
-
result.add(
|
|
537
|
-
"warning",
|
|
538
|
-
"declarations-unreadable",
|
|
539
|
-
f"declarations could not be read, so results are limited: {exc}",
|
|
540
|
-
)
|
|
541
|
-
return result
|
|
542
|
-
|
|
543
|
-
declared = {v.name for v in table.variables}
|
|
544
|
-
locals_only = {v.name for v in table.variables if not v.is_external}
|
|
545
|
-
fb_types = {v.name for v in table.variables if v.is_fb_instance}
|
|
546
|
-
return lint(
|
|
547
|
-
body,
|
|
548
|
-
declared=declared,
|
|
549
|
-
known_symbols=known_symbols,
|
|
550
|
-
fb_instance_types=fb_types,
|
|
551
|
-
local_vars=locals_only,
|
|
552
|
-
project_globals=project_globals,
|
|
553
|
-
)
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
# ── assignment type checking ─────────────────────────────────────────────────────
|
|
557
|
-
#
|
|
558
|
-
# Measured: a body that assigns a UINT to a BOOL makes the builder destroy the POU - .VB to 0 bytes
|
|
559
|
-
# and the grid to 79 MB - where a type-correct body merely stalls and leaves the POU intact. A type
|
|
560
|
-
# error is therefore worth refusing here, before it can reach the compiler.
|
|
561
|
-
#
|
|
562
|
-
# Deliberately narrow. Anything whose type cannot be established confidently is ignored, because a
|
|
563
|
-
# false refusal blocks correct code and costs more than the check saves.
|
|
564
|
-
|
|
565
|
-
_INTEGER_TYPES = frozenset({
|
|
566
|
-
"SINT", "USINT", "INT", "UINT", "DINT", "UDINT", "LINT", "ULINT",
|
|
567
|
-
"BYTE", "WORD", "DWORD", "LWORD",
|
|
568
|
-
})
|
|
569
|
-
_REAL_TYPES = frozenset({"REAL", "LREAL"})
|
|
570
|
-
_BOOL_TYPES = frozenset({"BOOL"})
|
|
571
|
-
_STRING_TYPES = frozenset({"STRING", "WSTRING", "CHAR", "WCHAR"})
|
|
572
|
-
|
|
573
|
-
_ASSIGN_LINE = re.compile(
|
|
574
|
-
r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*(.+?)\s*;\s*$"
|
|
575
|
-
)
|
|
576
|
-
_COMPARISON = re.compile(r"<=|>=|<>|=|<|>")
|
|
577
|
-
_LIT_REAL = re.compile(r"^[+-]?\d+\.\d*([eE][+-]?\d+)?$|^[+-]?\d+[eE][+-]?\d+$")
|
|
578
|
-
_LIT_INT = re.compile(r"^[+-]?\d+$")
|
|
579
|
-
_LIT_BOOL = re.compile(r"^(TRUE|FALSE)$", re.IGNORECASE)
|
|
580
|
-
_LIT_STRING = re.compile(r"^'[^']*'$")
|
|
581
|
-
_KEYWORDS = frozenset({"TRUE", "FALSE", "NOT", "AND", "OR", "XOR", "MOD", "DIV", "AND_THEN",
|
|
582
|
-
"OR_ELSE"})
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
def elementary_family(type_name):
|
|
586
|
-
"""'integer', 'real', 'bool', 'string' - or None when the type is not an elementary one."""
|
|
587
|
-
t = (type_name or "").strip().upper()
|
|
588
|
-
if t in _INTEGER_TYPES:
|
|
589
|
-
return "integer"
|
|
590
|
-
if t in _REAL_TYPES:
|
|
591
|
-
return "real"
|
|
592
|
-
if t in _BOOL_TYPES:
|
|
593
|
-
return "bool"
|
|
594
|
-
if t in _STRING_TYPES:
|
|
595
|
-
return "string"
|
|
596
|
-
return None
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
def _strip_comments(text):
|
|
600
|
-
return re.sub(r"\(\*.*?\*\)", " ", text)
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
def _rhs_family(expr, types):
|
|
604
|
-
"""The family of a right-hand side, or None when it cannot be told confidently."""
|
|
605
|
-
e = _strip_comments(expr).strip()
|
|
606
|
-
while e.startswith("(") and e.endswith(")"):
|
|
607
|
-
e = e[1:-1].strip()
|
|
608
|
-
if not e:
|
|
609
|
-
return None
|
|
610
|
-
|
|
611
|
-
if _LIT_STRING.match(e):
|
|
612
|
-
return "string"
|
|
613
|
-
if _LIT_BOOL.match(e):
|
|
614
|
-
return "bool"
|
|
615
|
-
if _LIT_REAL.match(e):
|
|
616
|
-
return "real"
|
|
617
|
-
if _LIT_INT.match(e):
|
|
618
|
-
return "integer"
|
|
619
|
-
|
|
620
|
-
if re.match(r"^NOT\b", e, re.IGNORECASE):
|
|
621
|
-
return "bool"
|
|
622
|
-
if _COMPARISON.search(e):
|
|
623
|
-
head = _COMPARISON.split(e)[0].strip()
|
|
624
|
-
if head and not _LIT_INT.match(head) and not _LIT_REAL.match(head):
|
|
625
|
-
return "bool"
|
|
626
|
-
|
|
627
|
-
tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", e)
|
|
628
|
-
if not tokens:
|
|
629
|
-
return None
|
|
630
|
-
families = set()
|
|
631
|
-
for tok in tokens:
|
|
632
|
-
if tok.upper() in _KEYWORDS:
|
|
633
|
-
continue
|
|
634
|
-
declared = types.get(tok)
|
|
635
|
-
if declared is None:
|
|
636
|
-
return None # unknown to us - say nothing
|
|
637
|
-
fam = elementary_family(declared)
|
|
638
|
-
if fam is None:
|
|
639
|
-
return None # struct, FB instance, array - not our business
|
|
640
|
-
families.add(fam)
|
|
641
|
-
|
|
642
|
-
if len(families) == 1:
|
|
643
|
-
return families.pop()
|
|
644
|
-
if families == {"integer", "real"}:
|
|
645
|
-
return "real" # INTEGER widens to REAL; never the reverse
|
|
646
|
-
return None
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
def check_assignment_types(code, types):
|
|
650
|
-
"""Assignments whose right-hand side cannot fit the left.
|
|
651
|
-
|
|
652
|
-
``types`` maps a declared name to its type name. Returns a list of
|
|
653
|
-
``(line_number, target, declared_type, rhs_family)``.
|
|
654
|
-
"""
|
|
655
|
-
findings = []
|
|
656
|
-
if not types:
|
|
657
|
-
return findings
|
|
658
|
-
for number, line in enumerate(code.split("\n"), start=1):
|
|
659
|
-
clean = _strip_comments(line)
|
|
660
|
-
if ":=" not in clean:
|
|
661
|
-
continue
|
|
662
|
-
m = _ASSIGN_LINE.match(clean)
|
|
663
|
-
if not m:
|
|
664
|
-
continue
|
|
665
|
-
target, rhs = m.group(1), m.group(2)
|
|
666
|
-
declared = types.get(target)
|
|
667
|
-
if declared is None:
|
|
668
|
-
continue
|
|
669
|
-
want = elementary_family(declared)
|
|
670
|
-
got = _rhs_family(rhs, types)
|
|
671
|
-
if want is None or got is None or want == got:
|
|
672
|
-
continue
|
|
673
|
-
if want == "real" and got == "integer":
|
|
674
|
-
continue # a widening the compiler accepts
|
|
675
|
-
findings.append((number, target, declared, got))
|
|
676
|
-
return findings
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
# The silent stall, for READS as well as writes.
|
|
435
|
+
#
|
|
436
|
+
# The assignment check above catches `X := ...`. Reading an undeclared global stalls
|
|
437
|
+
# the build just as hard - `IF PLCMODE_ON THEN` never finishes either - so the same
|
|
438
|
+
# rule has to be applied to every reference, not only to assignment targets.
|
|
439
|
+
# ------------------------------------------------------------------
|
|
440
|
+
reported = {f.line for f in result.findings if f.code == "global-not-declared-external"}
|
|
441
|
+
for name in sorted(used, key=str.casefold):
|
|
442
|
+
if name not in globals_u or name in declared_u:
|
|
443
|
+
continue
|
|
444
|
+
if name in KEYWORDS or name in fb_instances:
|
|
445
|
+
continue
|
|
446
|
+
line = code_start + code.count("\n", 0, code.upper().find(name))
|
|
447
|
+
if line in reported:
|
|
448
|
+
continue
|
|
449
|
+
reported.add(line)
|
|
450
|
+
result.add(
|
|
451
|
+
"error",
|
|
452
|
+
"global-not-declared-external",
|
|
453
|
+
f"{name} is a project global but this POU does not declare it as "
|
|
454
|
+
f"VAR_EXTERNAL with a matching resource-global name and type",
|
|
455
|
+
line,
|
|
456
|
+
hint=(
|
|
457
|
+
"Add it to this POU's VAR_EXTERNAL block, or use a variable this POU "
|
|
458
|
+
"already declares. Compare resource globals and exact types; "
|
|
459
|
+
"use mw_code_reference for VAR_EXTERNAL scope guidance."
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
# Unused locals.
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
for name in sorted(locals_u, key=str.casefold):
|
|
467
|
+
if name in used:
|
|
468
|
+
continue
|
|
469
|
+
result.add(
|
|
470
|
+
"warning",
|
|
471
|
+
"unused-local",
|
|
472
|
+
f"local variable {declared_display.get(name, name)!r} is declared but "
|
|
473
|
+
f"never used in the body",
|
|
474
|
+
None,
|
|
475
|
+
hint="Remove it, or confirm it is reserved intentionally.",
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# ------------------------------------------------------------------
|
|
479
|
+
# Function-block instances whose outputs are all discarded.
|
|
480
|
+
# ------------------------------------------------------------------
|
|
481
|
+
for name in sorted(fb_instances, key=str.casefold):
|
|
482
|
+
pattern = re.compile(rf"(?<![.\w]){re.escape(name)}\s*\(", re.IGNORECASE)
|
|
483
|
+
if not pattern.search(code):
|
|
484
|
+
continue
|
|
485
|
+
# Any `Inst.something` read is treated as using the block's result.
|
|
486
|
+
reads = re.findall(
|
|
487
|
+
rf"(?<![.\w]){re.escape(name)}\s*\.\s*([A-Za-z_]\w*)", code, re.IGNORECASE
|
|
488
|
+
)
|
|
489
|
+
if not reads:
|
|
490
|
+
result.add(
|
|
491
|
+
"warning",
|
|
492
|
+
"unassigned-fb-output",
|
|
493
|
+
f"function block {name!r} is called but none of its outputs is read",
|
|
494
|
+
None,
|
|
495
|
+
hint=(
|
|
496
|
+
"MotionWorks blocks expose results as outputs such as .Done, "
|
|
497
|
+
".Busy and .Error. Discarding them is legal but often a mistake."
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# ------------------------------------------------------------------
|
|
502
|
+
# Statements missing their terminator.
|
|
503
|
+
# ------------------------------------------------------------------
|
|
504
|
+
for line_no, statement in unterminated_lines(code, code_start):
|
|
505
|
+
result.add(
|
|
506
|
+
"warning",
|
|
507
|
+
"unterminated-statement",
|
|
508
|
+
f"line does not end with ';': {statement[:70]!r}",
|
|
509
|
+
line_no,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
return result
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def lint_pou(pou: object, known_symbols: set[str] | None = None,
|
|
516
|
+
project_globals: set[str] | None = None) -> LintResult:
|
|
517
|
+
"""Lint a POU by combining its declarations with its body.
|
|
518
|
+
|
|
519
|
+
``pou`` is a :class:`motionworks_iec_mcp.project.PouInfo`. Typed loosely to
|
|
520
|
+
avoid a circular import.
|
|
521
|
+
"""
|
|
522
|
+
body = pou.st_body() # type: ignore[attr-defined]
|
|
523
|
+
if body is None:
|
|
524
|
+
result = LintResult()
|
|
525
|
+
result.add(
|
|
526
|
+
"info",
|
|
527
|
+
"not-structured-text",
|
|
528
|
+
"POU body is graphical (LD/FBD), not Structured Text; nothing to lint",
|
|
529
|
+
)
|
|
530
|
+
return result
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
table = pou.declarations() # type: ignore[attr-defined]
|
|
534
|
+
except Exception as exc: # noqa: BLE001
|
|
535
|
+
result = LintResult()
|
|
536
|
+
result.add(
|
|
537
|
+
"warning",
|
|
538
|
+
"declarations-unreadable",
|
|
539
|
+
f"declarations could not be read, so results are limited: {exc}",
|
|
540
|
+
)
|
|
541
|
+
return result
|
|
542
|
+
|
|
543
|
+
declared = {v.name for v in table.variables}
|
|
544
|
+
locals_only = {v.name for v in table.variables if not v.is_external}
|
|
545
|
+
fb_types = {v.name for v in table.variables if v.is_fb_instance}
|
|
546
|
+
return lint(
|
|
547
|
+
body,
|
|
548
|
+
declared=declared,
|
|
549
|
+
known_symbols=known_symbols,
|
|
550
|
+
fb_instance_types=fb_types,
|
|
551
|
+
local_vars=locals_only,
|
|
552
|
+
project_globals=project_globals,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# ── assignment type checking ─────────────────────────────────────────────────────
|
|
557
|
+
#
|
|
558
|
+
# Measured: a body that assigns a UINT to a BOOL makes the builder destroy the POU - .VB to 0 bytes
|
|
559
|
+
# and the grid to 79 MB - where a type-correct body merely stalls and leaves the POU intact. A type
|
|
560
|
+
# error is therefore worth refusing here, before it can reach the compiler.
|
|
561
|
+
#
|
|
562
|
+
# Deliberately narrow. Anything whose type cannot be established confidently is ignored, because a
|
|
563
|
+
# false refusal blocks correct code and costs more than the check saves.
|
|
564
|
+
|
|
565
|
+
_INTEGER_TYPES = frozenset({
|
|
566
|
+
"SINT", "USINT", "INT", "UINT", "DINT", "UDINT", "LINT", "ULINT",
|
|
567
|
+
"BYTE", "WORD", "DWORD", "LWORD",
|
|
568
|
+
})
|
|
569
|
+
_REAL_TYPES = frozenset({"REAL", "LREAL"})
|
|
570
|
+
_BOOL_TYPES = frozenset({"BOOL"})
|
|
571
|
+
_STRING_TYPES = frozenset({"STRING", "WSTRING", "CHAR", "WCHAR"})
|
|
572
|
+
|
|
573
|
+
_ASSIGN_LINE = re.compile(
|
|
574
|
+
r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*(.+?)\s*;\s*$"
|
|
575
|
+
)
|
|
576
|
+
_COMPARISON = re.compile(r"<=|>=|<>|=|<|>")
|
|
577
|
+
_LIT_REAL = re.compile(r"^[+-]?\d+\.\d*([eE][+-]?\d+)?$|^[+-]?\d+[eE][+-]?\d+$")
|
|
578
|
+
_LIT_INT = re.compile(r"^[+-]?\d+$")
|
|
579
|
+
_LIT_BOOL = re.compile(r"^(TRUE|FALSE)$", re.IGNORECASE)
|
|
580
|
+
_LIT_STRING = re.compile(r"^'[^']*'$")
|
|
581
|
+
_KEYWORDS = frozenset({"TRUE", "FALSE", "NOT", "AND", "OR", "XOR", "MOD", "DIV", "AND_THEN",
|
|
582
|
+
"OR_ELSE"})
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def elementary_family(type_name):
|
|
586
|
+
"""'integer', 'real', 'bool', 'string' - or None when the type is not an elementary one."""
|
|
587
|
+
t = (type_name or "").strip().upper()
|
|
588
|
+
if t in _INTEGER_TYPES:
|
|
589
|
+
return "integer"
|
|
590
|
+
if t in _REAL_TYPES:
|
|
591
|
+
return "real"
|
|
592
|
+
if t in _BOOL_TYPES:
|
|
593
|
+
return "bool"
|
|
594
|
+
if t in _STRING_TYPES:
|
|
595
|
+
return "string"
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _strip_comments(text):
|
|
600
|
+
return re.sub(r"\(\*.*?\*\)", " ", text)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _rhs_family(expr, types):
|
|
604
|
+
"""The family of a right-hand side, or None when it cannot be told confidently."""
|
|
605
|
+
e = _strip_comments(expr).strip()
|
|
606
|
+
while e.startswith("(") and e.endswith(")"):
|
|
607
|
+
e = e[1:-1].strip()
|
|
608
|
+
if not e:
|
|
609
|
+
return None
|
|
610
|
+
|
|
611
|
+
if _LIT_STRING.match(e):
|
|
612
|
+
return "string"
|
|
613
|
+
if _LIT_BOOL.match(e):
|
|
614
|
+
return "bool"
|
|
615
|
+
if _LIT_REAL.match(e):
|
|
616
|
+
return "real"
|
|
617
|
+
if _LIT_INT.match(e):
|
|
618
|
+
return "integer"
|
|
619
|
+
|
|
620
|
+
if re.match(r"^NOT\b", e, re.IGNORECASE):
|
|
621
|
+
return "bool"
|
|
622
|
+
if _COMPARISON.search(e):
|
|
623
|
+
head = _COMPARISON.split(e)[0].strip()
|
|
624
|
+
if head and not _LIT_INT.match(head) and not _LIT_REAL.match(head):
|
|
625
|
+
return "bool"
|
|
626
|
+
|
|
627
|
+
tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", e)
|
|
628
|
+
if not tokens:
|
|
629
|
+
return None
|
|
630
|
+
families = set()
|
|
631
|
+
for tok in tokens:
|
|
632
|
+
if tok.upper() in _KEYWORDS:
|
|
633
|
+
continue
|
|
634
|
+
declared = types.get(tok)
|
|
635
|
+
if declared is None:
|
|
636
|
+
return None # unknown to us - say nothing
|
|
637
|
+
fam = elementary_family(declared)
|
|
638
|
+
if fam is None:
|
|
639
|
+
return None # struct, FB instance, array - not our business
|
|
640
|
+
families.add(fam)
|
|
641
|
+
|
|
642
|
+
if len(families) == 1:
|
|
643
|
+
return families.pop()
|
|
644
|
+
if families == {"integer", "real"}:
|
|
645
|
+
return "real" # INTEGER widens to REAL; never the reverse
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def check_assignment_types(code, types):
|
|
650
|
+
"""Assignments whose right-hand side cannot fit the left.
|
|
651
|
+
|
|
652
|
+
``types`` maps a declared name to its type name. Returns a list of
|
|
653
|
+
``(line_number, target, declared_type, rhs_family)``.
|
|
654
|
+
"""
|
|
655
|
+
findings = []
|
|
656
|
+
if not types:
|
|
657
|
+
return findings
|
|
658
|
+
for number, line in enumerate(code.split("\n"), start=1):
|
|
659
|
+
clean = _strip_comments(line)
|
|
660
|
+
if ":=" not in clean:
|
|
661
|
+
continue
|
|
662
|
+
m = _ASSIGN_LINE.match(clean)
|
|
663
|
+
if not m:
|
|
664
|
+
continue
|
|
665
|
+
target, rhs = m.group(1), m.group(2)
|
|
666
|
+
declared = types.get(target)
|
|
667
|
+
if declared is None:
|
|
668
|
+
continue
|
|
669
|
+
want = elementary_family(declared)
|
|
670
|
+
got = _rhs_family(rhs, types)
|
|
671
|
+
if want is None or got is None or want == got:
|
|
672
|
+
continue
|
|
673
|
+
if want == "real" and got == "integer":
|
|
674
|
+
continue # a widening the compiler accepts
|
|
675
|
+
findings.append((number, target, declared, got))
|
|
676
|
+
return findings
|