dsh-plc-decode 0.0.0-stage → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-plc-decode contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,85 @@
1
+ # dsh-plc-decode
2
+
3
+ Read PLC project files directly inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH). The plugin detects the format from the file header, decodes the project into readable reports and structured data, and provides project diffing, address audits, element-level read/write analysis and ladder-logic reconstruction.
4
+
5
+ **Read-only**: project files are parsed, never modified.
6
+
7
+ ## Supported formats
8
+
9
+ | Header | Format | Decoder |
10
+ |---|---|---|
11
+ | `50 4B 03 04` (ZIP) | CoDeSys V3.5 / LeadSysStudio `.project` (incl. Leadshine) | Bundled Python script: unpack → string table → restore POU ST sources |
12
+ | `57 01 01 00` | Delta WPLSoft / ISPSoft `.dvp` | Bundled Python script: structure / section comments / device comments / instruction table |
13
+ | `53 48 33 00` (SH3) | Siemens S7-200 SMART `.smart` | Embedded Node decoder: blocks / instruction records / operands / user & system symbol tables |
14
+ | `44 45 4D 00` (DEM) | Siemens S7-200 SMART `.smartlib` | Embedded Node decoder: library variables and comment stream |
15
+
16
+ > Leadshine LeadSysStudio saves CoDeSys-format `.project` files, so they decode through the same path: ST sources go to `pous/<POU>.txt`, and `/codesys-check` runs an equivalent compile check (struct members, array bounds, calls, address uniqueness, undeclared symbols).
17
+
18
+ ## Install
19
+
20
+ Prerequisites:
21
+
22
+ - DSH desktop app (the plugin runs inside DSH; Node version follows DSH);
23
+ - Python 3.9+ on `PATH` for `.project` / `.dvp` (standard library only, no `pip install`);
24
+ - Python is not needed for `.smart` / `.smartlib`.
25
+ - Compatible with DSH 0.2.1-rc.5 (tested); 0.3 and later are not guaranteed.
26
+
27
+ ```sh
28
+ dsh plugin --profile web add github:taoziliyouchong/dsh-plc-decode
29
+ ```
30
+
31
+ Restart DSH afterwards. The plugin has no npm runtime dependencies.
32
+
33
+ ## Usage
34
+
35
+ ### Tools (invoked by the model)
36
+
37
+ | Tool | Purpose |
38
+ |---|---|
39
+ | `plc-decode` | Unified entry: detect the four formats and dispatch; args `file`, optional `outDir` |
40
+ | `smart-pou-structure` | Internal structure of one POU: interface parameters (L-area address + type), box-level instruction chain, call sites and arguments |
41
+ | `smart-lad` | Ladder reconstruction: per block/network LAD instruction sequence (LD/A/O/AN/ON, EU/ED, LPS/LRD/LPP, coils, comparisons, instruction-box placeholders) |
42
+ | `smart-ladder-elements` | Element-level read/write judgement for one address (or a batch) |
43
+ | `smart-net-trace` | Network trace: elements, write coils, boxes and constants in offset order |
44
+ | `smart-box-pins` | Box pin table: pin name = actual argument for library call boxes (MBUS_CTRL, MBUS_MSG, TON, CTU, MOV_*, …) |
45
+ | `smart-pin-dump` | Byte-level location: all occurrences of an address, or operand records in an offset range |
46
+ | `smart-var-usage` | Variable audit: all references, overlapping byte forms, write-point judgement |
47
+ | `smart-free-space` | Address usage and free ranges: pick new addresses when extending a program |
48
+ | `smart-conflict` | Address conflicts: overlaps (real / suspected / registered only) and fragmented free space |
49
+ | `smart-diff-project` | SMART project diff: block list, call chain, I/O references, symbol table |
50
+ | `codesys-diff-project` | CoDeSys project diff: POU list, real logic changes, whitespace, string table, object table |
51
+
52
+ ### Slash commands
53
+
54
+ `/plc-decode`, `/codesys-read`, `/dvp-read`, `/smart-read`, `/codesys-check`, `/codesys-diff`, `/smart-diff`, `/smart-free-space`, `/smart-conflict`, `/smart-ladder`, `/smart-lad`.
55
+
56
+ ## Output
57
+
58
+ Decoded files go to `解码归档/<project folder name>/` (the literal folder name is Chinese for "decode archive"; it lives in the current workspace). Use `outDir` to override.
59
+
60
+ - `.project` → `00-索引.md`, `pous/<POU>.txt`, `工程内容报告.md`, `全部字符串.txt`, `对象表.md`, `项目树.md`
61
+ - `.dvp` → `00-索引.md`, `解码报告.md`
62
+ - `.smart` / `.smartlib` → `00-目录.md`, `解码报告.md`, `程序.lad`, `元素记录.tsv`, `符号地址.tsv`, `分析.md`, `记录.json`, `字符串全录.txt`
63
+
64
+ The smart decoder writes a source fingerprint (size + mtime) into the report and `记录.json`; re-check it after the project is edited.
65
+
66
+ ## Limitations
67
+
68
+ - HMI projects (`.hmismart`, `.fsprj`) are out of scope.
69
+ - Pin-level arguments of library call boxes (Slave / RW / Addr / Count / DataPtr) cannot be decoded; the tools say so instead of guessing.
70
+ - Encrypted or specially compressed projects may fail to decode; the decoder reports an error rather than a half-true result.
71
+ - Ladder series/parallel topology is reconstructed from instruction records (validated network-by-network against official STL exports); library call regions without standard box headers are not covered.
72
+ - Inferred instruction names are marked with `?`; undecoded names keep their raw form `??xx`.
73
+
74
+ ## Development
75
+
76
+ ```sh
77
+ npm run check # syntax check
78
+ npm run smoke # load the plugin and verify registrations (12 tools / 11 commands)
79
+ npm run smoke -- <project> # decode one real project end-to-end
80
+ npm pack --dry-run # inspect package contents
81
+ ```
82
+
83
+ ## License
84
+
85
+ MIT
package/README.md CHANGED
@@ -1,3 +1,129 @@
1
- # Temporary Holding Version
1
+ # dsh-plc-decode
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <img src="assets/icon-180.png" width="96" align="right" alt="dsh-plc-decode">
4
+
5
+ 在 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)里直接读 PLC 工程文件:按文件头自动识别格式,把工程解成可读报告与结构化数据,再基于解码结果做新旧对比、地址体检、元素级读/写判定与连线还原。
6
+
7
+ **只读**:解析工程文件,不改动原文件。
8
+
9
+ ## 支持的工程文件
10
+
11
+ | 文件头 | 格式 | 解码方式 |
12
+ |---|---|---|
13
+ | `50 4B 03 04`(ZIP) | CoDeSys V3.5 / LeadSysStudio `.project`(含雷赛) | 自带 Python 脚本:解包 → 字符串表 → 逐对象恢复 POU ST 源码 |
14
+ | `57 01 01 00` | 台达 WPLSoft / ISPSoft `.dvp` | 自带 Python 脚本:文件结构 / 段落注释 / 设备注释 / 指令表反汇编 |
15
+ | `53 48 33 00`(SH3) | 西门子 S7-200 SMART `.smart` 工程 | 内嵌 Node 解码器:块 / 指令记录 / 操作数 / 用户与系统符号表 |
16
+ | `44 45 4D 00`(DEM) | 西门子 S7-200 SMART `.smartlib` 库 | 内嵌 Node 解码器:库变量与注释流 |
17
+
18
+ > 雷赛(Leadshine)用 LeadSysStudio 保存的 `.project` 与 CoDeSys 同格式,走同一条解码链:ST 源码导出到 `pous/<POU>.txt`,可用 `/codesys-check` 做等价编译校验(结构体成员、数组下标、调用存在性、地址唯一、未声明符号)。
19
+
20
+ ## 安装
21
+
22
+ 前提:
23
+
24
+ - DSH 桌面端(插件运行在 DSH 内,Node 版本跟随 DSH);
25
+ - 解码 `.project` / `.dvp` 需要 Python 3.9+ 在 `PATH` 里(只用标准库,无需 pip 安装);
26
+ - 解码 `.smart` / `.smartlib` 不需要 Python。
27
+ - 兼容 DSH 0.2.1-rc.5(实测通过);0.3 起不保证。
28
+
29
+ 从 GitHub 安装:
30
+
31
+ ```sh
32
+ dsh plugin --profile web add github:taoziliyouchong/dsh-plc-decode
33
+ ```
34
+
35
+ 装完重启 DSH。本插件没有 npm 运行时依赖。
36
+
37
+ ## 使用
38
+
39
+ ### 工具(模型自动调用)
40
+
41
+ | 工具 | 用途 |
42
+ |---|---|
43
+ | `plc-decode` | 统一入口:按文件头识别四格式并分发解码,参数 `file` + 可选 `outDir` |
44
+ | `smart-pou-structure` | 某子程序的内部结构:接口参数(L 区地址 + 类型)、盒级指令链、调用点实参 |
45
+ | `smart-lad` | 连线还原:按块、按网络打印 LAD 指令序列(LD/A/O/AN/ON、EU/ED、LPS/LRD/LPP、线圈、比较、指令盒占位) |
46
+ | `smart-ladder-elements` | 元素级读/写判定:某地址(可一批)的每个落点是触点(读)还是写 |
47
+ | `smart-net-trace` | 网络级轨迹:给偏移区间,按顺序列出触点 / 写元件 / 指令盒 / 常数 |
48
+ | `smart-box-pins` | 盒级引脚表:库调用盒(MBUS_CTRL、MBUS_MSG、TON、CTU、MOV_* 等)的「引脚名 = 实参」 |
49
+ | `smart-pin-dump` | 字节级定位:某地址的全部出现位置,或某偏移区间内的操作数记录 |
50
+ | `smart-var-usage` | 变量使用体检:某地址的全部引用点、共用字节的其它地址形式、写入点判定 |
51
+ | `smart-free-space` | 地址占用与空闲段:加功能前选新地址(有符号名的、预留坑都不动) |
52
+ | `smart-conflict` | 地址冲突体检:重叠冲突(真冲突 / 疑似 / 仅登记)+ 连续空间被切碎 |
53
+ | `smart-diff-project` | SMART 新旧工程对比:块清单、主程序调用链、I-O 引用、符号表差异 |
54
+ | `codesys-diff-project` | CoDeSys 新旧工程对比:POU 清单、真实逻辑变化区段、空白差异、字符串表、对象表 |
55
+
56
+ ### 斜杠命令(人直接执行)
57
+
58
+ | 命令 | 用途 |
59
+ |---|---|
60
+ | `/plc-decode <文件> [输出目录]` | 统一解码入口 |
61
+ | `/plc-archive` | 查看当前生效的解码归档根目录、默认落点与改配置的方法 |
62
+ | `/codesys-read <工程>` | 解码 `.project`(ST 源码 + 报告)或 `.dvp`(解码报告) |
63
+ | `/dvp-read <工程>` | 台达 DVP 结构与指令表报告 |
64
+ | `/smart-read <.smart/.smartlib> [输出目录]` | SMART 工程 / 库解码 |
65
+ | `/codesys-check <工程>` | 解码 `.project` 后做等价编译静态校验(结构体 / 调用 / 地址唯一 / 未声明符号) |
66
+ | `/codesys-diff <旧> <新>` | CoDeSys 新旧对比 |
67
+ | `/smart-diff <旧> <新>` | SMART 新旧对比 |
68
+ | `/smart-free-space <.smart>` | 地址占用与空闲段 |
69
+ | `/smart-conflict <.smart>` | 地址冲突体检 |
70
+ | `/smart-ladder <.smart> [地址]` | 元素级读/写判定 |
71
+ | `/smart-lad <.smart> [块名] [网络号]` | 连线还原(LAD 指令序列) |
72
+
73
+ ## 输出位置
74
+
75
+ 默认写到**工作区**的 `解码归档/<工程所在文件夹名>/`(用文件夹名而不是工程文件名,避免不同文件夹里的同名工程互相覆盖;同一文件夹里有多个工程文件时用 `文件夹名_工程名`)。也可以在工具参数里用 `outDir` 指定。
76
+
77
+ ### 改归档地址(不用改代码)
78
+
79
+ 在 DSH profile 的 `cordis.patch.yml` 里给本插件那行加 `config`:
80
+
81
+ ```yaml
82
+ - id: dsh-plc-decode
83
+ name: dsh-plc-decode
84
+ config:
85
+ archiveDir: D:/plc/解码归档
86
+ ```
87
+
88
+ `archiveDir` 是归档**根目录**:相对路径按当前会话工作区解析,绝对路径原样使用;默认 `解码归档`。改完重启 dsh 生效,`/plc-archive` 可随时查看当前生效值。只想改一次就在解码时传 `outDir`。
89
+
90
+ | 格式 | 主要产物 |
91
+ |---|---|
92
+ | `.project` | `00-索引.md`(先读这份)、`pous/<POU>.txt`(ST 源码)、`工程内容报告.md`、`全部字符串.txt`、`对象表.md`、`项目树.md` |
93
+ | `.dvp` | `00-索引.md`、`解码报告.md`(结构 / 段落注释 / 设备注释 / 指令表)、可选 `程序解读.md` |
94
+ | `.smart` / `.smartlib` | `00-目录.md`(先读这份)、`解码报告.md`、`程序.lad`、`元素记录.tsv`、`符号地址.tsv`、`分析.md`、`记录.json`、`字符串全录.txt` |
95
+
96
+ `.smart` 的 `记录.json` 同时是各分析工具(地址占用、变量使用等)的输入,也是识别「已解码目录」的凭据;解码报告头部带**源文件指纹**(文件大小 + 修改时间),工程换版本后先核对指纹再用旧报告。
97
+
98
+ ## 已知边界
99
+
100
+ - 不处理触摸屏(HMI)工程:`.hmismart`、`.fsprj` 不在本插件范围。
101
+ - 库块调用区(如 MBUS_MSG 的 Slave / RW / Addr / Count / DataPtr)的引脚级实参解不出,工具会明确说明,不猜。
102
+ - 加密或特殊压缩的工程可能解不出;解码器会报错而不是给出半真结果。
103
+ - `.smart` 的串/并联拓扑由指令记录还原,已用官方导出的 STL 逐网络回归验证;库调用区(无标准盒头)不在覆盖范围内。
104
+ - 指令名带 `?`(如 `?DRVI`)是推断名,报告末尾有推断对照表;`??xx` 是未定名,保持原样。
105
+
106
+ ## 开发与自检
107
+
108
+ ```sh
109
+ npm run check # 语法检查
110
+ npm run smoke # 装载插件 + 核对注册面(工具 12 个 / 命令 12 个)
111
+ npm run smoke -- <工程文件> # 额外对一个真实工程跑端到端解码,核对产物落盘
112
+ npm pack --dry-run # 看包里内容(node_modules、publish/ 不应进包)
113
+ ```
114
+
115
+ ## 目录结构
116
+
117
+ ```
118
+ index.js 插件入口:工具与命令注册、四格式分发、SMART 解码器
119
+ invariant.js 伴随插件:登记包名所有权
120
+ assets/decode-pack.mjs 解码产物落盘(索引/目录/表格/记录)
121
+ assets/smart-lad.mjs 连线还原脚本(由 smart-lad 工具调用)
122
+ assets/smart-net-trace.mjs 网络轨迹脚本(由 smart-net-trace 工具调用)
123
+ assets/codesys-tools/ CoDeSys / DVP 解码 Python 脚本(只用标准库)
124
+ scripts/smoke.mjs 冒烟测试
125
+ ```
126
+
127
+ ## 许可
128
+
129
+ MIT
@@ -0,0 +1,390 @@
1
+ # -*- coding: utf-8 -*-
2
+ """静态语义校验: 等价于编译检查 (无法驱动 LeadSysStudio GUI 编译时的替代验证)。
3
+ 校验: ① 结构体成员存在性 ② 被调用的程序/动作/方法存在性 ③ 地址唯一性 ④ 数组下标越界
4
+ ⑤ 未声明符号 (排除库/设备外部符号)。
5
+ 用法: python check_project.py <pous目录>
6
+ """
7
+ import io
8
+ import re
9
+ import sys
10
+ import glob
11
+ import os
12
+
13
+ if __name__ == "__main__" and hasattr(sys.stdout, "reconfigure"):
14
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
15
+
16
+ POUS = sys.argv[1] if len(sys.argv) > 1 else "."
17
+
18
+ CJK = u"\u4e00-\u9fff"
19
+ ID_RE = re.compile(u"[\\w" + CJK + u"]+")
20
+ ST_HEADER_RE = re.compile(
21
+ r"^\s*(PROGRAM|FUNCTION_BLOCK|FUNCTION|METHOD|INTERFACE|CLASS|TYPE|VAR(?:_GLOBAL|_INPUT|_OUTPUT|_IN_OUT)?)\b",
22
+ re.M)
23
+
24
+ KEYWORDS = {
25
+ "IF","THEN","ELSE","ELSIF","END_IF","FOR","TO","BY","DO","END_FOR","WHILE","REPEAT",
26
+ "UNTIL","END_WHILE","END_REPEAT","CASE","OF","END_CASE","VAR","END_VAR","VAR_IN_OUT",
27
+ "VAR_INPUT","VAR_OUTPUT","VAR_GLOBAL","VAR_GLOBAL_CONSTANT","VAR_GLOBAL_RETAIN",
28
+ "VAR_GLOBAL_PERSISTENT","VAR_GLOBAL_RETAIN_PERSISTENT","VAR_TEMP","STRUCT","END_STRUCT",
29
+ "TYPE","END_TYPE","FUNCTION_BLOCK","FUNCTION","PROGRAM","ACTION","METHOD","INTERFACE",
30
+ "IMPLEMENTS","EXTENDS","CONSTANT","RETAIN","PERSISTENT","ARRAY","OF","POINTER","AT",
31
+ "AND","OR","XOR","NOT","TRUE","FALSE","BOOL","INT","DINT","UDINT","UINT","REAL",
32
+ "LREAL","WORD","DWORD","BYTE","STRING","SINT","USINT","TIME","TOD","DATE","DT",
33
+ }
34
+ # 库/设备外部符号 (库函数/FB/类型/设备对象/库实例), 大小写不敏感
35
+ STD = {
36
+ "MC_Power","MC_Stop","MC_Reset","MC_Halt","MC_Jog","MC_MoveVelocity","MC_SetPosition",
37
+ "MC_Home","MC_MoveAbsolute","MC_MoveRelative","MC_MoveAdditive","MC_GearIn","MC_GearOut",
38
+ "MC_Direction","SMC3_ReinitDrive","SMC3_PersistPosition","AXIS_REF","AXIS_REF_SM3",
39
+ "AXIS_REF_ETC_DS402_CS","ETCSlave","TON","TOF","TP","R_TRIG","F_TRIG","CTU","CTD","CTUD",
40
+ "LIMIT","MIN","MAX","ABS","ADR","SEL","MUX","MOVE","SHR","SHL","MOD","DWORD_TO_UDINT",
41
+ "LREAL_TO_REAL","TO_DINT","TO_UDINT","TO_DWORD","TO_INT","TO_UINT","TO_REAL","TO_LREAL",
42
+ "TO_BOOL","ANY_TO_TIME","INT_TO_DINT","DINT_TO_INT","TIME_TO","REAL_TO","STRING_TO",
43
+ "UDINT_TO","BYTE_TO","WORD_TO","DINT_TO_REAL","SM3_Basic","positive","negative","normal",
44
+ "Fastest","shortest","xConfigFinished","xDistributedClockInSync","xError","fbeFBError",
45
+ "wID","wStatusWord","dwDigitalInputs","wState","SetOpMode","wOpMode","bVirtual",
46
+ "wCommunicationState","bDriveStartRealState","bRegulatorRealState","nAxisState",
47
+ "fActVelocity","fSetPosition","fActPosition","fActTorque","dwPosOffsetForResiduals",
48
+ "In","Out","Master","Slave","Axis","Execute","Enable","Position","Velocity","Acceleration",
49
+ "Deceleration","Jerk","Direction","Distance","BufferMode","Done","Busy","Active",
50
+ "CommandAborted","Error","ErrorId","ErrorID","InGear","InVelocity","Status","bRegulatorOn",
51
+ "bDriveStart","Mode","RatioNumerator","RatioDenominator","Q","ET","CLK","IN","PT",
52
+ "JogForward","JogBackward",
53
+ "High_Speed_IO_Module","LocalBus_Master_Module",
54
+ }
55
+ STD_UPPER = {s.upper() for s in STD}
56
+ PARAM_NAMES = {"IN","PT","Q","ET","CLK","CV","RESET","CU","PV","COUNT","MAXVALUE"}
57
+
58
+ # 数组/结构体声明解析:从各 POU 的实际声明里取类型,不预设变量名
59
+ ARRAY_DECL_RE = re.compile(u"ARRAY\\s*\\[\\s*(-?\\d+)\\s*\\.\\.\\s*(-?\\d+)\\s*\\]\\s*OF\\s+"
60
+ u"(?:ARRAY\\s*\\[[^\\]]*\\]\\s*OF\\s+)?([\\w" + CJK + u"]+)")
61
+ STRUCT_DECL_RE = re.compile(u"^([\\w" + CJK + u"]+)(?:\\s+AT\\s+%[^\\s]+)?\\s*:\\s*([\\w" + CJK + u"]+)\\s*(?:;|:=)")
62
+
63
+
64
+ def parse_decl(line):
65
+ """声明行 → ('array', lo, hi, 元素类型) / ('struct', 类型名) / None"""
66
+ m = ARRAY_DECL_RE.search(line)
67
+ if m:
68
+ try:
69
+ return ("array", int(m.group(1)), int(m.group(2)), m.group(3))
70
+ except ValueError:
71
+ return None
72
+ m = STRUCT_DECL_RE.match(line)
73
+ if m:
74
+ return ("struct", m.group(2))
75
+ return None
76
+
77
+
78
+ def read_pou(path):
79
+ txt = open(path, encoding="utf-8-sig").read()
80
+ body = [l for l in txt.splitlines() if not l.startswith("#")]
81
+ return "\n".join(body).lstrip()
82
+
83
+
84
+ def strip_comments(txt):
85
+ """剥 ST 注释: // 行注释 + (* *) 块注释。栈式扫描, 正确处理嵌套/未闭合 ((* 之后全视为注释)。"""
86
+ out = []
87
+ i, depth, n = 0, 0, len(txt)
88
+ while i < n:
89
+ if txt.startswith("(*", i):
90
+ depth += 1
91
+ i += 2
92
+ elif txt.startswith("*)", i) and depth > 0:
93
+ depth -= 1
94
+ i += 2
95
+ elif depth > 0:
96
+ i += 1
97
+ elif txt.startswith("//", i):
98
+ j = txt.find("\n", i)
99
+ i = n if j == -1 else j + 1
100
+ else:
101
+ out.append(txt[i])
102
+ i += 1
103
+ return "".join(out)
104
+
105
+
106
+ def extract_var_decls(txt):
107
+ decls = {}
108
+ for m in re.finditer(r"(?s)(?:VAR|VAR_IN_OUT|VAR_INPUT|VAR_OUTPUT|VAR_TEMP)\b(.*?)(?=\bEND_VAR)", txt):
109
+ for line in m.group(1).splitlines():
110
+ line = line.strip()
111
+ if not line or line.startswith(("//", "(*", "*)")):
112
+ continue
113
+ mm = re.match(u"^([\\w" + CJK + u"]+)\\s*:", line)
114
+ if mm:
115
+ decls[mm.group(1)] = line
116
+ return decls
117
+
118
+
119
+ def extract_globals(txt):
120
+ names, addrs, decls = set(), {}, {}
121
+ for m in re.finditer(r"(?s)VAR_GLOBAL(.*?)(?=\bEND_VAR)", txt):
122
+ for line in m.group(1).splitlines():
123
+ line = line.strip()
124
+ if not line or line.startswith(("//", "(*", "*)")):
125
+ continue
126
+ am = re.search(r"AT\s+(%[A-Z][A-Za-z]*\d+(?:\.\d+)?)", line)
127
+ nm = re.match(u"^([\\w" + CJK + u"]+)", line)
128
+ if nm:
129
+ names.add(nm.group(1))
130
+ decls[nm.group(1)] = line
131
+ if am:
132
+ addrs.setdefault(am.group(1), nm.group(1))
133
+ return names, addrs, decls
134
+
135
+
136
+ def extract_dut_fields(txt):
137
+ fields = set()
138
+ for line in txt.splitlines():
139
+ line = line.strip()
140
+ if not line or line.startswith("//"):
141
+ continue
142
+ if ":" in line and not line.startswith(("TYPE", "END_TYPE", "STRUCT", "END_STRUCT")):
143
+ mm = re.match(u"^([\\w" + CJK + u"]+)\\s*:", line)
144
+ if mm:
145
+ fields.add(mm.group(1))
146
+ return fields
147
+
148
+
149
+ def enum_members(txt):
150
+ m = set()
151
+ t = txt.split("END_TYPE")[0]
152
+ if "(" in t and "STRUCT" not in t:
153
+ body = t.split("(", 1)[1]
154
+ for mm in re.finditer(u"([\\w" + CJK + u"]+)\\s*(?::=\\s*\\d+)?", body):
155
+ m.add(mm.group(1))
156
+ return m
157
+
158
+
159
+ # ---------------- 加载 ----
160
+ pous = {}
161
+ for f in glob.glob(os.path.join(POUS, "*.txt")):
162
+ name = os.path.basename(f)[:-4]
163
+ txt = read_pou(f)
164
+ # 跳过设备对象(fallback 文本提取,非 ST 代码):前 2000 字符里找不到任何 ST 声明头
165
+ if not ST_HEADER_RE.search(txt[:2000]):
166
+ continue
167
+ pous[name] = txt
168
+
169
+ dut_fields = {}
170
+ declared = set()
171
+ locals_map = {}
172
+ globals_map = {}
173
+ global_decls = {} # 全局变量名 -> 声明行(数组/结构体类型推断用)
174
+ enum_names = set()
175
+ global_visible = set() # 仅"跨 POU 可见"声明 (全局/枚举/DUT 类型与字段/调用 POU)
176
+ local_pool = {} # name -> {局部声明名: 原行}
177
+ for name, txt in pous.items():
178
+ if txt.startswith("TYPE"):
179
+ if "STRUCT" in txt.split("END_TYPE")[0]:
180
+ m = re.match(u"TYPE\\s+([\\w" + CJK + u"]+)", txt)
181
+ dname = m.group(1) if m else name
182
+ dut_fields[dname] = extract_dut_fields(txt)
183
+ global_visible.add(dname)
184
+ else:
185
+ en = enum_members(txt)
186
+ if en:
187
+ enum_names |= en
188
+ declared |= en
189
+ global_visible |= en
190
+ elif "VAR_GLOBAL" in txt:
191
+ n, a, d = extract_globals(txt)
192
+ globals_map[name] = (n, a)
193
+ global_decls.update(d)
194
+ declared |= n
195
+ global_visible |= n
196
+ else:
197
+ loc = extract_var_decls(txt)
198
+ locals_map[name] = loc
199
+ local_pool[name] = loc
200
+ declared |= set(loc)
201
+ callables = set(pous.keys())
202
+ for flds in dut_fields.values():
203
+ declared |= flds
204
+ global_visible |= flds
205
+ declared |= set(dut_fields.keys()) # DUT 类型名也是声明
206
+ global_visible |= callables
207
+
208
+ # ---------------- 校验 1: 结构体成员 + 数组下标 ----------------
209
+ def decl_kinds(name):
210
+ """该 POU 可见的数组/结构体变量(全局 + 本 POU 局部):{变量名: 声明解析结果}"""
211
+ kinds = {}
212
+ for var, line in global_decls.items():
213
+ k = parse_decl(line)
214
+ if k:
215
+ kinds[var] = k
216
+ for var, line in locals_map.get(name, {}).items():
217
+ k = parse_decl(line)
218
+ if k:
219
+ kinds[var] = k
220
+ return kinds
221
+
222
+
223
+ struct_field_issues = []
224
+ for name, txt in pous.items():
225
+ if txt.startswith("TYPE"):
226
+ continue
227
+ kinds = decl_kinds(name)
228
+ if not kinds:
229
+ continue
230
+ txt = strip_comments(txt)
231
+ # 数组元素成员:base[idx].field
232
+ for m in re.finditer(u"([\\w" + CJK + u"]+)\\s*\\[\\s*([^\\]]+?)\\s*\\]\\s*\\.\\s*([\\w" + CJK + u"]+)", txt):
233
+ base, idx, field = m.group(1), m.group(2).strip(), m.group(3)
234
+ k = kinds.get(base)
235
+ if not k or k[0] != "array":
236
+ continue
237
+ dut = k[3]
238
+ if dut in dut_fields and field not in dut_fields[dut]:
239
+ struct_field_issues.append(u"%s: %s[%s].%s 不是 %s 的字段" % (name, base, idx, field, dut))
240
+ if idx.isdigit() and not (k[1] <= int(idx) <= k[2]):
241
+ struct_field_issues.append(u"%s: %s[%s] 越界 (%d..%d)" % (name, base, idx, k[1], k[2]))
242
+ # 结构体实例成员:var.field
243
+ for m in re.finditer(u"([\\w" + CJK + u"]+)\\s*\\.\\s*([\\w" + CJK + u"]+)", txt):
244
+ var, field = m.group(1), m.group(2)
245
+ k = kinds.get(var)
246
+ if not k or k[0] != "struct":
247
+ continue
248
+ dut = k[1]
249
+ if dut in dut_fields and field not in dut_fields[dut]:
250
+ struct_field_issues.append(u"%s: %s.%s 不是 %s 的字段" % (name, var, field, dut))
251
+ # 纯数组下标越界:base[idx](后面不跟 .field,避免与上一条重复)
252
+ for m in re.finditer(u"([\\w" + CJK + u"]+)\\s*\\[\\s*(\\d+)\\s*\\](?!\\s*\\.)", txt):
253
+ base, idx = m.group(1), int(m.group(2))
254
+ k = kinds.get(base)
255
+ if k and k[0] == "array" and not (k[1] <= idx <= k[2]):
256
+ struct_field_issues.append(u"%s: %s[%d] 越界 (%d..%d)" % (name, base, idx, k[1], k[2]))
257
+
258
+ # ---------------- 校验 2: 调用存在性 ----------------
259
+ call_issues = []
260
+ for name, txt in pous.items():
261
+ if txt.startswith("TYPE"):
262
+ continue
263
+ txt = strip_comments(txt)
264
+ for m in re.finditer(u"(?<![\\w" + CJK + u"])([A-Z][\\w" + CJK + u"]*)\\s*\\(", txt):
265
+ callee = m.group(1)
266
+ if callee in callables or callee in KEYWORDS or callee.upper() in STD_UPPER:
267
+ continue
268
+ if callee.startswith(("MC_", "TON", "R_TRIG", "F_TRIG", "SM3", "CTU", "TP", "TOF")):
269
+ continue
270
+ if callee in declared:
271
+ continue
272
+ call_issues.append(u"%s: 调用未定义 %s()" % (name, callee))
273
+
274
+ # ---------------- 校验 3: 地址唯一 ----------------
275
+ addr_issues = []
276
+ all_addrs = {}
277
+ for gvl, (n, a) in globals_map.items():
278
+ for addr, var in a.items():
279
+ if addr in all_addrs and all_addrs[addr] != var:
280
+ addr_issues.append(u"地址重叠: %s 被 %s(%s) 与 %s 同时占用" % (addr, all_addrs[addr], gvl, var))
281
+ all_addrs.setdefault(addr, var)
282
+
283
+ # ---------------- 校验 4: 未声明符号 ----------------
284
+ undef_issues = []
285
+ for name, txt in pous.items():
286
+ if txt.startswith("TYPE"):
287
+ continue
288
+ clean = strip_comments(txt)
289
+ for t in set(ID_RE.findall(clean)):
290
+ if t in KEYWORDS or t in declared or t in enum_names or t in callables:
291
+ continue
292
+ if t.upper() in STD_UPPER or t.upper() in PARAM_NAMES:
293
+ continue
294
+ if t.isdigit() or re.fullmatch(r"\d+[A-Za-z]+", t) or re.fullmatch(r"(MB|MW|MX|QX|QB|QI|IX|IW|QD)\d+", t):
295
+ continue
296
+ if t.startswith(("MC_", "SM3_", "SMC3", "AXIS", "ETC", "FB_", "ST_", "DUT_", "GVL",
297
+ "TON", "R_TRIG", "F_TRIG", "CTU", "TP", "TOF")):
298
+ continue
299
+ if len(t) == 1 or t in ("LeadSysStudio",):
300
+ continue
301
+ undef_issues.append(u"%s: 未声明符号 %s" % (name, t))
302
+
303
+ # ---------------- 校验 5: POU 声明头缺失 ----------------
304
+ header_issues = []
305
+ for name, txt in pous.items():
306
+ if txt.startswith("TYPE"):
307
+ continue
308
+ first = ""
309
+ for l in txt.splitlines():
310
+ l = l.strip()
311
+ if not l or l.startswith("//"):
312
+ continue
313
+ first = l
314
+ break
315
+ if not re.match(u"^(PROGRAM|FUNCTION_BLOCK|FUNCTION|VAR_GLOBAL)(?=\\s|$)", first):
316
+ header_issues.append(u"%s: 缺少 POU 声明头 (首行应为 PROGRAM/FUNCTION_BLOCK/FUNCTION/VAR_GLOBAL, 当前: %s)"
317
+ % (name, first[:40] if first else "(空)"))
318
+
319
+ # ---------------- 校验 6: 跨 POU 引用他人局部声明 ----------------
320
+ cross_issues = []
321
+ local_all = set()
322
+ for loc in local_pool.values():
323
+ local_all |= set(loc)
324
+ for name, txt in pous.items():
325
+ if txt.startswith("TYPE"):
326
+ continue
327
+ own = set(local_pool.get(name, {}))
328
+ clean = strip_comments(txt)
329
+ for t in set(ID_RE.findall(clean)):
330
+ if t in own or t in global_visible or t in KEYWORDS or t.isdigit():
331
+ continue
332
+ if t.upper() in STD_UPPER or t.upper() in PARAM_NAMES:
333
+ continue
334
+ if t in local_all:
335
+ for oname, loc in local_pool.items():
336
+ if oname != name and t in loc:
337
+ cross_issues.append(u"%s: 引用了 %s 的局部声明 %s (编译将报未声明, 应把声明移到本 POU 或全局)"
338
+ % (name, oname, t))
339
+ break
340
+
341
+ # ---------------- 输出 ----------------
342
+ print("=" * 64)
343
+ print(u"静态语义校验 (等价编译检查)")
344
+ print("=" * 64)
345
+ print(u"POU 数 %d | DUT 字段组 %d | 全局声明 %d | 枚举成员 %d" %
346
+ (len(pous), len(dut_fields), sum(len(v[0]) for v in globals_map.values()), len(enum_names)))
347
+
348
+ print(u"\n[1] 结构体成员/数组越界:")
349
+ if struct_field_issues:
350
+ for e in sorted(set(struct_field_issues)):
351
+ print(" ✗", e)
352
+ else:
353
+ print(" ✓ 全部有效")
354
+
355
+ print(u"\n[2] 程序/动作/方法调用:")
356
+ if call_issues:
357
+ for e in sorted(set(call_issues)):
358
+ print(" ✗", e)
359
+ else:
360
+ print(" ✓ 全部存在")
361
+
362
+ print(u"\n[3] AT 地址唯一性:")
363
+ if addr_issues:
364
+ for e in sorted(set(addr_issues)):
365
+ print(" ✗", e)
366
+ else:
367
+ print(" ✓ 无重叠")
368
+
369
+ print(u"\n[4] 未声明符号 (排除库/设备外部, 需人工核对):")
370
+ if undef_issues:
371
+ for e in sorted(set(undef_issues)):
372
+ print(" ?", e)
373
+ else:
374
+ print(" ✓ 无")
375
+
376
+ print(u"\n[5] POU 声明头:")
377
+ if header_issues:
378
+ for e in sorted(set(header_issues)):
379
+ print(" ✗", e)
380
+ else:
381
+ print(" ✓ 全部 POU 有声明头")
382
+
383
+ print(u"\n[6] 跨 POU 非法局部引用:")
384
+ if cross_issues:
385
+ for e in sorted(set(cross_issues)):
386
+ print(" ✗", e)
387
+ else:
388
+ print(" ✓ 无")
389
+
390
+ print(u"\n结论: 静态等价检查; 最终以 LeadSysStudio 编译为准 (工程已在 IDE 打开)")