Files
STM32F4-Base/.dsh/skills/keil-build/scripts/keil_build.py
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

480 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""keil_build.py - Keil MDK-ARM 命令行构建 + 判定(纯标准库,可移植 skill 版)。
用法(**项目根目录**下运行,任意 python3):
python <skill>/scripts/keil_build.py
[--project X.uvprojx] [--uv4 UV4.exe] [--target 名字]
[--log build_log.txt] [--timeout 600]
[--lock-task ID] [--lock-wait N] [--no-lock]
解析顺序(命令行 > 项目档案 > 环境 > 探测):
工程 --project > 档案 "uvprojx" > 自动搜 cwd 下唯一 *.uvprojx
UV4 --uv4 > 档案 "uv4" > $DSH_KEIL_UV4 > $DTU_KEIL_UV4 > PATH
(环境变量**显式设了但路径无效 = 立即报错,不静默回退**)
日志 --log > 档案 "build_log" > <工程目录>\\build_log.txt
判定口径(实测教训沉淀;原仓库 MDK-ARM/build.bat 已删除,本脚本即仓库编译入口):
1. 先删旧日志——UV4 -o 只在真跑了构建才写日志,残留旧日志会被当成本次结果
2. UV4 单实例:IDE 开着时 -b 可能被转发给 IDE(退出码丢失)→ 只 WARN 不杀进程
3. PASS = 日志存在 + 无 "error:"(大小写不敏感)+ 含 "0 Error(s), 0 Warning(s)"
(0E0W 精确匹配同时把"只有警告"的构建判 FAIL)
**build 锁:本脚本自持自释(2026-10-12)**
UV4 不能两个实例同时 `-b`,所以构建锁不该由调用方"记得释放"——那正是泄漏源
(Agent 手工 `lock acquire` 后忘了 `release`,锁要挂到 TTL 才被接管)。
改为:本脚本进入时 acquire、退出时**无条件** release(try/finally,构建失败/异常/
SystemExit 都会释放)。
- 锁工具 = 同级 skill `task-worktree/scripts/task_worktree.py`(**找不到则降级为
不加锁并告警**,保持本 skill 可移植)
- 持有者名 `--lock-task`:默认 `$DTU_TASK_ID` → 否则从 cwd 的 `_wt/<ID>-<slug>/`
反推 `<ID>`(与工作树命名约定一致,故"手工锁了同名任务"仍幂等)→ 否则 `build-cli`
- 被占用:默认**立即失败**(退出码 4,不静默等待);`--lock-wait N` 可等待 N 秒
- 调用方**已持有** build 锁时(如 `task-worktree done` 的门禁),设
`DTU_LOCK_HELD=build` 让本脚本跳过自持自释,避免自我死锁
- **TTL 自报**:acquire 时按 `--timeout` 声明陈旧窗口(`timeout/3600 + 15min`,
下限 0.5h)。故崩溃/强杀留下的锁,其自动接管窗口与本次构建上限同量级,
而不再是全局 6h(编译很快,兜底窗口也该短)。
退出码(与 task-worktree 同一约定;2026-10-12 修正——此前实现与文档不符):
0 = PASS(0 错 0 警)
1 = 构建失败(看打印的报错行 / 日志)
2 = **环境/前置错误**(UV4 找不到或路径无效、工程找不到、档案解析失败)
3 = 用法错误
4 = build 锁被占用(并发 -b 不可行)
⚠ 修正说明:旧实现把环境错误写成 `raise SystemExit("...")` → **实际退出码是 1**,
而用法错误由 argparse 抛 SystemExit(2) → 恰好占用了文档里"环境错误"的位置。
故 AGENTS.md / `new-machine-setup` SKILL.md 里写的"报 exit 2 = 环境问题"当时**不成立**。
现已按文档口径改正:环境错误 `BuildEnvError` → 2,argparse 用法错误 → 3。
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
LOCK_NAME = "build"
class BuildEnvError(Exception):
"""环境/前置错误(UV4 或工程找不到、档案解析失败)→ 退出码 2。"""
def _expand_deep(v):
"""展开未展开的 REG_EXPAND_SZ 字面量(百分号形态的变量引用),最多四轮,防自指死循环。"""
for _ in range(4):
nv = os.path.expandvars(v)
if nv == v:
break
v = nv
return v
def harden_windows_env():
"""拉起 UV4 前补齐/展开子进程环境——堵住仓库根的「%SystemDrive%/」字面量垃圾目录。
Windows 的 known-folder 路径在注册表里是**可展开串**:ProgramData 的真实取值来自
「%SystemDrive%/ProgramData/…」,本机 HKCU Environment 里的 TMP/TEMP 实测就是未展开的
「%USERPROFILE%/AppData/Local/Temp」。父进程环境残缺时(典型:Agent 在 Node
child_process / run_code 里 spawn,子进程继承到一个**空环境**,SystemDrive、ProgramData、
USERPROFILE 一个都没有)这些串展不开、留在原地就成了**相对路径**,于是 UV4 / fontTools 类
字体工具按它在**当前目录**(= 仓库根)建出
「%SystemDrive%/ProgramData/Microsoft/Windows/Caches/*.db」(图标+字体缓存库,约 1 MB),
污染 git status(2026-10-26 同一会话两次复发;仓库 .gitignore 已入库兜住它,但正解是
**根本别让它长出来**)。故此处回填 + 展开,并打可定位的 WARN:ENV_SPARSE。
"""
if os.name != "nt":
return
filled, expanded = [], []
for key, val in list(os.environ.items()):
if isinstance(val, str) and "%" in val:
new = _expand_deep(val)
if new != val and "%" not in new:
os.environ[key] = new
expanded.append(key)
root = os.environ.get("SystemRoot") or os.environ.get("windir") or ""
root = _expand_deep(root)
if "%" in root or (root and not os.path.isdir(root)):
root = os.path.join(os.path.abspath(os.sep), "Windows") # 兜底:当前盘根下的 Windows 目录
drive = os.path.splitdrive(root)[0] or os.path.abspath(os.sep)[:2]
drive = drive.rstrip(os.sep) or "C:"
sysroot = drive + os.sep
user = _expand_deep(os.environ.get("USERPROFILE") or "")
if "%" in user:
user = ""
defaults = {
"SystemDrive": drive,
"SystemRoot": root,
"windir": root,
"ComSpec": os.path.join(root, "System32", "cmd.exe"),
"ProgramData": os.path.join(sysroot, "ProgramData"),
"ALLUSERSPROFILE": os.path.join(sysroot, "ProgramData"),
"ProgramFiles": os.path.join(sysroot, "Program Files"),
}
if not user:
guess = _expand_deep(os.path.expanduser("~"))
if not guess.startswith("~") and "%" not in guess:
user = guess
if user:
defaults["USERPROFILE"] = user
defaults["APPDATA"] = os.path.join(user, "AppData", "Roaming")
defaults["LOCALAPPDATA"] = os.path.join(user, "AppData", "Local")
defaults["TMP"] = os.path.join(user, "AppData", "Local", "Temp")
defaults["TEMP"] = defaults["TMP"]
for key, val in defaults.items():
if not os.environ.get(key):
os.environ[key] = val
filled.append(key)
# TMP/TEMP 必须落在真实目录:留未展开值 = 工具按字面量在当前目录建「%USERPROFILE%/…」垃圾树
td = os.environ.get("TEMP") or os.environ.get("TMP") or ""
if "%" in td or (td and not os.path.isdir(td)):
cand = []
lad = os.environ.get("LOCALAPPDATA") or ""
if lad and "%" not in lad:
cand.append(os.path.join(lad, "Temp"))
cand.append(os.path.join(sysroot, "Temp"))
picked = next((c for c in cand if os.path.isdir(c)), None)
if not picked:
for c in cand:
try:
os.makedirs(c, exist_ok=True)
picked = c
break
except OSError:
continue
if picked:
os.environ["TEMP"] = picked
os.environ["TMP"] = picked
filled.append("TEMP/TMP=" + picked)
if filled or expanded:
print("WARN: ENV_SPARSE: 子进程环境残缺/含未展开字面量 —— 已回填[{}] 已展开[{}]".format(
", ".join(sorted(filled)) or "-", ", ".join(sorted(expanded)) or "-"))
print(" 正解:用继承**完整环境**的 shell 跑编译(ops_bash / PowerShell),"
"别从 Node spawn(空 process.env)直接起 UV4 或字体门禁脚本")
def check_percent_literal_junk():
"""构建后自检:环境残缺时 Windows 会在**当前目录**按字面量建 %SystemDrive%/ 之类的垃圾树。
只告警、不自动删——删除口径见本 skill「构建副产物」一节:必须用 bash 引号形态,
绝不能用 cmd(cmd 会把 %SystemDrive% 展开成 C:,等于对整块系统盘下 rd /s /q)。
"""
for name in ("%SystemDrive%", "%USERPROFILE%", "%LOCALAPPDATA%", "%APPDATA%",
"%ProgramData%", "%TEMP%", "%TMP%"):
if os.path.isdir(name):
print("WARN: JUNK_DIR: 工作区出现字面量目录 {}(未展开的 known-folder 路径;"
".gitignore 已兜住,不得入库)".format(name))
print(" 清理(只用 bash 引号形态,禁用 cmd 以免展开成 C:): rm -rf -- '{}'".format(name))
class _ArgumentParser(argparse.ArgumentParser):
"""用法错误 → 退出码 3。
argparse 默认 `error()` 走 `SystemExit(2)`,与"环境错误=2"冲突,故覆盖之。
"""
def error(self, message):
self.print_usage(sys.stderr)
self.exit(3, "{}: error: {}\n".format(self.prog, message))
def find_config_file():
env = os.environ.get("DSH_SERIAL_CONFIG")
if env:
return Path(env)
cwd = Path.cwd()
candidates = [cwd / ".serialtest" / "config.json",
cwd / ".opencode" / "serial_config.json"]
for c in candidates:
if c.is_file():
return c
return candidates[0]
def load_config():
"""档案缺失不是错误;解析失败报错由调用方 exit 3。返回 (cfg, err)。"""
f = find_config_file()
if not f.is_file():
return {}, ""
try:
raw = json.loads(f.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as e:
return {}, "cannot parse {}: {}".format(f, e)
if not isinstance(raw, dict):
return {}, "config root is not an object: {}".format(f)
return raw, ""
def find_uv4(cli_arg, cfg):
"""UV4 解析。显式来源(--uv4/档案/环境变量)设了但无效 = 硬错误,不回退。"""
for label, val in (("--uv4", cli_arg),
("config 'uv4'", cfg.get("uv4")),
("$DSH_KEIL_UV4", os.environ.get("DSH_KEIL_UV4")),
("$DTU_KEIL_UV4", os.environ.get("DTU_KEIL_UV4"))):
if val:
if Path(val).is_file():
return Path(val), label
raise BuildEnvError(("BUILD_ENV_ERROR: {} is set but not a file: {} "
"(fix the value; no silent fallback)").format(label, val))
w = shutil.which("UV4.exe") or shutil.which("UV4")
if w:
return Path(w), "PATH"
raise BuildEnvError("BUILD_ENV_ERROR: UV4.exe not found "
"(--uv4 / config 'uv4' / $DSH_KEIL_UV4 / $DTU_KEIL_UV4 / PATH)")
def find_project(cli_arg, cfg):
if cli_arg:
p = Path(cli_arg)
if not p.is_file():
raise BuildEnvError("BUILD_ENV_ERROR: --project not found: {}".format(p))
return p
v = cfg.get("uvprojx")
if v:
p = Path(v)
if not p.is_file():
raise BuildEnvError("BUILD_ENV_ERROR: config 'uvprojx' not found: {}".format(p))
return p
hits = sorted(p for p in Path.cwd().rglob("*.uvprojx")
if not any(part.startswith(".") for part in p.parts))
if len(hits) == 1:
print("PROJ: auto-detected {}".format(hits[0]))
return hits[0]
if not hits:
raise BuildEnvError("BUILD_ENV_ERROR: no *.uvprojx under cwd "
"(--project or config 'uvprojx')")
raise BuildEnvError("BUILD_ENV_ERROR: multiple .uvprojx found, pick one "
"(--project or config 'uvprojx'):\n " +
"\n ".join(str(h) for h in hits))
# --------------------------------------------------------------- build 锁(自持自释)
def find_lock_tool():
"""定位 task-worktree 的 lock CLI(同级 skill)。找不到 → None(降级为不加锁)。
keil-build 与 task-worktree 是兄弟目录:.dsh/skills/{keil-build,task-worktree}/
故从本文件上溯 2 级即到 skills/。找不到不报错——保持本 skill 可独立搬运。
"""
try:
skills = Path(__file__).resolve().parents[2]
except IndexError:
return None
cand = skills / "task-worktree" / "scripts" / "task_worktree.py"
return cand if cand.is_file() else None
def plugin_tree_owner(cwd: Path):
"""DSH worktree 插件的树(仓库内 .dsh-worktrees/worktree/<name>)→ 持有者名 pw:<name>。
2026-10-29 与 task-worktree skill 的 `plugin` 子命令对齐:插件建的树不在 _wt/ 约定里,
以前在这里跑构建,持有者名一律落成 "build-cli"——`lock status` 看不出是谁在占 UV4,
多个插件树 / 主树并发时就只剩猜。名字里的多段斜杠压平成 -,与
task_worktree.py 的 plugin_lock_owner() 完全一致(同一个可 grep 的标识)。
"""
parts = cwd.parts
if ".dsh-worktrees" not in parts:
return None
tail = parts[parts.index(".dsh-worktrees") + 1:]
if tail and tail[0] == "worktree":
tail = tail[1:]
return "pw:" + "-".join(tail) if tail else "pw:?"
def default_lock_task():
"""持有者名:$DTU_TASK_ID > cwd 反推 `_wt/<ID>-<slug>/` 的 `<ID>` > 插件树 `pw:<name>` > "build-cli"。"""
v = os.environ.get("DTU_TASK_ID")
if v:
return v
try:
cwd = Path.cwd().resolve()
except OSError:
return "build-cli"
for p in (cwd, *cwd.parents):
if p.parent.name == "_wt":
return p.name.split("-", 1)[0] or p.name
return plugin_tree_owner(cwd) or "build-cli"
def build_lock_ttl_hours(timeout_s):
"""build 锁自报的陈旧窗口(小时)≈ UV4 超时上限 + 15min 余量,下限 0.5h。
这样「崩溃/被强杀」留下的锁,其自动接管窗口与本次构建的真实上限同量级,
而不是全局 6h —— 编译很快,锁的兜底窗口也该短。
"""
try:
t = max(0.0, float(timeout_s))
except (TypeError, ValueError):
t = 600.0
return max(0.5, t / 3600.0 + 0.25)
def lock_cli(tool, action, task, wait=0, force=False, ttl=None):
"""调 task-worktree 的 `lock <action> build --task <task>`。返回 (ok, 输出)。"""
cmd = [sys.executable, str(tool), "lock", action, LOCK_NAME, "--task", task]
if action == "acquire" and wait > 0:
cmd += ["--wait", str(wait)]
if action == "acquire" and ttl:
cmd += ["--ttl", "{:.4f}".format(ttl)]
if force:
cmd += ["--force"]
try:
r = subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
stdin=subprocess.DEVNULL)
except OSError as e:
return False, "lock CLI 调用失败:{}".format(e)
return r.returncode == 0, (r.stdout + r.stderr).strip()
def acquire_build_lock(args):
"""返回 (state, tool, task, note)。state ∈ ok / caller / skipped / busy。"""
if args.no_lock:
return "skipped", None, None, "--no-lock"
held = [x.strip() for x in (os.environ.get("DTU_LOCK_HELD") or "").split(",")
if x.strip()]
if LOCK_NAME in held:
return "caller", None, None, "调用方已持有(DTU_LOCK_HELD)"
tool = find_lock_tool()
if tool is None:
return "skipped", None, None, "未找到 task-worktree skill"
task = args.lock_task or default_lock_task()
ok, out = lock_cli(tool, "acquire", task, wait=args.lock_wait,
ttl=build_lock_ttl_hours(args.timeout))
if not ok:
return "busy", tool, task, out
return "ok", tool, task, ""
def run_build(args):
"""真正的构建主体(锁由 main 负责)。返回退出码。"""
cfg, err = load_config()
if err:
raise BuildEnvError("BUILD_ENV_ERROR: {}".format(err))
proj = find_project(args.project, cfg)
uv4, src = find_uv4(args.uv4, cfg)
target = args.target or cfg.get("uv_target") or ""
log = Path(args.log or cfg.get("build_log") or (proj.parent / "build_log.txt"))
print("UV4: {} ({})".format(uv4, src))
print("PROJ: {} target={}".format(proj, target or "(default)"))
print("LOG: {}".format(log))
# 环境残缺守卫:先补齐/展开,否则 UV4 会在仓库根按字面量建 %SystemDrive%/ 垃圾树
harden_windows_env()
# UV4 单实例陷阱:IDE 开着时 -b 可能被转发(退出码丢失、GUI 弹出)——只 WARN
try:
tl = subprocess.run(["tasklist", "/fi", "imagename eq UV4.exe", "/nh"],
capture_output=True, timeout=10)
if b"UV4.exe" in (tl.stdout or b""):
print("WARN: UV4 IDE is running - this build may be forwarded to it. "
"Close the IDE for a deterministic CLI build.")
except (OSError, subprocess.TimeoutExpired):
pass
# 先删旧日志:UV4 -o 只在真跑构建时写日志,残留会被当成本次结果
try:
log.unlink()
except FileNotFoundError:
pass
cmd = [str(uv4), "-b", str(proj), "-j0", "-o", str(log)]
if target:
cmd.insert(3, "-t" + target)
try:
proc = subprocess.run(cmd, capture_output=True, timeout=args.timeout)
rc = proc.returncode
except subprocess.TimeoutExpired:
print("BUILD_FAIL: UV4 timed out after {}s (hung or forwarded to IDE?)".format(args.timeout))
return 1
print("UV4_RC: {}".format(rc))
check_percent_literal_junk()
if not log.is_file():
print("BUILD_FAIL: no build log produced - UV4 did not run or hung")
return 1
text = log.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
errs = [l for l in lines if "error:" in l.lower()]
if errs:
print("BUILD_FAIL: {} error line(s) in log:".format(len(errs)))
for l in errs[:20]:
print(" | {}".format(l.strip()))
if len(errs) > 20:
print(" ... {} more".format(len(errs) - 20))
return 1
if "0 Error(s), 0 Warning(s)" not in text:
print("BUILD_FAIL: no '0 Error(s), 0 Warning(s)' marker in log "
"(warnings present or build incomplete). Tail:")
for l in lines[-15:]:
print(" | {}".format(l.strip()))
return 1
for l in lines:
if l.strip().startswith("Program Size:"):
print(l.strip())
print("BUILD_OK: 0 errors, 0 warnings")
return 0
def main(argv=None):
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = _ArgumentParser(description="Keil UV4 CLI build + verdict (pure stdlib)")
ap.add_argument("--project", default=None, help="*.uvprojx (default: config/auto-detect)")
ap.add_argument("--uv4", default=None, help="UV4.exe path")
ap.add_argument("--target", default=None, help="build target name (-t; default: project default)")
ap.add_argument("--log", default=None, help="build log path (default: <proj dir>/build_log.txt)")
ap.add_argument("--timeout", type=int, default=600, help="UV4 timeout seconds")
ap.add_argument("--lock-task", default=None,
help="build 锁持有者名 (default: $DTU_TASK_ID / _wt/<ID>-* / build-cli)")
ap.add_argument("--lock-wait", type=int, default=0,
help="build 锁等待秒数 (0=不等待,占用即退出码 4)")
ap.add_argument("--no-lock", action="store_true",
help="完全不加 build 锁(危险:并发 -b 会互相干扰)")
args = ap.parse_args(argv)
state, tool, task, note = acquire_build_lock(args)
if state == "busy":
print("BUILD_LOCK_BUSY: build 锁被占用(UV4 不能并发构建)")
if note:
for ln in note.splitlines():
print("BUILD_LOCK_BUSY: {}".format(ln))
print("BUILD_LOCK_BUSY: 用 --lock-wait N 等待,或等对方 release")
return 4
if state == "ok":
print("LOCK: 已获取 build 锁(task={})".format(task))
elif state == "caller":
print("LOCK: 由调用方持有,跳过自持自释")
else:
print("WARN: 本次构建**未加 build 锁**({})——并发 -b 会互相干扰".format(note))
try:
return run_build(args)
except BuildEnvError as e:
print(str(e))
return 2
finally:
if state == "ok":
ok, out = lock_cli(tool, "release", task)
print("LOCK: {}".format(out or ("已释放 build 锁" if ok else "释放 build 锁失败")))
if __name__ == "__main__":
sys.exit(main())