entry_refs.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. # -*- coding: utf-8 -*-
  2. r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引用的文件"(2026-09-16 实炸)。
  3. ## 为什么要这个模块
  4. 交付包 v0.4.0 第一次打出来时, 目标机 (D:\产品\app) 双击 `start.bat` 报:
  5. 无法找到脚本文件 "D:\产品\app\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. ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat',
  26. 'install.bat', 'install.ps1', 'install.sh')
  27. # 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件)
  28. _REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+'
  29. r'(?:[\\/][A-Za-z0-9_.\u4e00-\u9fff-]+)*'
  30. r'\.(?:bat|vbs|ps1|sh|py|txt|json|yaml|yml|md|html|zip|csv|parquet)')
  31. _STRIP = ('dp0', 'DP0') # %~dp0 / %dp0 展开后的残前缀
  32. def _candidates(tok: str):
  33. """一个 token 可能对应的"安装根相对路径"候选 (从最具体到最宽松)。"""
  34. t = tok.replace('\\', '/').lstrip('./')
  35. cands = [t]
  36. for p in _STRIP:
  37. if t.startswith(p) and len(t) > len(p):
  38. cands.append(t[len(p):])
  39. parts = t.split('/')
  40. cands += ['/'.join(parts[i:]) for i in range(1, len(parts))]
  41. return [c for c in dict.fromkeys(cands) if c]
  42. def _norm(name: str) -> str:
  43. return name.replace('\\', '/').lstrip('./').lower()
  44. def entry_refs(path: pathlib.Path) -> set[str]:
  45. """一个入口脚本里出现的全部"像文件"的路径 token。"""
  46. try:
  47. txt = path.read_text(encoding='utf-8-sig', errors='replace')
  48. except OSError:
  49. return set()
  50. return {m.group(0) for m in _REF.finditer(txt)}
  51. def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]:
  52. """→ {入口脚本: {安装根相对路径…}} —— 只留**本机源码树里真实存在**的那些。
  53. 本机不存在的 token 直接丢掉: 它们是"目标机安装后才有的"(venv/系统 exe)或纯噪声(`sys.exe`)。
  54. """
  55. root = pathlib.Path(root)
  56. out: dict[str, set[str]] = {}
  57. for name in ENTRY_FILES:
  58. p = root / name
  59. if not p.is_file():
  60. continue
  61. found: set[str] = set()
  62. for tok in entry_refs(p):
  63. for cand in _candidates(tok):
  64. if (root / cand).is_file():
  65. found.add(_norm(cand))
  66. break
  67. if found:
  68. out[name] = found
  69. return out
  70. _SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
  71. 'outputs', 'data', 'logs', 'run'}
  72. def _iter_scripts(root: pathlib.Path, exts: tuple[str, ...]):
  73. """自带脚本文件的遍历 (跳过 .venv/node_modules/outputs 等大目录)。"""
  74. import os
  75. for dp, dn, fns in os.walk(root):
  76. dn[:] = [d for d in dn if d not in _SKIP_WALK]
  77. for fn in fns:
  78. if fn.lower().endswith(exts):
  79. yield pathlib.Path(dp) / fn
  80. def encoding_problems(root: pathlib.Path) -> list[str]:
  81. r"""入口脚本的**编码守则**检查 —— 这几条都是实机踩出来的, 违反了就是"双击/一跑即报错"。
  82. · `install.ps1` 必须 **UTF-8 带 BOM + CRLF**: Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI
  83. 代码页(中文机 = GBK)解码 → 中文变乱码, 相邻的转义反引号被吞 → 引号不配对 → 级联 ParserError
  84. (报在看起来没问题的行上)。2026-09-16 真的发出去过一个这样的包: 开箱验证里
  85. `install 退出码 1, 耗时 1s`, 报的正是 `The '<' operator is reserved for future use`。
  86. ★ 起因很隐蔽: 编辑工具保存 .ps1 **不会替你保留 BOM** —— 在规范化之后再改一次文件, BOM 就没了。
  87. 所以这条必须由机器守, 不能靠"我记得"。
  88. · `.bat` 必须 CRLF 且**不能**带 BOM: 批处理按字节读, 行尾 LF 出怪问题; 开头 BOM 会让第一行
  89. (`@echo off`) 失效并被当命令执行。
  90. · `.sh` 必须 LF: CRLF 会让 `#!/bin/sh` 的 shebang 与每个词尾都粘上一个 `\r` ——
  91. POSIX 里只有空格/Tab/换行分隔词, 所以 `set -e` 变成 `set "-e\r"`(非法选项)、
  92. `RT=""` 变成 `RT="\r"`(后面对 `-n "$RT"` 的判断直接翻面)。
  93. 2026-09-16 实测发现 `install.sh` **从写出来那天起就是 CRLF**(git HEAD blob 就是 CRLF, 不是某个编辑器改的),
  94. 也就是说此前所有交付包的 Linux/macOS 安装脚本都是坏的。已改 LF, 并用 .gitattributes 钉住。
  95. """
  96. root = pathlib.Path(root)
  97. bad: list[str] = []
  98. for p in _iter_scripts(root, ('.ps1',)):
  99. b = p.read_bytes()
  100. rel = p.relative_to(root).as_posix()
  101. if b[:3] != b'\xef\xbb\xbf':
  102. bad.append(f'{rel} 缺 UTF-8 BOM (PS 5.1 会按 ANSI/GBK 解码 → 中文乱码 + ParserError)')
  103. if b.count(b'\n') != b.count(b'\r\n'):
  104. bad.append(f'{rel} 有裸 LF (PowerShell 脚本按 CRLF 交付)')
  105. for p in _iter_scripts(root, ('.bat',)):
  106. b = p.read_bytes()
  107. rel = p.relative_to(root).as_posix()
  108. if b.count(b'\n') != b.count(b'\r\n'):
  109. bad.append(f'{rel} 有裸 LF (.bat 行尾必须是 CRLF)')
  110. if b[:3] == b'\xef\xbb\xbf':
  111. bad.append(f'{rel} 带 UTF-8 BOM (.bat 不能有 BOM, 否则第一行失效)')
  112. for p in _iter_scripts(root, ('.sh',)):
  113. b = p.read_bytes()
  114. if b'\r\n' in b:
  115. bad.append(f'{p.relative_to(root).as_posix()} 含 CRLF '
  116. f'(POSIX shell 需要 LF: 词尾会粘 \\r, 判断与 shebang 都会出错)')
  117. return bad
  118. def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]:
  119. """→ [(入口脚本, 缺失的安装根相对路径)] —— provided=None 时按 root 下的真实文件判断。
  120. provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。
  121. """
  122. have = None if provided is None else {_norm(x.rstrip('/')) for x in provided}
  123. bad: list[tuple[str, str]] = []
  124. root = pathlib.Path(root)
  125. for entry, refs in sorted(referenced_tree_files(root).items()):
  126. for rel in sorted(refs):
  127. ok = (rel in have) if have is not None else (root / rel).is_file()
  128. if not ok:
  129. bad.append((entry, rel))
  130. return bad
  131. def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
  132. """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。
  133. 只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
  134. 缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
  135. """
  136. got = {_norm(x) for x in already}
  137. root = pathlib.Path(root)
  138. add: list[pathlib.Path] = []
  139. for _, refs in referenced_tree_files(root).items():
  140. for rel in refs:
  141. if '/' in rel or rel in got:
  142. continue
  143. p = root / rel
  144. if p.is_file():
  145. add.append(p)
  146. got.add(rel)
  147. return sorted(set(add), key=lambda p: p.name)
  148. if __name__ == '__main__': # 直接跑 = 对当前安装目录做一次闭合检查 + 编码守则检查
  149. import sys
  150. r = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
  151. refs = referenced_tree_files(r)
  152. miss = missing_refs(r)
  153. enc = encoding_problems(r)
  154. for e in sorted(refs):
  155. print(f' {e:18s} → ' + ', '.join(sorted(refs[e])))
  156. for e, rel in miss:
  157. print(f'[X] {e} 引用了 {rel}, 但 {r} 下没有')
  158. for m in enc:
  159. print(f'[X] 编码守则: {m}')
  160. if not miss:
  161. print(f'[OK] 入口脚本引用闭合 ({len(refs)} 个入口, '
  162. f'{sum(len(v) for v in refs.values())} 条引用全部在位)')
  163. if not enc:
  164. print('[OK] 入口脚本编码守则 (install.ps1 = UTF-8 BOM + CRLF; .bat = CRLF 无 BOM; .sh = LF)')
  165. sys.exit(1 if (miss or enc) else 0)