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
@@ -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")