diff --git a/.dsh/skills/keil-build/SKILL.md b/.dsh/skills/keil-build/SKILL.md index b1611e2a..a9a8bda2 100644 --- a/.dsh/skills/keil-build/SKILL.md +++ b/.dsh/skills/keil-build/SKILL.md @@ -1,7 +1,7 @@ --- name: keil-build -description: Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用):UV4 -b 构建 → "0 错误 0 警告"判定 → build_log 定位报错。封装入口约定(PowerShell 与 bash/ops_bash 两种调用形态及各自的 exit 2 归属)、退出码判读、UV4 改写工程文件的入库陷阱。 -whenToUse: 需要编译 Keil MDK 工程、跑 `@build` 验证代码改动是否 0 错误 0 警告、解读 UV4 构建日志与退出码 2/4,或在 bash / `ops_bash` 里执行 `@build` 报语法错(syntax error near unexpected token)时。 +description: Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用):UV4 -b 构建 → "0 错误 0 警告"判定 → build_log 定位报错。封装入口约定(PowerShell 与 bash/ops_bash 两种调用形态及各自的 exit 2 归属)、退出码判读、UV4 改写工程文件的入库陷阱、构建副产物「%SystemDrive% 字面量垃圾目录」的定性与清理/入库排除。 +whenToUse: 需要编译 Keil MDK 工程、跑 `@build` 验证代码改动是否 0 错误 0 警告、解读 UV4 构建日志与退出码 2/4,或在 bash / `ops_bash` 里执行 `@build` 报语法错(syntax error near unexpected token),或构建后 `git status` 冒出 `%SystemDrive%` 目录时。 --- # keil-build — Keil 命令行编译(独立可运行 skill) @@ -81,6 +81,49 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被 - **只 build 锁自动;`bench` 锁仍须 Agent 手工持**(它是**会话作用域**,跨 烧录→`@test`→探针多条命令,逐命令自动释放会让别的任务插进来扰动设备)。 +## 构建副产物:`%SystemDrive%` 字面量垃圾目录(2026-10-26 两次复发 → 2026-10-28 定性并加守卫) + +**症状**:`@build`(或 `scripts/check_font_charset.py` 这类 fontTools 字体工具)跑完,仓库根 +冒出未跟踪目录 `%SystemDrive%/ProgramData/Microsoft/Windows/Caches/`,里面是几个 `.db` +(`cversions.2.db` + `{6AF0698E-…}` 图标缓存 + `{DDF571F2-…}` 字体缓存,合计约 1 MB)—— +就是本机 `C:\ProgramData\Microsoft\Windows\Caches` 那份缓存被**按字面量原地复刻**了一份。 + +**根因(2026-10-28 实测)**:Windows 的 known-folder 路径在注册表里是**可展开串**;展开不了, +它就变成一个**相对路径**,于是按「当前目录」(= 仓库根)建出来。三条现场证据: + +```bash +reg.exe query HKCU\Environment # 本机 TMP/TEMP = REG_EXPAND_SZ %USERPROFILE%\AppData\Local\Temp +env | wc -l # ops_bash 里 = 74(完整环境) +node -e "console.log(Object.keys(process.env).length)" # run_code 里 = 0(空环境!) +``` + +`run_code` 的 Node 进程 `process.env` 是**空的**,从它里面 `spawn` 出去的 UV4 / 字体脚本就拿不到 +`SystemDrive`/`ProgramData`/`ALLUSERSPROFILE`;实测 `os.path.expandvars("%SystemDrive%\ProgramData")` +原样返回、且 `os.path.isabs()` = **False** → 目录就长在工作区里(同族还会冒出 +`%USERPROFILE%/AppData/Local/Temp`)。**正解不是「让用户去设环境变量」**,是别在残缺环境里跑构建(见第 2 条)。 + +**清理(有安全阀)**: + +- ⚠ **绝不用 cmd / PowerShell 直接删**:`rd /s /q %SystemDrive%` 会被 cmd **展开成 `C:`**—— + 等于对整块系统盘下令递归强删且不带确认。 +- ✅ 只用 bash 引号形态(`%` 在 bash 里没有特殊含义):先 `find '%SystemDrive%' -type l` 确认不是 + reparse point / 符号链接,再 `rm -rf -- '%SystemDrive%'`;或 Node + `fs.rmSync("%SystemDrive%", { recursive: true, force: true })`。 +- 它是**工具副产物**,不是谁在飞的改动——直接删,别 stash、更别提交(提交它 = 把本机缓存塞进历史)。 + +**让它不再污染工作区(两层,均已落地、可核验)**: + +1. **进不了版本库**:`.gitignore:167` 的 `%SystemDrive%/`(2026-10-26 入库,注释里就写着复发证据)。 + 核验三步(2026-10-28 复跑全绿):造一份同名目录 + `.db` → `git status --porcelain` 零命中 → + `git check-ignore -v <路径>` 落在 `.gitignore:167:%SystemDrive%/` → `git add -A --dry-run` 零暂存。 +2. **根本别再长出来**:本 skill 脚本 2026-10-28 起在拉起 UV4 前跑 `harden_windows_env()`——回填 + `SystemDrive`/`SystemRoot`/`windir`/`ComSpec`/`ProgramData`/`ALLUSERSPROFILE`/`ProgramFiles` 与 + `USERPROFILE`/`APPDATA`/`LOCALAPPDATA`/`TMP`/`TEMP`,就地展开环境里未展开的 `%VAR%` 字面量, + 并把 `TMP`/`TEMP` 指到真实目录;命中即打 `WARN: ENV_SPARSE:`。构建后再跑 + `check_percent_literal_junk()`,看到 `%SystemDrive%`/`%USERPROFILE%` 一类字面量目录就打 + `WARN: JUNK_DIR:`(**只告警不自动删**——删目录不该由构建脚本代劳)。守卫是兜底不是许可: + **`@build` 仍要走 `ops_bash` / PowerShell(继承完整环境)**,非在 Node 里 spawn 就把 `env` 传全。 + ## 本仓库 **`@build` = 本 skill**(2026-10-10 起为仓库文档口径):在仓库根目录跑 @@ -103,6 +146,10 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被 判断:`git diff` 里只见 XML 格式/`PackID` 差异而**无源文件挂载增删** → `git checkout -- ` 丢弃;只有真的增删了源文件(grep 文件名计数变化)才提交。 同目录的 `*.uvoptx` / `*.uvguix.*` 是 IDE 个人设置,已在 `.gitignore` 里,不会露出来。 +- **构建后仓库根冒出 `%SystemDrive%/`** = UV4 / 字体工具在残缺环境(缺 `SystemDrive`、`ProgramData`) + 里按**未展开的 known-folder 字面量**建出来的**相对路径**目录,不是任何人在飞的改动: + `rm -rf -- '%SystemDrive%'`(bash 引号形态)删掉即可,**严禁 `rd /s /q %SystemDrive%`——cmd 会把它 + 展开成 `C:`**;`.gitignore` 已兜住 + 脚本已加 `ENV_SPARSE`/`JUNK_DIR` 守卫 → 见「构建副产物」一节。 - 报错定位读日志别只看终端回显:本仓库 = `MDK-ARM\build_log.txt`。 - **exit 2 先分三种再动手**(2026-10-10 / 2026-10-26 实测):① **bash 语法错**——stderr 有 `syntax error near unexpected token`,是把 PowerShell 形态丢进了 `ops_bash`,换写法即可; diff --git a/.dsh/skills/keil-build/scripts/keil_build.py b/.dsh/skills/keil-build/scripts/keil_build.py index 8baf8571..a8ea4531 100644 --- a/.dsh/skills/keil-build/scripts/keil_build.py +++ b/.dsh/skills/keil-build/scripts/keil_build.py @@ -64,6 +64,117 @@ 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 跑 @build(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。 @@ -242,6 +353,9 @@ def run_build(args): 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"], @@ -268,6 +382,7 @@ def run_build(args): 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")