fix(keil-build): 定性并挡住 %SystemDrive% 字面量垃圾目录(并发会话在飞工作)

@build 与 fontTools 类脚本在残缺环境里起子进程时(run_code 的 Node
进程 process.env 为空),Windows known-folder 的**可展开串**展开不了
→ 变成相对路径,于是仓库根长出 %SystemDrive%/ProgramData/Microsoft/
/Windows/Caches/(约 1 MB 缓存的字面量复刻),它不是任何人在飞的改动。

- SKILL.md:新增「构建副产物」一节——症状 / 根因(三条现场证据)/
  清理(只用 bash 引号形态,明令禁止 rd /s /q %SystemDrive%,cmd 会
  把它展开成 C: 等于对系统盘下令递归强删)/ 两层防复发(.gitignore
  已入库 + 脚本守卫),并在坑表加一条速查
- keil_build.py +115 行:拉起 UV4 前 harden_windows_env() 回填缺失的
  SystemDrive/SystemRoot/ProgramData/USERPROFILE/TMP 等并就地展开残留
  的 %VAR% 字面量,命中打 WARN: ENV_SPARSE;构建后
  check_percent_literal_junk() 见到字面量目录只打 WARN: JUNK_DIR 并
  给出安全删除命令(守卫是兜底不是许可:@build 仍须走继承完整环境
  的 shell,删目录也不由构建脚本代劳)

py_compile 通过、--help 正常。本笔是并行会话在飞的改动,按用户
「把所有内容都 commit」的指示代为落盘;要退回:git reset --soft HEAD~1。
This commit is contained in:
2026-10-28 22:22:59 +08:00
parent 16be01915d
commit 261bd9fd31
2 changed files with 164 additions and 2 deletions
+49 -2
View File
@@ -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 -- <uvprojx>` 丢弃;只有真的增删了源文件(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`,换写法即可;
@@ -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")