pages_audit.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""页面归口审计 —— 把"这一页该不该随输入数据变 / 算不算产物"变成机器每天能查的事 (2026-09-17, 用户令 1)。
  4. ## 背景(用户的问题)
  5. http://127.0.0.1:28084/#findings #sim #documents 及它们包含的子页,
  6. "应否随着输入数据的变化而变化"、"是否也属于产物"、"如是产物应纳入产物管理"。
  7. 答案不是一句话能给的: 三页里既有**受管静态叙述**(方法论/架构), 也有**冻结交付件**(治理清单/报告),
  8. 还有**真·数据派生快照**(把 outputs 里的数据烘进页面) —— 后者才是"属于产物、必须纳入产物管理"的那批。
  9. 本模块把这份判断落成 `configs/portal_pages.yaml` 登记表 + 五条机器可查的规则, 于是:
  10. · static —— 正文不得引用产物(setting 变了就该改分类);
  11. · live —— iframe/链接指向的端口必须是 `configs/serve.json` 里的已知服务;
  12. · data-citing —— 引用的产物路径**必须存在**(否则是悬空引用, 挂羊头卖狗肉);
  13. · data-derived —— **必须有 source + (source_sha256 或 generated_at)**; 有 sha 的还要跟当前产物
  14. 逐字节比对 ⇒ **陈旧检测**(数据换了、页面没换 = 报出来);
  15. · frozen-delivery —— 文件名或正文里必须能读到版本号与日期(客户拿到手才知道是哪一版)。
  16. ## 为什么要有"陈旧检测"
  17. 页面把产物烘进去, 就**脱离**了"产物即时进页面"的机制(§5.1 的指纹热重载只对实时读产物的服务生效)。
  18. 数据重算后, 这种页面**不会自己变**; 没有检测, 它就一直挂着旧数字 —— 这正是用户担心的情形。
  19. ## 退出码 (给 rebuild_all / check 用)
  20. 0 全部一致 · 5 登记缺项/文件缺失/未登记页 · 6 溯源缺失 · 7 页面陈旧(数据已变, 页面没变) · 8 交付件缺版本号 · 9 分类错误
  21. ## 用法
  22. python scripts/pages_audit.py # 表 + 检查(默认)
  23. python scripts/pages_audit.py --list # 只打表
  24. python scripts/pages_audit.py --check # 只检查(安静模式, 只打结论)
  25. python scripts/pages_audit.py --write-doc # 把表写进 docs/系统设计说明.md (标记区间内)
  26. python scripts/pages_audit.py --json # 机器可读结果
  27. """
  28. from __future__ import annotations
  29. try:
  30. from app_common.app_common_guanlan.api import install_root as _install_root
  31. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  32. from pathlib import Path as _P
  33. def _install_root(_f): return _P(_f).resolve().parents[4]
  34. import argparse
  35. import fnmatch
  36. import glob as globmod
  37. import hashlib
  38. import html as htmllib
  39. import json
  40. import pathlib
  41. import re
  42. import sys
  43. ROOT = _install_root(__file__)
  44. sys.path.insert(0, str(ROOT))
  45. from src import paths as P # noqa: E402
  46. REG = P.config('portal_pages.yaml')
  47. DOC = P.ROOT / 'docs' / '系统设计说明.md'
  48. def dt_from(ts: float | None) -> str:
  49. """mtime → 'YYYY-mm-dd HH:MM' (报告里给人看的时间)。"""
  50. if not ts:
  51. return '?'
  52. import datetime as _dt
  53. return _dt.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
  54. DOC_BEGIN, DOC_END = '<!-- PAGES:BEGIN -->', '<!-- PAGES:END -->'
  55. RC_OK, RC_MISS, RC_PROV, RC_STALE, RC_VER, RC_KIND = 0, 5, 6, 7, 8, 9
  56. KINDS = ('static', 'live', 'data-citing', 'data-derived', 'frozen-delivery')
  57. RE_OUT = re.compile(r'outputs/[A-Za-z0-9_\u4e00-\u9fff\-./]+')
  58. # 版本号只认"像版本"的: v1.0 / V2.1 / 版本1.2 / 客户版|外发版|正式版|评审版。
  59. # ★别写成 \d{8} 之类 —— 那会把页面里的机组编号(00019164)、时间戳当成版本号(2026-09-17 实测踩到)。
  60. RE_VER = re.compile(r'[vV]\d+(?:\.\d+)+|版本\s*[vV]?\d+(?:\.\d+)*|客户版|外发版|正式版|评审版')
  61. RE_DATE = re.compile(r'20\d\d-\d\d-\d\d|20\d{6}')
  62. RE_GEN = re.compile(r'生成[^0-9]{0,8}(20\d\d-\d\d-\d\d[ T]?\d?\d?:?\d?\d?)')
  63. def load(path=None):
  64. import yaml
  65. reg_p = pathlib.Path(path) if path else REG
  66. if not reg_p.is_file():
  67. raise SystemExit(f'缺登记表 {P.rel(reg_p)}')
  68. reg = yaml.safe_load(reg_p.read_text(encoding='utf-8'))
  69. reg['_file'] = str(reg_p)
  70. return reg
  71. def sha256(p: pathlib.Path) -> str | None:
  72. try:
  73. return hashlib.sha256(p.read_bytes()).hexdigest()
  74. except OSError:
  75. return None
  76. def expand(entry) -> list[pathlib.Path]:
  77. """一个登记项的 `file` 字段 → 实际文件列表 (支持 * 通配)。"""
  78. pat = P.ROOT / entry['file']
  79. s = str(pat)
  80. if any(c in s for c in '*?['):
  81. return sorted(pathlib.Path(x) for x in globmod.glob(s, recursive=True))
  82. return [pat] if pat.is_file() else []
  83. def texts(files):
  84. for f in files:
  85. try:
  86. yield f, f.read_text(encoding='utf-8', errors='replace')
  87. except OSError:
  88. continue
  89. def read_embedded_json(path: pathlib.Path, var: str):
  90. """抓页面里内嵌的 window.<var> = {…} —— 模板里是 HTML 转义过的, 解析前先反转义。"""
  91. try:
  92. t = path.read_text(encoding='utf-8', errors='replace')
  93. except OSError:
  94. return None
  95. m = re.search(r'window\.' + re.escape(var) + r'\s*=\s*(\{)', t)
  96. if not m:
  97. return None
  98. raw, depth = '', 0
  99. for ch in t[m.start(1):]:
  100. raw += ch
  101. if ch == '{':
  102. depth += 1
  103. elif ch == '}':
  104. depth -= 1
  105. if depth == 0:
  106. break
  107. for cand in (raw, htmllib.unescape(raw)):
  108. try:
  109. return json.loads(cand)
  110. except Exception:
  111. continue
  112. return None
  113. def check_entry(e, results):
  114. """按 kind 查一个登记项 → 往 results 里加 (level, 项, 说明, 退出码)。
  115. level: OK 通过 / X 失败 / ! 要处理 / ? 只能人工看 / i 已知缺口(登记了 known_gap 并写了理由)。
  116. ★ known_gap 的存在是**如实**的产物: 交付包里确实没有那件产物(例如振动线六层链的 cleaned/gearbox_life),
  117. 页面里却引用了它。我们不改交付件正文(客户手里那一版是冻结的), 但必须把"引用悬空"这件事记在明处,
  118. 所以它报 `i` 而不是装作通过, 也不当成新问题反复报警。
  119. """
  120. eid = e.get('id') or e.get('name') or '?'
  121. kind = e.get('kind')
  122. gap = e.get('known_gap') or {}
  123. if kind not in KINDS:
  124. results.append(('X', eid, f'kind 非法: {kind} (允许 {"/".join(KINDS)})', RC_KIND))
  125. return
  126. files = expand(e)
  127. if not files and not e.get('members_in_zip'):
  128. results.append(('X', eid, f'登记的 file 不存在或没匹配到: {e.get("file")}', RC_MISS))
  129. return
  130. # ① 引用的产物必须在位 (data-citing / data-derived; 登记了 cites 的也查)
  131. for f, t in texts(files):
  132. cited = set(e.get('cites') or [])
  133. if kind in ('data-derived', 'data-citing'):
  134. cited |= set(RE_OUT.findall(t))
  135. for c in sorted(cited):
  136. if (P.ROOT / c).exists():
  137. continue
  138. if gap and c in (gap.get('missing') or []):
  139. results.append(('i', eid, f'引用悬空(已知): {c} —— {gap.get("why", "见登记表")}', RC_OK))
  140. else:
  141. results.append(('X', eid, f'{P.rel(f)} 引用的产物不在位: {c} (悬空引用)', RC_MISS))
  142. # ② 分类规则
  143. if kind == 'static':
  144. for f, t in texts(files):
  145. if RE_OUT.search(t):
  146. results.append(('!', eid, f'{P.rel(f)} 正文里出现产物路径 —— 可能该改成 data-citing/data-derived',
  147. RC_KIND))
  148. elif kind == 'live':
  149. svc = {}
  150. try:
  151. svc = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
  152. except Exception:
  153. pass
  154. ports = {int(v) for k, v in svc.items() if isinstance(v, int)}
  155. tgt = e.get('live_target') or ''
  156. m = re.search(r':(\d{2,5})', tgt)
  157. if m and ports and int(m.group(1)) not in ports:
  158. results.append(('X', eid, f'live_target 端口 {m.group(1)} 不在 configs/serve.json 的服务端口里', RC_MISS))
  159. elif kind == 'data-derived':
  160. src = e.get('source')
  161. var = e.get('embedded_json_var')
  162. skey, shkey = e.get('source_key'), e.get('source_sha_key')
  163. found_sha, found_src, found_gen = None, src, None
  164. if var and skey:
  165. for f in files:
  166. obj = read_embedded_json(f, var)
  167. if isinstance(obj, dict):
  168. found_src = obj.get(skey) or found_src
  169. found_sha = obj.get(shkey) if shkey else None
  170. break
  171. for f, t in texts(files):
  172. if found_gen is None:
  173. g = RE_GEN.search(t)
  174. if g:
  175. found_gen = g.group(1)
  176. if not found_src:
  177. if gap:
  178. results.append(('i', eid, f'溯源缺失(已知): {gap.get("why", "")}', RC_OK))
  179. else:
  180. results.append(('!', eid, 'data-derived 但没登记 source, 页面里也没有内嵌来源 ⇒ 溯源缺失', RC_PROV))
  181. if not (found_sha or found_gen or e.get('generated_at_key')):
  182. if e.get('generated_at_source') == 'mtime':
  183. # 页面件自己就是生成物: 用"它自己的 mtime"当生成时间, 与 source 的 mtime 比 ⇒ 可做陈旧代理判据
  184. src = e.get('source')
  185. mt = None
  186. for f in files:
  187. try:
  188. mt = f.stat().st_mtime
  189. break
  190. except OSError:
  191. continue
  192. sp = (P.ROOT / src) if isinstance(src, str) else None
  193. if mt and sp is not None and sp.is_file() and (sp.stat().st_mtime - mt) > 86400:
  194. results.append(('X', eid, f'**页面件疑似陈旧**: {P.rel(files[0])} 写于 '
  195. f'{dt_from(mt)}, 而它的源 {src} 更新于 {dt_from(sp.stat().st_mtime)} '
  196. f'⇒ 源换了、这份页面件没重生成', RC_STALE))
  197. else:
  198. results.append(('OK', eid, f'按 mtime 判: 页面件与源 {src or "(未登记)"} 的新旧关系正常 '
  199. f'(页面件 {dt_from(mt) if mt else "?"})', RC_OK))
  200. if not found_src:
  201. pass
  202. elif not gap:
  203. results.append(('!', eid, 'data-derived 但既无 source_sha256 也无生成时间 ⇒ 无法做陈旧检测', RC_PROV))
  204. if found_src:
  205. sp = P.ROOT / found_src
  206. if not sp.exists():
  207. if gap and found_src in (gap.get('missing') or []):
  208. results.append(('i', eid, f'内嵌来源不在位(已知): {found_src} —— {gap.get("why", "")}', RC_OK))
  209. else:
  210. results.append(('!', eid, f'内嵌来源 {found_src} 不在位 (页面里烘的是别处/历史数据)', RC_MISS))
  211. elif found_sha:
  212. cur = sha256(sp)
  213. if cur and cur.lower() != str(found_sha).lower():
  214. results.append(('X', eid,
  215. f'**页面陈旧**: 内嵌快照 source_sha256={str(found_sha)[:16]} 与当前 '
  216. f'{found_src} 的 sha256={cur[:16]} 不同 ⇒ 数据变了, 这份页面没跟着重生成',
  217. RC_STALE))
  218. else:
  219. results.append(('OK', eid, f'快照与当前产物一致 ({found_src} sha256 {str(found_sha)[:16]})', RC_OK))
  220. elif e.get('generated_at_source') != 'mtime':
  221. results.append(('?', eid, f'有来源 {found_src} 但无指纹, 只能按生成时间判断 '
  222. f'(记录: {found_gen or "无"})', RC_PROV))
  223. elif kind == 'frozen-delivery':
  224. ok_ver = ok_date = False
  225. for f in files:
  226. name = f.name
  227. ok_ver |= bool(RE_VER.search(name))
  228. ok_date |= bool(RE_DATE.search(name))
  229. if not (ok_ver and ok_date):
  230. t = f.read_text(encoding='utf-8', errors='replace') # 版本号/日期可能在正文深处
  231. ok_ver |= bool(RE_VER.search(t))
  232. ok_date |= bool(RE_DATE.search(t))
  233. if e.get('version'):
  234. ok_ver = True
  235. if not ok_ver:
  236. results.append(('!', eid, '冻结交付件但没有版本号 (文件名/正文都没有 v*/日期) ⇒ 客户拿到手分不清是哪一版',
  237. RC_VER))
  238. if not ok_date and not e.get('version'):
  239. results.append(('?', eid, '冻结交付件没有日期标记', RC_VER))
  240. # ③ 子项递归
  241. for c in e.get('children') or []:
  242. check_entry(c, results)
  243. def declared_patterns(reg) -> list[str]:
  244. """登记表里所有 file 字段 (含通配) —— 覆盖检查用。"""
  245. pats = []
  246. def walk(e):
  247. f = e.get('file')
  248. if f:
  249. pats.append(str(f))
  250. for c in e.get('children') or []:
  251. walk(c)
  252. for e in reg.get('pages') or []:
  253. walk(e)
  254. return pats
  255. def coverage_gaps(reg) -> list[str]:
  256. """登记表里没写、但门户里真实存在的内嵌页 —— 漏登记就等于没管。"""
  257. pats = declared_patterns(reg)
  258. gaps = []
  259. tdir = P.RELEASE / 'portal_src' / 'templates'
  260. for f in sorted(tdir.glob('*.html')):
  261. rel = f.relative_to(P.ROOT).as_posix()
  262. if not any(fnmatch.fnmatch(rel, p) or rel == p for p in pats):
  263. gaps.append(rel)
  264. return gaps
  265. def render(reg, results) -> str:
  266. lines = ['| 页面/子页 | kind | 随输入数据变? | 依据(为什么这么判) |', '|---|---|---|---|']
  267. def walk(e, depth=0):
  268. eid = e.get('id') or e.get('name') or '?'
  269. title = e.get('title') or ''
  270. pre = '&nbsp;&nbsp;└ ' * depth
  271. lines.append(f"| {pre}`{eid}`{' ' + title if title and e.get('id') else ''} | `{e.get('kind')}` | "
  272. f"{'**是**' if e.get('changes_with_data') else '否'} | {e.get('evidence', '')} |")
  273. for c in e.get('children') or []:
  274. walk(c, depth + 1)
  275. for e in reg.get('pages') or []:
  276. walk(e)
  277. return '\n'.join(lines)
  278. def inventory() -> list[dict]:
  279. """**所有网页**总清点(用户令 2026-09-17 第 2 问):把系统会呈现的每一页/每个子页列出来,
  280. 并判定"是不是产物(= 由 data/raw 重算出来的输出数据)"。
  281. 来源四路,各有依据(不靠猜):
  282. ① 门户锚点 `release/portal_src/shell.html` 的 `<div class="pg" id=… data-title=…>`
  283. ② 工作台标签 `app_frontEnd/.../assets/app.js` 的 `TABS` 常量(SPA tab,实时读产物)
  284. ③ 组件产物页 `outputs/<场>/**/*.html|*.md` —— **这些本身就是产物**
  285. ④ 交付/仿真/三维页 `release/如东/**/*.html`、仿真资料包 zip 内的页面、`release/viewer/*.html`、内嵌件
  286. """
  287. reg = load()
  288. entries = _all_entries(reg)
  289. reg_files = {str(e.get('file') or '') for e in entries}
  290. reg_ids = {str(e.get('id') or e.get('name') or '') for e in entries}
  291. rows: list[dict] = []
  292. # ① 门户锚点
  293. shell = P.RELEASE / 'portal_src' / 'shell.html'
  294. if shell.is_file():
  295. t = shell.read_text(encoding='utf-8', errors='replace')
  296. for m in re.finditer(r'<div class="pg"[^>]*id="([^"]+)"[^>]*data-title="([^"]*)"', t):
  297. rows.append(dict(source='门户锚点', name=f'#{m.group(1)}', title=m.group(2), kind='shell',
  298. product=False, registered=m.group(1) in reg_ids,
  299. evidence='外壳页(静态/iframe/链接);逐条判定见 §10 与 §12'))
  300. # ② 工作台 SPA 标签
  301. appjs = P.ROOT / 'app_frontEnd' / 'app_frontEnd_guanlan' / 'assets' / 'app.js'
  302. if appjs.is_file():
  303. t = appjs.read_text(encoding='utf-8', errors='replace')
  304. m = re.search(r"const TABS = \[([^\]]+)\]", t)
  305. if m:
  306. for tab in re.findall(r"'([a-z_]+)'", m.group(1)):
  307. rows.append(dict(source='工作台标签', name=f'tab={tab}', title='', kind='live', product=False,
  308. registered=_tab_registered(tab, entries),
  309. evidence='SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照'))
  310. # ③ 组件产物页(本身就是产物)
  311. out = P.out_root()
  312. ledger = _ledger_keys()
  313. REPORT_HINT = ('报告', '收敛', '诊断', '评估', '结论', 'report', 'summary', 'findings')
  314. if out.is_dir():
  315. for f in sorted(list(out.rglob('*.html')) + list(out.rglob('*.md'))):
  316. rel = f.relative_to(out).as_posix()
  317. if rel.startswith('_'):
  318. continue
  319. full = f'{out.name}/{rel}'
  320. if f.suffix == '.html':
  321. src, prod, why = '组件页面件 (.html)', True, '位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件'
  322. elif any(k in f.name.lower() for k in REPORT_HINT):
  323. src, prod, why = '组件结论件 (.md,页面会渲染)', True, '产物(结论文本);工作台 report tab / CMS report.html 会渲染它'
  324. else:
  325. src, prod, why = '产物里的过程留痕 (.md,非网页)', True, '产物,但没有任何页面呈现它(给下游线的交接/复跑记录)'
  326. rows.append(dict(source=src, name=rel, title='', kind='product-page', product=prod,
  327. registered=(rel in ledger), evidence=why + f';台账: {"在" if rel in ledger else "缺"}'))
  328. # ④ 交付/仿真/三维/内嵌
  329. # 已登记判定: 精确名 或 **通配模式**(登记表里常按 wildcard 收一族文件, 如 tpl-report-*.html.html)
  330. _ = None
  331. for pat, src, kind, prod, why in (
  332. ('如东/**/*.html', '交付页 (release/如东)', 'frozen-delivery', False, '客户交付件,按版本冻结'),
  333. ('viewer/*.html', '三维页 (release/viewer)', 'static', False, '图纸/模型派生的三维资产'),
  334. ('portal_src/templates/*.html', '门户内嵌件', 'embed', None, '内嵌交付件(§10 逐条判定;个别为数据派生)')):
  335. for f in sorted(P.RELEASE.glob(pat)):
  336. if f.name.startswith('.'):
  337. continue
  338. rel = f.relative_to(P.RELEASE).as_posix()
  339. rows.append(dict(source=src, name=rel, title='', kind=kind, product=prod,
  340. registered=_is_registered(rel, f'outputs/{rel}', entries), evidence=why))
  341. # 仿真资料包里的页面
  342. z = P.RELEASE / '如东SWT40_控制律仿真台_20260906.zip'
  343. if z.is_file():
  344. import zipfile
  345. with zipfile.ZipFile(z) as zf:
  346. for n in zf.namelist():
  347. if not n.endswith('.html'):
  348. continue
  349. try:
  350. nm = n.encode('cp437').decode('utf-8')
  351. except Exception:
  352. nm = n
  353. rows.append(dict(source='仿真资料包 (zip)', name=nm.split('/')[-1], title='', kind='frozen-delivery',
  354. product=False, registered=True, evidence='图纸派生的冻结资料包(sim_sys_server 从 zip 现读)'))
  355. return rows
  356. def _declared_patterns(entries: list[dict]) -> list[str]:
  357. """登记表里所有 `file:` 取值(含通配)—— 判定"这一页/这一件有没有被登记"用。"""
  358. return [str(e.get('file') or '') for e in entries if e.get('file')]
  359. def _is_registered(rel: str, full: str, entries: list[dict]) -> bool:
  360. """精确 或 通配 命中页面登记表 → 已登记(通配用全路径与 basename 两种都比一次)。"""
  361. import fnmatch
  362. names = {rel, full, pathlib.Path(rel).name}
  363. pats = _declared_patterns(entries)
  364. pats += [pathlib.Path(x).name for x in pats]
  365. for n in names:
  366. if n in pats:
  367. return True
  368. if any(fnmatch.fnmatch(n, x) for x in pats):
  369. return True
  370. return False
  371. def _tab_registered(tab: str, entries: list[dict]) -> bool:
  372. for e in entries:
  373. if e.get('id') == 'detail_tabs' and tab in (e.get('tabs') or []):
  374. return True
  375. return False
  376. def _ledger_keys() -> set:
  377. """产物台账里的键(相对 outputs/<场>/),判定"产物有没有被管起来"用。"""
  378. prov_p = P.out_root() / '_provenance.json'
  379. if not prov_p.is_file():
  380. return set()
  381. try:
  382. return set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys())
  383. except Exception:
  384. return set()
  385. def _all_entries(reg) -> list[dict]:
  386. out = []
  387. def walk(e):
  388. out.append(e)
  389. for c in e.get('children') or []:
  390. walk(c)
  391. for e in reg.get('pages') or []:
  392. walk(e)
  393. return out
  394. def render_inventory(rows: list[dict]) -> str:
  395. head = ['| 来源 | 页面/子页 | 是产物? | 依据 | 已登记? |', '|---|---|---|---|---|']
  396. body = []
  397. for r in rows:
  398. p = '**是**' if r['product'] else ('—(逐条判定)' if r['product'] is None else '不是')
  399. body.append(f"| {r['source']} | `{r['name']}`{' ' + r['title'] if r.get('title') else ''} | {p} | "
  400. f"{r['evidence']} | {'✔' if r['registered'] else '**✘ 未登记**'} |")
  401. return '\n'.join(head + body)
  402. def audit(registry=None):
  403. """→ (rc, results, unregistered)。
  404. 供 `guanlan.py check` / `scripts/rebuild_all.py` 直接调用 —— 走函数而不是子进程:
  405. 子进程的 stdout 是中文, Windows 控制台默认 cp936 会把 UTF-8 输出解成乱码, 解析结论就不可靠了。
  406. """
  407. reg = load(registry)
  408. results: list[tuple[str, str, str, int]] = []
  409. for e in reg.get('pages') or []:
  410. check_entry(e, results)
  411. results += ledger_coverage(reg)
  412. rc_map = {r[3] for r in results if r[0] not in ('OK', 'i', '?') and r[3]}
  413. return (max(rc_map) if rc_map else RC_OK), results, coverage_gaps(reg)
  414. def ledger_coverage(reg) -> list:
  415. """页面侧产物的**台账覆盖**检查 (用户令 2026-09-17: 是产物就要纳入产物管理)。
  416. 凡登记表里指向 `outputs/<场>/…` 的路径 (`file` / `source`), 都必须出现在该场的
  417. `_provenance.json::files` 里 —— 否则它虽然被页面用了, 却没进产物台账 (来源/类型无从查)。
  418. """
  419. out: list[tuple[str, str, str, int]] = []
  420. prov_p = P.out_root() / '_provenance.json'
  421. if not prov_p.is_file():
  422. return out
  423. try:
  424. prov = set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys())
  425. except Exception:
  426. return out
  427. prefix = P.out_root().relative_to(P.ROOT).as_posix() + '/' # outputs/<场>/
  428. def walk(e):
  429. eid = e.get('id') or e.get('name') or '?'
  430. gap = e.get('known_gap') or {}
  431. gap_missing = set(gap.get('missing') or [])
  432. for key in ('file', 'source'):
  433. v = e.get(key)
  434. if not isinstance(v, str) or not v.startswith(prefix) or any(c in v for c in '*?['):
  435. continue
  436. if v in gap_missing or any(v.endswith(g.split('/')[-1]) for g in gap_missing):
  437. out.append(('i', eid, f'页面侧产物 {v} 不在本包(已知缺口): {gap.get("why", "")[:60]}', RC_OK))
  438. continue
  439. rel = v[len(prefix):]
  440. if rel not in prov:
  441. out.append(('X', eid, f'页面侧产物 {v} 未进产物台账 (_provenance.json 里没有 {rel}) '
  442. f'⇒ 来源/类型无从查, 不算"纳入产物管理"', RC_PROV))
  443. for c in e.get('children') or []:
  444. walk(c)
  445. for e in reg.get('pages') or []:
  446. walk(e)
  447. return out
  448. def main() -> int:
  449. ap = argparse.ArgumentParser()
  450. ap.add_argument('--list', action='store_true', help='只打表')
  451. ap.add_argument('--check', action='store_true', help='只检查')
  452. ap.add_argument('--write-doc', action='store_true', help='把表写进 docs/系统设计说明.md')
  453. ap.add_argument('--inventory', action='store_true',
  454. help='**所有网页**总清点: 门户锚点/工作台标签/组件产物页/交付页/三维页/内嵌件/仿真页, 逐条判定是否产物')
  455. ap.add_argument('--json', action='store_true', help='机器可读输出')
  456. ap.add_argument('--registry', default=None, help='登记表路径 (默认 configs/portal_pages.yaml; 测试用)')
  457. a = ap.parse_args()
  458. reg = load(a.registry)
  459. rc, results, gaps = audit(a.registry)
  460. if a.json:
  461. print(json.dumps(dict(rc=rc,
  462. results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in results],
  463. unregistered=gaps), ensure_ascii=False, indent=1))
  464. return rc
  465. if a.inventory:
  466. rows = inventory()
  467. prod = [r for r in rows if r['product'] is True]
  468. un = [r for r in rows if not r['registered']]
  469. print(f'== 所有网页总清点: {len(rows)} 项 ==')
  470. print(f' 判定为产物(= 输入数据重算出来的输出数据): {len(prod)} 项; 未被管起来: {len(un)} 项')
  471. cur = None
  472. for r in rows:
  473. if r['source'] != cur:
  474. cur = r['source']
  475. print(f'\n ▸ {cur}')
  476. flag = '产物' if r['product'] is True else ('逐条判定' if r['product'] is None else '非产物')
  477. print(f' [{"✔" if r["registered"] else "✘"}] {r["name"][:74]:74s} {flag}')
  478. if un:
  479. print('\n 未被管起来的条目:')
  480. for r in un:
  481. print(f' ✘ {r["source"]} | {r["name"]}')
  482. print('\n 判定依据: 产物板(组件页面件/结论件/过程留痕) = 位置在 outputs/<场>/ 下; 页面侧 = 登记表 kind 与 §10/§12 的逐条依据')
  483. if a.write_doc:
  484. write_doc(load())
  485. return RC_OK
  486. if a.list or not a.check:
  487. print('== 门户页面归口 (configs/portal_pages.yaml) ==')
  488. for e in reg.get('pages') or []:
  489. def walk(x, d=0):
  490. eid = x.get('id') or x.get('name')
  491. print(f' {" " * d}{eid:34s} {x.get("kind"):16s} '
  492. f'{"随数据变" if x.get("changes_with_data") else "不随数据变"}')
  493. for c in x.get('children') or []:
  494. walk(c, d + 1)
  495. walk(e)
  496. if not a.check or True:
  497. lvl = {}
  498. for level, item, note, r in results:
  499. lvl[level] = lvl.get(level, 0) + 1
  500. print(f'\n== 检查: {lvl.get("OK", 0)} 项一致, {lvl.get("i", 0)} 项已知缺口, '
  501. f'{sum(v for k, v in lvl.items() if k not in ("OK", "i"))} 项要处理 ==')
  502. for level, item, note, r in results:
  503. if level not in ('OK', 'i'):
  504. print(f' [{level}] {item}: {note}')
  505. if lvl.get('i'):
  506. print(f' [i] 已知缺口 {lvl["i"]} 条 (引用悬空/溯源缺失, 已在登记表里写明理由, 不重复刷屏):')
  507. seen = set()
  508. for level, item, note, r in results:
  509. if level == 'i' and item not in seen:
  510. seen.add(item)
  511. print(f' · {item}')
  512. if gaps:
  513. print(f' [!] 门户里有 {len(gaps)} 个内嵌页/子页**未登记**(漏登记=没管):')
  514. for g in gaps[:8]:
  515. print(f' · {g}')
  516. print(f'结论: {"全部一致" if rc == RC_OK else "见上"}; 退出码 {rc}')
  517. if a.write_doc:
  518. write_doc(reg)
  519. return rc
  520. return rc
  521. INV_BEGIN, INV_END = '<!-- PAGES-INV:BEGIN -->', '<!-- PAGES-INV:END -->'
  522. def write_inventory_doc() -> None:
  523. """把"所有网页总清点"写进 §12.2(自动块, 放在 §13 之前)。"""
  524. rows = inventory()
  525. prod = sum(1 for r in rows if r['product'] is True)
  526. un = [r for r in rows if not r['registered']]
  527. head = (f'### 12.2 全部网页总清点(自动生成,勿手改)\n\n'
  528. f'共 **{len(rows)}** 项(门户锚点 / 工作台标签 / 组件页面件 / 组件结论件 / 产物里的过程留痕 / '
  529. f'交付页 / 三维页 / 门户内嵌件 / 仿真资料包页面),其中判定为**产物**的 {prod} 项;'
  530. f'未被管起来的 **{len(un)}** 项。判据:产物树里的件看"在不在产物台账里",网页看"在不在页面登记表里"。\n')
  531. block = f'{INV_BEGIN}\n{head}\n{render_inventory(rows)}\n\n{INV_END}'
  532. if not DOC.is_file():
  533. return
  534. t = DOC.read_text(encoding='utf-8')
  535. if INV_BEGIN in t and INV_END in t:
  536. t = re.sub(re.escape(INV_BEGIN) + r'.*?' + re.escape(INV_END), lambda m: block, t, flags=re.S)
  537. else:
  538. anchor = '\n---\n\n## 13.'
  539. t = t.replace(anchor, f'\n\n{block}\n{anchor}', 1) if anchor in t else t.rstrip() + '\n\n' + block + '\n'
  540. DOC.write_text(t, encoding='utf-8')
  541. print(' 已写入 §12.2 全部网页总清点 (自动块)')
  542. def write_doc(reg) -> None:
  543. if not DOC.is_file():
  544. print(f' (缺 {P.rel(DOC)}, 跳过写文档)')
  545. return
  546. t = DOC.read_text(encoding='utf-8')
  547. block = (f'{DOC_BEGIN}\n### 10.1 门户页面归口(自动生成,勿手改)\n\n'
  548. f'判据与说明见 `configs/portal_pages.yaml` 头部;检查器 `scripts/pages_audit.py`。\n\n'
  549. f'{render(reg, None)}\n\n{DOC_END}')
  550. if DOC_BEGIN in t and DOC_END in t:
  551. t = re.sub(re.escape(DOC_BEGIN) + r'.*?' + re.escape(DOC_END), lambda m: block, t, flags=re.S)
  552. else:
  553. t = t.rstrip() + '\n\n---\n\n## 10. 页面归口(哪些页面是产物、该不该随数据变)\n\n' + block + '\n'
  554. DOC.write_text(t, encoding='utf-8')
  555. print(f' 已写入 {P.rel(DOC)} (§10 页面归口)')
  556. write_inventory_doc()
  557. if __name__ == '__main__':
  558. from src import console
  559. console.soft()
  560. sys.exit(main())