entry_refs.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. # -*- coding: utf-8 -*-
  2. r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引用的文件"(2026-09-16 实炸)。
  3. ## 为什么要这个模块
  4. 交付包 v0.4.0 第一次打出来时, 目标机 (现场安装目录 <安装目录>) 双击 `start.bat` 报:
  5. 无法找到脚本文件 "<安装目录>\start_hidden.vbs"
  6. 原因不是 start.bat 写错, 而是打包器的 `INCLUDE_FILES` 是**手工清单**: 新加根目录文件
  7. `start_hidden.vbs`(当时的无窗口启动器) 时忘了把它写进去, 于是包里 `start.bat` 指向一个
  8. 不存在的文件。手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**:
  9. 凡是包内入口脚本 (.bat/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**,
  10. 它就**必须**出现在包里(或被打包器自动补进去)。
  11. · 本机不存在的路径 (`.venv\Scripts\pythonw.exe`, `python.exe`, `vendor\ollama\OllamaSetup.exe` 之类)
  12. 按"目标机安装后才有的东西"忽略 —— 它们本来就不该在包里;
  13. · `%~dp0xxx` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断;
  14. · 只做**存在性**判断(不做内容比对): 这份检查的目的只有一个 —— 包内不留"指向空气"的入口。
  15. ★ 该 `.vbs` 已于 2026-09-16 按用户令删除 (无窗口启动改用 `pythonw.exe` + Python 启动器),
  16. 但这份检查留着: 它守的是"入口引用的文件必须齐全"这条性质, 与用什么语言实现无关。
  17. 单一实现, 三处共用: `scripts/pack_dist.py`(打包时 + 开箱验证)、`guanlan.py check`(装机后自检)。
  18. """
  19. from __future__ import annotations
  20. import pathlib
  21. import re
  22. # 包内入口脚本 (相对安装根)。这些是"用户会直接双击 / 直接敲"的东西。
  23. # ★ 2026-09-16 用户令"把 VBScript 替换掉"之后 `start_hidden.vbs` 已删除: 无窗口启动改由
  24. # `pythonw.exe` + `scripts/guanlan_start_hidden.py` 承担, 不再经过 Windows 脚本宿主。
  25. # ★ 2026-09-17 用户令"实现打包功能": `pack.bat` / `pack.sh` 也是入口, 一并纳入闭合检查。
  26. # ★ 2026-09-17 用户令"增加卸载脚本": `uninstall.bat` / `uninstall.sh` 同样是用户会直接双击的入口
  27. # —— 它引用的 `scripts\guanlan_uninstall.py` 少打进包, 后果就是"装得上、卸不掉"。
  28. ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat', 'pack.bat', 'pack.sh',
  29. 'install.bat', 'install.ps1', 'install.sh', 'uninstall.bat', 'uninstall.sh') + tuple(
  30. # ★2026-09-28 P9 新结构:每个模块目录自带的入口包装脚本(内部调安装根统一入口)同样纳入闭合检查
  31. f'{_m}/{_s}'
  32. for _m in ('app_common', 'app_ETL', 'app_algorithmModel', 'app_ontology', 'app_backEnd',
  33. 'app_frontEnd', 'app_qualityGate')
  34. for _s in ('install.bat', 'install.sh', 'uninstall.bat', 'uninstall.sh', 'run.bat', 'run.sh'))
  35. # 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件)
  36. _REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+'
  37. r'(?:[\\/][A-Za-z0-9_.\u4e00-\u9fff-]+)*'
  38. r'\.(?:bat|vbs|ps1|sh|py|txt|json|yaml|yml|md|html|zip|csv|parquet)')
  39. _STRIP = ('dp0', 'DP0') # %~dp0 / %dp0 展开后的残前缀
  40. def _candidates(tok: str):
  41. """一个 token 可能对应的"安装根相对路径"候选 (从最具体到最宽松)。"""
  42. t = tok.replace('\\', '/').lstrip('./')
  43. cands = [t]
  44. for p in _STRIP:
  45. if t.startswith(p) and len(t) > len(p):
  46. cands.append(t[len(p):])
  47. parts = t.split('/')
  48. cands += ['/'.join(parts[i:]) for i in range(1, len(parts))]
  49. return [c for c in dict.fromkeys(cands) if c]
  50. def _norm(name: str) -> str:
  51. return name.replace('\\', '/').lstrip('./').lower()
  52. def entry_refs(path: pathlib.Path) -> set[str]:
  53. """一个入口脚本里出现的全部"像文件"的路径 token。"""
  54. try:
  55. txt = path.read_text(encoding='utf-8-sig', errors='replace')
  56. except OSError:
  57. return set()
  58. return {m.group(0) for m in _REF.finditer(txt)}
  59. def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]:
  60. """→ {入口脚本: {安装根相对路径…}} —— 只留**本机源码树里真实存在**的那些。
  61. 本机不存在的 token 直接丢掉: 它们是"目标机安装后才有的"(venv/系统 exe)或纯噪声(`sys.exe`)。
  62. """
  63. root = pathlib.Path(root)
  64. out: dict[str, set[str]] = {}
  65. for name in ENTRY_FILES:
  66. p = root / name
  67. if not p.is_file():
  68. continue
  69. found: set[str] = set()
  70. for tok in entry_refs(p):
  71. for cand in _candidates(tok):
  72. if (root / cand).is_file():
  73. found.add(_norm(cand))
  74. break
  75. if found:
  76. out[name] = found
  77. return out
  78. _SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
  79. 'outputs', 'data', 'logs', 'run'}
  80. def _iter_scripts(root: pathlib.Path, exts: tuple[str, ...]):
  81. """自带脚本文件的遍历 (跳过 .venv/node_modules/outputs 等大目录)。"""
  82. import os
  83. for dp, dn, fns in os.walk(root):
  84. dn[:] = [d for d in dn if d not in _SKIP_WALK]
  85. for fn in fns:
  86. if fn.lower().endswith(exts):
  87. yield pathlib.Path(dp) / fn
  88. def encoding_problems(root: pathlib.Path) -> list[str]:
  89. r"""入口脚本的**编码守则**检查 —— 这几条都是实机踩出来的, 违反了就是"双击/一跑即报错"。
  90. · `install.ps1` 必须 **UTF-8 带 BOM + CRLF**: Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI
  91. 代码页(中文机 = GBK)解码 → 中文变乱码, 相邻的转义反引号被吞 → 引号不配对 → 级联 ParserError
  92. (报在看起来没问题的行上)。2026-09-16 真的发出去过一个这样的包: 开箱验证里
  93. `install 退出码 1, 耗时 1s`, 报的正是 `The '<' operator is reserved for future use`。
  94. ★ 起因很隐蔽: 编辑工具保存 .ps1 **不会替你保留 BOM** —— 在规范化之后再改一次文件, BOM 就没了。
  95. 所以这条必须由机器守, 不能靠"我记得"。
  96. · `.bat` 必须 CRLF 且**不能**带 BOM: 批处理按字节读, 行尾 LF 出怪问题; 开头 BOM 会让第一行
  97. (`@echo off`) 失效并被当命令执行。
  98. · `.sh` 必须 LF: CRLF 会让 `#!/bin/sh` 的 shebang 与每个词尾都粘上一个 `\r` ——
  99. POSIX 里只有空格/Tab/换行分隔词, 所以 `set -e` 变成 `set "-e\r"`(非法选项)、
  100. `RT=""` 变成 `RT="\r"`(后面对 `-n "$RT"` 的判断直接翻面)。
  101. 2026-09-16 实测发现 `install.sh` **从写出来那天起就是 CRLF**(git HEAD blob 就是 CRLF, 不是某个编辑器改的),
  102. 也就是说此前所有交付包的 Linux/macOS 安装脚本都是坏的。已改 LF, 并用 .gitattributes 钉住。
  103. """
  104. root = pathlib.Path(root)
  105. bad: list[str] = []
  106. for p in _iter_scripts(root, ('.ps1',)):
  107. b = p.read_bytes()
  108. rel = p.relative_to(root).as_posix()
  109. if b[:3] != b'\xef\xbb\xbf':
  110. bad.append(f'{rel} 缺 UTF-8 BOM (PS 5.1 会按 ANSI/GBK 解码 → 中文乱码 + ParserError)')
  111. if b.count(b'\n') != b.count(b'\r\n'):
  112. bad.append(f'{rel} 有裸 LF (PowerShell 脚本按 CRLF 交付)')
  113. for p in _iter_scripts(root, ('.bat',)):
  114. b = p.read_bytes()
  115. rel = p.relative_to(root).as_posix()
  116. if b.count(b'\n') != b.count(b'\r\n'):
  117. bad.append(f'{rel} 有裸 LF (.bat 行尾必须是 CRLF)')
  118. if b[:3] == b'\xef\xbb\xbf':
  119. bad.append(f'{rel} 带 UTF-8 BOM (.bat 不能有 BOM, 否则第一行失效)')
  120. # ★ 2026-09-17: .bat 必须**纯 ASCII**。原因: 批处理的解析与输出都跟控制台代码页绑在一起
  121. # (cp936 / cp65001 / 系统区域设置)。现场实测: 中文注释行被 cmd 拆开当成命令执行
  122. # ("'本身是控制台程序' 不是内部或外部命令") —— 放在注释里都不安全。
  123. # 所有中文说明改放 README / docs; .bat 只留英文。
  124. nonascii = [x for x in b if x > 0x7F]
  125. if nonascii:
  126. bad.append(f'{rel} 含 {len(nonascii)} 个非 ASCII 字节 (.bat 必须纯 ASCII: 中文在批处理里会随代码页被曲解, '
  127. f'现场出现过"注释被当命令执行"; 中文说明放 README/docs)')
  128. txt = b.decode('utf-8', 'replace')
  129. if '<<' in txt:
  130. bad.append(f'{rel} 含 `<<` —— 在批处理里是重定向, `echo <x>` 这种写法会直接报错')
  131. # `>` 只在**非注释行**上查重定向语义: 本机实测 `rem` 行里的 `>` 不会被当重定向 (但 `echo <x>` 会),
  132. # 所以注释行放行 —— 免得逼着人把说明写成天书, 同时保住真正会炸的那种写法。
  133. for i, ln in enumerate(txt.splitlines(), 1):
  134. st = ln.strip()
  135. if not st or st.lower().startswith(('rem', '::')):
  136. continue
  137. if re.search(r'>{1,2}(?!\s|"|nul\b|&)', ln):
  138. bad.append(f'{rel} 第 {i} 行的 `>` 后面不是重定向目标 '
  139. f'(.bat 里 `>` 是重定向符, 想显示要用 ^> 转义)')
  140. break
  141. for p in _iter_scripts(root, ('.sh',)):
  142. b = p.read_bytes()
  143. if b'\r\n' in b:
  144. bad.append(f'{p.relative_to(root).as_posix()} 含 CRLF '
  145. f'(POSIX shell 需要 LF: 词尾会粘 \\r, 判断与 shebang 都会出错)')
  146. return bad
  147. # 装机时才有 / 只在本机有效的文件 —— 入口脚本**可以**引用它们, 但它们**不该在包里**
  148. # (所以闭合检查必须放行, 否则这条守卫会得出相反的结论, 见 missing_refs 的注释)。
  149. MACHINE_ARTIFACTS = ('install-info.json',) # 安装时写的"本机装没装/哪一版"凭据
  150. def _is_machine_artifact(rel: str) -> bool:
  151. name = rel.replace('\\', '/').split('/')[-1].lower()
  152. return name in MACHINE_ARTIFACTS or name.endswith('.lnk')
  153. def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]:
  154. """→ [(入口脚本, 缺失的安装根相对路径)] —— provided=None 时按 root 下的真实文件判断。
  155. provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。
  156. ★ 2026-09-17: **放行"装机产物"** (见 MACHINE_ARTIFACTS)。这条守卫的本意是"包内不许有指向空气的
  157. 入口"(曾经漏带 start_hidden.vbs, 目标机双击即报错), 而 `install-info.json` 是**故意不发**的:
  158. 它是安装时才写的本机凭据, 装之前本来就该不在, 而 `install.ps1`/`install.sh` 的版本检查那一步
  159. 正是靠"文件在不在"判断装没装的。原先这两件事会打架 —— 把 install-info.json 排除出包之后,
  160. 闭合检查反过来报"install.ps1 引用了它但包里没有 ⇒ 不能交付", 于是包被闸删掉。
  161. 判据修正为: 引用的文件必须"在包内**或在目标机上必然存在**(装机产物/安装时自建)"。
  162. """
  163. have = None if provided is None else {_norm(x.rstrip('/')) for x in provided}
  164. bad: list[tuple[str, str]] = []
  165. root = pathlib.Path(root)
  166. for entry, refs in sorted(referenced_tree_files(root).items()):
  167. for rel in sorted(refs):
  168. if _is_machine_artifact(rel):
  169. continue
  170. ok = (rel in have) if have is not None else (root / rel).is_file()
  171. if not ok:
  172. bad.append((entry, rel))
  173. return bad
  174. def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
  175. """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。
  176. 只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
  177. 缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
  178. ★ 装机产物 (`install-info.json` / `*.lnk`) 不在此列 —— 它们本机有, 但**不该随包**(见 MACHINE_ARTIFACTS)。
  179. """
  180. got = {_norm(x) for x in already}
  181. root = pathlib.Path(root)
  182. add: list[pathlib.Path] = []
  183. for _, refs in referenced_tree_files(root).items():
  184. for rel in refs:
  185. if '/' in rel or rel in got or _is_machine_artifact(rel):
  186. continue
  187. p = root / rel
  188. if p.is_file():
  189. add.append(p)
  190. got.add(rel)
  191. return sorted(set(add), key=lambda p: p.name)
  192. if __name__ == '__main__': # 直接跑 = 对当前安装目录做一次闭合检查 + 编码守则检查
  193. import sys
  194. r = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
  195. refs = referenced_tree_files(r)
  196. miss = missing_refs(r)
  197. enc = encoding_problems(r)
  198. for e in sorted(refs):
  199. print(f' {e:18s} → ' + ', '.join(sorted(refs[e])))
  200. for e, rel in miss:
  201. print(f'[X] {e} 引用了 {rel}, 但 {r} 下没有')
  202. for m in enc:
  203. print(f'[X] 编码守则: {m}')
  204. if not miss:
  205. print(f'[OK] 入口脚本引用闭合 ({len(refs)} 个入口, '
  206. f'{sum(len(v) for v in refs.values())} 条引用全部在位)')
  207. if not enc:
  208. print('[OK] 入口脚本编码守则 (install.ps1 = UTF-8 BOM + CRLF; .bat = CRLF 无 BOM; .sh = LF)')
  209. sys.exit(1 if (miss or enc) else 0)