# -*- coding: utf-8 -*- r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引用的文件"(2026-09-16 实炸)。 ## 为什么要这个模块 交付包 v0.4.0 第一次打出来时, 目标机 (现场安装目录 <安装目录>) 双击 `start.bat` 报: 无法找到脚本文件 "<安装目录>\start_hidden.vbs" 原因不是 start.bat 写错, 而是打包器的 `INCLUDE_FILES` 是**手工清单**: 新加根目录文件 `start_hidden.vbs`(当时的无窗口启动器) 时忘了把它写进去, 于是包里 `start.bat` 指向一个 不存在的文件。手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**: 凡是包内入口脚本 (.bat/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**, 它就**必须**出现在包里(或被打包器自动补进去)。 · 本机不存在的路径 (`.venv\Scripts\pythonw.exe`, `python.exe`, `vendor\ollama\OllamaSetup.exe` 之类) 按"目标机安装后才有的东西"忽略 —— 它们本来就不该在包里; · `%~dp0xxx` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断; · 只做**存在性**判断(不做内容比对): 这份检查的目的只有一个 —— 包内不留"指向空气"的入口。 ★ 该 `.vbs` 已于 2026-09-16 按用户令删除 (无窗口启动改用 `pythonw.exe` + Python 启动器), 但这份检查留着: 它守的是"入口引用的文件必须齐全"这条性质, 与用什么语言实现无关。 单一实现, 三处共用: `scripts/pack_dist.py`(打包时 + 开箱验证)、`guanlan.py check`(装机后自检)。 """ from __future__ import annotations import pathlib import re # 包内入口脚本 (相对安装根)。这些是"用户会直接双击 / 直接敲"的东西。 # ★ 2026-09-16 用户令"把 VBScript 替换掉"之后 `start_hidden.vbs` 已删除: 无窗口启动改由 # `pythonw.exe` + `scripts/guanlan_start_hidden.py` 承担, 不再经过 Windows 脚本宿主。 # ★ 2026-09-17 用户令"实现打包功能": `pack.bat` / `pack.sh` 也是入口, 一并纳入闭合检查。 # ★ 2026-09-17 用户令"增加卸载脚本": `uninstall.bat` / `uninstall.sh` 同样是用户会直接双击的入口 # —— 它引用的 `scripts\guanlan_uninstall.py` 少打进包, 后果就是"装得上、卸不掉"。 ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat', 'pack.bat', 'pack.sh', 'install.bat', 'install.ps1', 'install.sh', 'uninstall.bat', 'uninstall.sh') + tuple( # ★2026-09-28 P9 新结构:每个模块目录自带的入口包装脚本(内部调安装根统一入口)同样纳入闭合检查 f'{_m}/{_s}' for _m in ('app_common', 'app_ETL', 'app_algorithmModel', 'app_ontology', 'app_backEnd', 'app_frontEnd', 'app_qualityGate') for _s in ('install.bat', 'install.sh', 'uninstall.bat', 'uninstall.sh', 'run.bat', 'run.sh')) # 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件) _REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+' r'(?:[\\/][A-Za-z0-9_.\u4e00-\u9fff-]+)*' r'\.(?:bat|vbs|ps1|sh|py|txt|json|yaml|yml|md|html|zip|csv|parquet)') _STRIP = ('dp0', 'DP0') # %~dp0 / %dp0 展开后的残前缀 def _candidates(tok: str): """一个 token 可能对应的"安装根相对路径"候选 (从最具体到最宽松)。""" t = tok.replace('\\', '/').lstrip('./') cands = [t] for p in _STRIP: if t.startswith(p) and len(t) > len(p): cands.append(t[len(p):]) parts = t.split('/') cands += ['/'.join(parts[i:]) for i in range(1, len(parts))] return [c for c in dict.fromkeys(cands) if c] def _norm(name: str) -> str: return name.replace('\\', '/').lstrip('./').lower() def entry_refs(path: pathlib.Path) -> set[str]: """一个入口脚本里出现的全部"像文件"的路径 token。""" try: txt = path.read_text(encoding='utf-8-sig', errors='replace') except OSError: return set() return {m.group(0) for m in _REF.finditer(txt)} def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]: """→ {入口脚本: {安装根相对路径…}} —— 只留**本机源码树里真实存在**的那些。 本机不存在的 token 直接丢掉: 它们是"目标机安装后才有的"(venv/系统 exe)或纯噪声(`sys.exe`)。 """ root = pathlib.Path(root) out: dict[str, set[str]] = {} for name in ENTRY_FILES: p = root / name if not p.is_file(): continue found: set[str] = set() for tok in entry_refs(p): for cand in _candidates(tok): if (root / cand).is_file(): found.add(_norm(cand)) break if found: out[name] = found return out _SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor', 'outputs', 'data', 'logs', 'run'} def _iter_scripts(root: pathlib.Path, exts: tuple[str, ...]): """自带脚本文件的遍历 (跳过 .venv/node_modules/outputs 等大目录)。""" import os for dp, dn, fns in os.walk(root): dn[:] = [d for d in dn if d not in _SKIP_WALK] for fn in fns: if fn.lower().endswith(exts): yield pathlib.Path(dp) / fn def encoding_problems(root: pathlib.Path) -> list[str]: r"""入口脚本的**编码守则**检查 —— 这几条都是实机踩出来的, 违反了就是"双击/一跑即报错"。 · `install.ps1` 必须 **UTF-8 带 BOM + CRLF**: Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI 代码页(中文机 = GBK)解码 → 中文变乱码, 相邻的转义反引号被吞 → 引号不配对 → 级联 ParserError (报在看起来没问题的行上)。2026-09-16 真的发出去过一个这样的包: 开箱验证里 `install 退出码 1, 耗时 1s`, 报的正是 `The '<' operator is reserved for future use`。 ★ 起因很隐蔽: 编辑工具保存 .ps1 **不会替你保留 BOM** —— 在规范化之后再改一次文件, BOM 就没了。 所以这条必须由机器守, 不能靠"我记得"。 · `.bat` 必须 CRLF 且**不能**带 BOM: 批处理按字节读, 行尾 LF 出怪问题; 开头 BOM 会让第一行 (`@echo off`) 失效并被当命令执行。 · `.sh` 必须 LF: CRLF 会让 `#!/bin/sh` 的 shebang 与每个词尾都粘上一个 `\r` —— POSIX 里只有空格/Tab/换行分隔词, 所以 `set -e` 变成 `set "-e\r"`(非法选项)、 `RT=""` 变成 `RT="\r"`(后面对 `-n "$RT"` 的判断直接翻面)。 2026-09-16 实测发现 `install.sh` **从写出来那天起就是 CRLF**(git HEAD blob 就是 CRLF, 不是某个编辑器改的), 也就是说此前所有交付包的 Linux/macOS 安装脚本都是坏的。已改 LF, 并用 .gitattributes 钉住。 """ root = pathlib.Path(root) bad: list[str] = [] for p in _iter_scripts(root, ('.ps1',)): b = p.read_bytes() rel = p.relative_to(root).as_posix() if b[:3] != b'\xef\xbb\xbf': bad.append(f'{rel} 缺 UTF-8 BOM (PS 5.1 会按 ANSI/GBK 解码 → 中文乱码 + ParserError)') if b.count(b'\n') != b.count(b'\r\n'): bad.append(f'{rel} 有裸 LF (PowerShell 脚本按 CRLF 交付)') for p in _iter_scripts(root, ('.bat',)): b = p.read_bytes() rel = p.relative_to(root).as_posix() if b.count(b'\n') != b.count(b'\r\n'): bad.append(f'{rel} 有裸 LF (.bat 行尾必须是 CRLF)') if b[:3] == b'\xef\xbb\xbf': bad.append(f'{rel} 带 UTF-8 BOM (.bat 不能有 BOM, 否则第一行失效)') # ★ 2026-09-17: .bat 必须**纯 ASCII**。原因: 批处理的解析与输出都跟控制台代码页绑在一起 # (cp936 / cp65001 / 系统区域设置)。现场实测: 中文注释行被 cmd 拆开当成命令执行 # ("'本身是控制台程序' 不是内部或外部命令") —— 放在注释里都不安全。 # 所有中文说明改放 README / docs; .bat 只留英文。 nonascii = [x for x in b if x > 0x7F] if nonascii: bad.append(f'{rel} 含 {len(nonascii)} 个非 ASCII 字节 (.bat 必须纯 ASCII: 中文在批处理里会随代码页被曲解, ' f'现场出现过"注释被当命令执行"; 中文说明放 README/docs)') txt = b.decode('utf-8', 'replace') if '<<' in txt: bad.append(f'{rel} 含 `<<` —— 在批处理里是重定向, `echo ` 这种写法会直接报错') # `>` 只在**非注释行**上查重定向语义: 本机实测 `rem` 行里的 `>` 不会被当重定向 (但 `echo ` 会), # 所以注释行放行 —— 免得逼着人把说明写成天书, 同时保住真正会炸的那种写法。 for i, ln in enumerate(txt.splitlines(), 1): st = ln.strip() if not st or st.lower().startswith(('rem', '::')): continue if re.search(r'>{1,2}(?!\s|"|nul\b|&)', ln): bad.append(f'{rel} 第 {i} 行的 `>` 后面不是重定向目标 ' f'(.bat 里 `>` 是重定向符, 想显示要用 ^> 转义)') break for p in _iter_scripts(root, ('.sh',)): b = p.read_bytes() if b'\r\n' in b: bad.append(f'{p.relative_to(root).as_posix()} 含 CRLF ' f'(POSIX shell 需要 LF: 词尾会粘 \\r, 判断与 shebang 都会出错)') return bad # 装机时才有 / 只在本机有效的文件 —— 入口脚本**可以**引用它们, 但它们**不该在包里** # (所以闭合检查必须放行, 否则这条守卫会得出相反的结论, 见 missing_refs 的注释)。 MACHINE_ARTIFACTS = ('install-info.json',) # 安装时写的"本机装没装/哪一版"凭据 def _is_machine_artifact(rel: str) -> bool: name = rel.replace('\\', '/').split('/')[-1].lower() return name in MACHINE_ARTIFACTS or name.endswith('.lnk') def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]: """→ [(入口脚本, 缺失的安装根相对路径)] —— provided=None 时按 root 下的真实文件判断。 provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。 ★ 2026-09-17: **放行"装机产物"** (见 MACHINE_ARTIFACTS)。这条守卫的本意是"包内不许有指向空气的 入口"(曾经漏带 start_hidden.vbs, 目标机双击即报错), 而 `install-info.json` 是**故意不发**的: 它是安装时才写的本机凭据, 装之前本来就该不在, 而 `install.ps1`/`install.sh` 的版本检查那一步 正是靠"文件在不在"判断装没装的。原先这两件事会打架 —— 把 install-info.json 排除出包之后, 闭合检查反过来报"install.ps1 引用了它但包里没有 ⇒ 不能交付", 于是包被闸删掉。 判据修正为: 引用的文件必须"在包内**或在目标机上必然存在**(装机产物/安装时自建)"。 """ have = None if provided is None else {_norm(x.rstrip('/')) for x in provided} bad: list[tuple[str, str]] = [] root = pathlib.Path(root) for entry, refs in sorted(referenced_tree_files(root).items()): for rel in sorted(refs): if _is_machine_artifact(rel): continue ok = (rel in have) if have is not None else (root / rel).is_file() if not ok: bad.append((entry, rel)) return bad def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]: """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。 只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管, 缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。 ★ 装机产物 (`install-info.json` / `*.lnk`) 不在此列 —— 它们本机有, 但**不该随包**(见 MACHINE_ARTIFACTS)。 """ got = {_norm(x) for x in already} root = pathlib.Path(root) add: list[pathlib.Path] = [] for _, refs in referenced_tree_files(root).items(): for rel in refs: if '/' in rel or rel in got or _is_machine_artifact(rel): continue p = root / rel if p.is_file(): add.append(p) got.add(rel) return sorted(set(add), key=lambda p: p.name) if __name__ == '__main__': # 直接跑 = 对当前安装目录做一次闭合检查 + 编码守则检查 import sys r = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve() refs = referenced_tree_files(r) miss = missing_refs(r) enc = encoding_problems(r) for e in sorted(refs): print(f' {e:18s} → ' + ', '.join(sorted(refs[e]))) for e, rel in miss: print(f'[X] {e} 引用了 {rel}, 但 {r} 下没有') for m in enc: print(f'[X] 编码守则: {m}') if not miss: print(f'[OK] 入口脚本引用闭合 ({len(refs)} 个入口, ' f'{sum(len(v) for v in refs.values())} 条引用全部在位)') if not enc: print('[OK] 入口脚本编码守则 (install.ps1 = UTF-8 BOM + CRLF; .bat = CRLF 无 BOM; .sh = LF)') sys.exit(1 if (miss or enc) else 0)