config_audit.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""配置审计 —— 把"配置目录/文件统一"变成机器每天能查的事 (2026-09-17 用户令 2)。
  4. ## 查什么 (每条都能指到具体文件/代码行)
  5. R1 顶层约定 configs/ 顶层只许放 `src/paths.py::TOP_LEVEL_CONFIGS` 里的运行期单件配置 +
  6. 已登记的域目录 + README.md/registry.yaml。散件一律报出来。
  7. R2 备份垃圾 任何 `*.bak*` `*~` `*.old` `*.orig` `*.tmp` `*.rej` 都是垃圾 (实测 configs/ 里
  8. 躺过一个 `serve.json.bak-bomfix`)。
  9. R3 命名 配置名不得含空格/中文; 全大写视为"命名不统一"(提示级, 因为场名/机型名有历史约定)。
  10. R4 内容绝对路径 配置**内容**里不得出现本机绝对路径 (`C:\` `D:\` `F:\` `/Users/…` `/Volumes/…` `/home/<人名>`)。
  11. R5 引用闭合 代码里 `P.config('x')` / `P.config_dir('x')` 指向的文件/目录必须存在; 不存在且**未登记**
  12. 在 `configs/registry.yaml::known_missing` 的, 按"悬空引用"报错 (登记过的记 `i`)。
  13. R6 手拼路径 代码里不许再拼 `"configs" / …` 字符串 (白名单: src/paths.py 自身、审计脚本、注释/文档串)。
  14. R7 无读者 configs/ 下每个文件要么被登记表的 consumers 覆盖, 要么所在域声明了 `no_reader_ok` + 理由。
  15. R8 场配置 `configs/farms/` 下每个文件要么是**能加载的场定义**(必需键齐), 要么在登记表里
  16. 声明为"机型/物理约束 profile"或"CSV 数据表" —— 否则就是"配了看不见"的东西。
  17. ## 退出码 (给 check / rebuild_all 用)
  18. 0 全部通过 · 5 结构问题(散件/垃圾/未登记域/未登记文件) · 6 悬空引用 · 7 内容含本机绝对路径 · 8 手拼路径 · 9 场配置不合约定
  19. ## 用法
  20. python scripts/config_audit.py # 表 + 检查
  21. python scripts/config_audit.py --list # 只打配置目录表
  22. python scripts/config_audit.py --json
  23. """
  24. from __future__ import annotations
  25. import argparse
  26. import fnmatch
  27. import json
  28. import os
  29. import pathlib
  30. import re
  31. import sys
  32. ROOT = pathlib.Path(__file__).resolve().parents[1]
  33. sys.path.insert(0, str(ROOT))
  34. from src import paths as P # noqa: E402
  35. REGISTRY = P.config('registry.yaml')
  36. CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv', '.example', '.md')
  37. JUNK = ('*.bak', '*.bak-*', '*~', '*.old', '*.orig', '*.tmp', '*.rej', '*.swp')
  38. RE_JUNK = re.compile(r'\.(?:bak|old|orig|tmp|rej|swp)(?:$|[-.])|~$')
  39. RE_BADNAME = re.compile(r'[\s]') # 空格会切断脚本参数, 一律不许
  40. RE_CN_NAME = re.compile(r'[\u4e00-\u9fff]') # 中文文件名: 项目里大量存在, 只提示不判错
  41. RE_ABS = re.compile(r'(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+|/home/[a-z][A-Za-z0-9_.\-]*)')
  42. # 配置调用的参数可能不止一个: P.config('value_assumptions', f'{farm}.yaml') —— 要把字面量参数全抓下来拼成相对路径
  43. RE_CONFIG_CALL = re.compile(r'P\.config(?:_dir)?\(([^)]*)\)')
  44. RE_STR_ARG = re.compile(r'[\'"]([^\'"]+)[\'"]')
  45. RE_HANDMADE = re.compile(r'''(?:ROOT|root|REPO|here|dst)\s*/\s*['"]configs['"]|['"]configs/[\w.-]+['"]''')
  46. # 说明性字段 (记录"当时数据在哪台机器上") 里的本机路径算**溯源信息**, 不算 bug —— 但只限注释行与这些键
  47. RE_DESCRIPTIVE_KEY = re.compile(r'^\s*#|^\s*(?:data_location|data_source|source_root|source_note|note|comment|说明|来源)\s*:')
  48. SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
  49. 'outputs', 'data', 'logs', 'run', 'release', 'resources', 'reference'}
  50. def load_registry():
  51. import yaml
  52. if not REGISTRY.is_file():
  53. raise SystemExit(f'缺配置登记表 {P.rel(REGISTRY)}')
  54. return yaml.safe_load(REGISTRY.read_text(encoding='utf-8')) or {}
  55. def cfg_files():
  56. out = []
  57. for dp, dn, fns in os.walk(P.CONFIGS):
  58. dn[:] = [d for d in dn if d not in {'__pycache__'}]
  59. for f in fns:
  60. out.append(pathlib.Path(dp) / f)
  61. return sorted(out)
  62. def py_files():
  63. out = []
  64. for dp, dn, fns in os.walk(ROOT):
  65. dn[:] = [d for d in dn if d not in SKIP_WALK]
  66. out += [pathlib.Path(dp) / f for f in fns if f.endswith('.py')]
  67. return out
  68. def audit():
  69. """→ (rc, results, info)。results = [(level, item, note, rc)]。"""
  70. reg = load_registry()
  71. res: list[tuple[str, str, str, int]] = []
  72. top_ok = {t['file'] for t in reg.get('top_level') or []}
  73. domain_dirs = {d['dir'] for d in reg.get('domains') or []}
  74. known_missing = set()
  75. known_why = {}
  76. for m in reg.get('known_missing') or []:
  77. for name in [m['file']] + list(m.get('also_missing') or []):
  78. known_missing.add(name)
  79. known_why[name] = m.get('impact', '')
  80. # ── R1/R2/R3/R4: 文件层
  81. for f in cfg_files():
  82. rel = f.relative_to(P.CONFIGS).as_posix()
  83. if f.name == 'registry.yaml' or f.suffix == '.md':
  84. continue
  85. if '/' not in rel:
  86. if f.name not in top_ok:
  87. res.append(('X', rel, 'configs/ 顶层只许放已登记的运行期单件配置 (见登记表 top_level)', 5))
  88. else:
  89. d = rel.split('/')[0]
  90. if d not in domain_dirs:
  91. res.append(('X', rel, f'所在域 {d}/ 未在登记表登记 (新域要写明 kind + consumers)', 5))
  92. if RE_JUNK.search(f.name) or any(fnmatch.fnmatch(f.name, j) for j in JUNK):
  93. res.append(('X', rel, '备份/垃圾文件 (配置文件只留正本; 改历史靠 git)', 5))
  94. if RE_BADNAME.search(f.name):
  95. res.append(('X', rel, '文件名含空格 —— 空格会切断脚本参数, 配置名一律用 [a-z0-9_.-]', 5))
  96. elif RE_CN_NAME.search(f.name):
  97. res.append(('?', rel, '文件名含中文 (项目里常见, 不是错; 注意某些工具/编码环境会踩)', 0))
  98. elif re.search(r'[A-Z]', f.stem) and '/' in rel:
  99. res.append(('?', rel, '文件名含大写 (历史约定, 不是错; 新文件建议小写)', 0))
  100. if f.suffix.lower() in ('.yaml', '.yml', '.json', '.csv'):
  101. try:
  102. body = f.read_text(encoding='utf-8-sig', errors='replace')
  103. except OSError:
  104. body = ''
  105. hit_desc = hit_code = None
  106. for ln in body.splitlines():
  107. m = RE_ABS.search(ln)
  108. if not m:
  109. continue
  110. if RE_DESCRIPTIVE_KEY.match(ln):
  111. hit_desc = hit_desc or m.group(0)
  112. else:
  113. hit_code = hit_code or m.group(0)
  114. if hit_code:
  115. res.append(('X', rel, f'内容含本机绝对路径 {hit_code[:24]!r} (在**被读取的字段**里 ⇒ 换机必失配)', 7))
  116. elif hit_desc:
  117. res.append(('?', rel, f'说明性字段/注释里记了本机路径 {hit_desc[:24]!r} —— 属溯源信息(当时数据在哪台机器), 不算失配', 0))
  118. # ── R5/R6: 代码层
  119. seen_conf = set()
  120. for p in py_files():
  121. t = p.read_text(encoding='utf-8', errors='replace')
  122. if p.name != 'config_audit.py': # 本脚本的说明文字里就有 P.config('x') 之类的例子, 别自证其罪
  123. for args in RE_CONFIG_CALL.findall(t):
  124. parts = RE_STR_ARG.findall(args)
  125. if not parts:
  126. continue
  127. rel_call = '/'.join(parts).replace('\\', '/')
  128. # f-string 参数 (P.config(f'analysis_lock_{farm}.yaml')) 在源码里是字面量带花括号:
  129. # 把 {…} 当通配符, 只要有任一匹配文件就算"在位", 否则报缺。
  130. if '{' in rel_call:
  131. import glob as _glob
  132. pat = str(P.CONFIGS / re.sub(r'\{[^}]*\}', '*', rel_call))
  133. if _glob.glob(pat):
  134. continue
  135. wildcard = rel_call
  136. known = known_missing | {k.replace('<场>', '*') for k in known_missing}
  137. if any(fnmatch.fnmatch(wildcard, k) for k in known):
  138. res.append(('i', f'{P.rel(p)} → configs/{wildcard}',
  139. '悬空引用(已知): per-场配置未随包 (见登记表 known_missing)', 0))
  140. else:
  141. res.append(('X', f'{P.rel(p)} → configs/{wildcard}',
  142. '代码引用的 per-场配置一个都不存在, 且未登记 ⇒ 补文件或删引用', 6))
  143. continue
  144. seen_conf.add(rel_call)
  145. target = P.CONFIGS / rel_call
  146. if target.exists():
  147. continue
  148. if rel_call in known_missing or any(rel_call.startswith(k.replace('<场>', '')) for k in known_missing):
  149. res.append(('i', f'{P.rel(p)} → configs/{rel_call}',
  150. f'悬空引用(已知): {known_why.get(rel_call, "见登记表 known_missing")[:80]}', 0))
  151. else:
  152. res.append(('X', f'{P.rel(p)} → configs/{rel_call}',
  153. '代码引用的配置不存在, 且未登记在 known_missing ⇒ 补文件或删引用', 6))
  154. for i, ln in enumerate(t.splitlines(), 1):
  155. s = ln.strip()
  156. # 豁免"配置取用口自己的定义文件": 原先按旧路径 src/paths.py 判, P1 迁移后它搬到
  157. # app_common/common/app_common_guanlan/paths.py —— 改成**按取用口所在文件**判, 位置无关。
  158. import sys as _s
  159. _accessor = _s.modules.get(P.config.__module__)
  160. _accessor_file = pathlib.Path(_accessor.__file__).resolve() if getattr(_accessor, '__file__', None) else None
  161. if s.startswith('#') or p.name in ('config_audit.py',) or p == P.SRC / 'paths.py' \
  162. or (_accessor_file is not None and p.resolve() == _accessor_file):
  163. continue
  164. if 'config-path-allow' in ln or 'portability-allow' in ln:
  165. continue
  166. if RE_HANDMADE.search(ln):
  167. res.append(('!', f'{P.rel(p)}:{i}', f'手拼 configs 路径: {s[:70]} ⇒ 改用 P.config()/P.config_dir()', 8))
  168. # ── R7: 无读者
  169. declared = json.dumps(reg, ensure_ascii=False)
  170. for d in reg.get('domains') or []:
  171. if (d.get('consumers') or []) or d.get('no_reader_ok'):
  172. continue
  173. res.append(('X', f'{d["dir"]}/', '未声明 consumers, 也没写 no_reader_ok + 理由', 5))
  174. for f in cfg_files():
  175. rel = f.relative_to(P.CONFIGS).as_posix()
  176. if '/' not in rel or f.suffix == '.md' or f.name in top_ok:
  177. continue
  178. # 逐件"有没有人提过它"太吵 (170 件里绝大多数靠域级 consumers 覆盖) —— 只查**域级声明**。
  179. # 域级没声明 consumers 又没写 no_reader_ok 的, 由上面的域检查报出来。
  180. _ = rel
  181. # ── R8: 场配置
  182. from src.windscada import config as WC
  183. for f, keys in WC.foreign_farm_files():
  184. res.append(('i', f'configs/farms/{f.name}', f'非场定义 (顶层键 {keys}) ⇒ 不参与场加载, 已登记为机型/物理约束 profile', 0))
  185. farm_dom = next((d for d in reg.get('domains') or [] if d['dir'] == 'farms'), {})
  186. n_prof = sum(1 for f, _ in WC.foreign_farm_files())
  187. declared_prof = next((s for s in (farm_dom.get('subkinds') or []) if 'profile' in s.get('name', '')), {})
  188. if declared_prof and int(declared_prof.get('present', -1)) not in (-1, n_prof):
  189. res.append(('X', 'configs/farms/', f'登记表写 profile {declared_prof.get("present")} 个, 实际 {n_prof} 个', 9))
  190. for name, src in WC.available().items():
  191. if src != '内置' and not (P.ROOT / src).is_file():
  192. res.append(('X', f'configs/farms/{name}', f'available() 列出了 {src}, 但文件不在', 9))
  193. info = dict(config_files=len(cfg_files()),
  194. domains=len(domain_dirs),
  195. top_level=sorted(top_ok),
  196. known_missing=sorted(known_missing),
  197. farm_defs=list(WC.available()),
  198. farm_profiles=n_prof)
  199. rc_map = {r[3] for r in res if r[0] not in ('OK', 'i', '?') and r[3]}
  200. return (max(rc_map) if rc_map else 0), res, info
  201. def main() -> int:
  202. ap = argparse.ArgumentParser()
  203. ap.add_argument('--list', action='store_true', help='只打配置目录表')
  204. ap.add_argument('--json', action='store_true')
  205. a = ap.parse_args()
  206. rc, res, info = audit()
  207. if a.json:
  208. print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in res],
  209. info=info), ensure_ascii=False, indent=1))
  210. return rc
  211. if a.list or True:
  212. print('== 配置目录约定 (configs/registry.yaml) ==')
  213. print(f' 运行期单件: {", ".join(info["top_level"])}')
  214. for d in load_registry().get('domains') or []:
  215. print(f' {d["dir"]:20s} {d.get("kind", "")[:52]}')
  216. print(f' 配置文件合计 {info["config_files"]} 件 · 域 {info["domains"]} 个 · '
  217. f'场定义 {len(info["farm_defs"])} 个 · 机型 profile {info["farm_profiles"]} 件')
  218. if not a.list:
  219. lvl = {}
  220. for level, item, note, r in res:
  221. lvl[level] = lvl.get(level, 0) + 1
  222. print(f'\n== 检查: 不一致 {lvl.get("X", 0)} · 待处理 {lvl.get("!", 0)} · 提示 {lvl.get("?", 0)} · '
  223. f'已知缺口 {lvl.get("i", 0)} ==')
  224. for level, item, note, r in res:
  225. if level in ('X', '!'):
  226. print(f' [{level}] {item}: {note}')
  227. seen = set()
  228. for level, item, note, r in res:
  229. if level == 'i' and note not in seen:
  230. seen.add(note)
  231. print(f' [i] {item}: {note}')
  232. print(f'结论: {"全部符合约定" if rc == 0 else "见上"}; 退出码 {rc}')
  233. return rc
  234. if __name__ == '__main__':
  235. from src import console
  236. console.soft()
  237. sys.exit(main())