module_boundary_audit.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""模块边界审计(用户令 2026-09-22:按算法/数据接入管理/前端/后端等模块重构,组件化、高内聚低耦合、可插拔)。
  4. ## 它查什么(每条都由 `configs/modules.yaml` 驱动)
  5. | 规则 | 内容 | 退出码 |
  6. |---|---|---|
  7. | R1 结构 | 每个登记模块目录含 `<pkg>/__init__.py`、`<pkg>/api.py`、`common/`(模块内通用组件)、`README.MD` | 5 |
  8. | R2 边界 | 模块之间**只许经对方 `api.py`**:`app_x/**/*.py` 里 import 另一个 `app_y` 的实现包(`app_y.app_y_guanlan.*`,非 `api`)即违规 | 6 |
  9. | R3 依赖方向 | 只许 import `allow` 里列出的模块;反向依赖/成环即违规 | 7 |
  10. | R4 公共层纯净 | `app_common/**` 不得 import 任何其它业务模块 | 7 |
  11. | R5 接口可用 | 每个 `api.py` 的 `_TARGETS`(含带注解写法)转发目标必须真实存在 | 8 |
  12. 另外**统计迁移进度**(不算失败):`src/**`、`scripts/**` 里还有多少件尚未迁进模块目录(P1–P7 的度量)。
  13. ## 用法
  14. python scripts/module_boundary_audit.py # 审计(rc=0 通过)
  15. python scripts/module_boundary_audit.py --brief # 只打结论与进度
  16. 退出码: 0 通过 · 5 结构缺失 · 6 边界违规 · 7 依赖违规 · 8 接口目标缺失
  17. """
  18. from __future__ import annotations
  19. try:
  20. from app_common.app_common_guanlan.api import install_root as _install_root
  21. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  22. from pathlib import Path as _P
  23. def _install_root(_f): return _P(_f).resolve().parents[4]
  24. import ast
  25. import pathlib
  26. import re
  27. import sys
  28. ROOT = _install_root(__file__)
  29. sys.path.insert(0, str(ROOT))
  30. from src import paths as P # noqa: E402
  31. def load_registry() -> dict:
  32. import yaml
  33. return yaml.safe_load(P.config('modules.yaml').read_text(encoding='utf-8'))
  34. def module_pkg(name: str) -> str:
  35. return f'{name}_guanlan'
  36. def _imports_of(path: pathlib.Path) -> list[str]:
  37. """→ 该文件里所有 import 的模块名(`import a.b` 与 `from a.b import c` 都算 a.b)。"""
  38. try:
  39. tree = ast.parse(path.read_text(encoding='utf-8'))
  40. except SyntaxError:
  41. return []
  42. out = []
  43. for n in ast.walk(tree):
  44. if isinstance(n, ast.Import):
  45. out += [a.name for a in n.names]
  46. elif isinstance(n, ast.ImportFrom) and n.module:
  47. out.append(n.module)
  48. return out
  49. def audit(verbose: bool = True):
  50. reg = load_registry()
  51. mods = {m['name']: m for m in reg['modules']}
  52. problems: list[tuple[str, str]] = [] # (规则, 说明)
  53. notes: list[str] = []
  54. # R1 结构(P9 新布局:实现包直接挂模块根;common/ 作模块内通用组件目录;README 大小写都认)
  55. for name, m in mods.items():
  56. base = ROOT / name
  57. readme = next((base / r for r in ('README.MD', 'README.md') if (base / r).is_file()), None)
  58. for rel in (f'common/__init__.py', f'{module_pkg(name)}/__init__.py', f'{module_pkg(name)}/api.py'):
  59. if not (base / rel).is_file():
  60. problems.append(('R1', f'{name}/ 缺 {rel}'))
  61. if readme is None:
  62. problems.append(('R1', f'{name}/ 缺 README.MD(或 README.md)'))
  63. # R5 接口目标存在(P9:api 在模块根下;★同时认 `_TARGETS: dict[...] = {…}` 这种带注解的写法)
  64. for name in mods:
  65. api = ROOT / name / module_pkg(name) / 'api.py'
  66. if not api.is_file():
  67. continue
  68. try:
  69. tree = ast.parse(api.read_text(encoding='utf-8'))
  70. except SyntaxError as e:
  71. problems.append(('R1', f'{name}/api.py 语法错误: {e}'))
  72. continue
  73. tg = {}
  74. for n in tree.body:
  75. if isinstance(n, ast.AnnAssign) and getattr(n.target, 'id', '') == '_TARGETS':
  76. val = n.value
  77. elif isinstance(n, ast.Assign) and getattr(n.targets[0], 'id', '') == '_TARGETS':
  78. val = n.value
  79. else:
  80. continue
  81. try:
  82. tg = ast.literal_eval(val)
  83. except Exception: # noqa: BLE001
  84. tg = {}
  85. for pub, target in tg.items():
  86. rel = pathlib.Path(*target.split('.'))
  87. if not ((ROOT / rel).with_suffix('.py').is_file() or (ROOT / rel / '__init__.py').is_file()):
  88. problems.append(('R5', f'{name}.api.{pub} → {target} 的转发目标不存在'))
  89. # R2/R3/R4 边界与依赖
  90. for name, m in mods.items():
  91. allow = set(m.get('allow') or [])
  92. for f in (ROOT / name).rglob('*.py'):
  93. for imp in _imports_of(f):
  94. parts = imp.split('.')
  95. if parts[0] not in mods or parts[0] == name:
  96. continue
  97. other = parts[0]
  98. # 只许 app_<other>.app_<other>_guanlan.api(P9 布局;旧的 …common.… 别名写法同样放行)
  99. parts_n = [p for i, p in enumerate(parts) if not (i == 1 and p == 'common')]
  100. ok_face = (len(parts_n) >= 3 and parts_n[1] == module_pkg(other) and parts_n[2] == 'api')
  101. if not ok_face:
  102. problems.append(('R2', f'{f.relative_to(ROOT).as_posix()} 直接 import {imp}'
  103. f'(应走 {other}.{module_pkg(other)}.api)'))
  104. if other not in allow:
  105. problems.append(('R3', f'{f.relative_to(ROOT).as_posix()} import {other}'
  106. f',但 {name} 的 allow 未包含它'))
  107. if name == 'app_common':
  108. problems.append(('R4', f'{f.relative_to(ROOT).as_posix()} 公共层依赖了业务模块 {other}'))
  109. # R6 入口与布局解耦: 安装器必须能在当前布局下解析出包版本
  110. _ver_cands = ['app_common/app_common_guanlan/version.py', 'src/version.py']
  111. _found = None
  112. for _c in _ver_cands:
  113. _f = ROOT / _c
  114. if _f.is_file() and re.search(r"^VERSION\s*=\s*'([^']+)'", _f.read_text(encoding='utf-8'), re.M):
  115. _found = (_c, re.search(r"^VERSION\s*=\s*'([^']+)'", _f.read_text(encoding='utf-8'), re.M).group(1))
  116. break
  117. if not _found:
  118. problems.append(('R6', f'当前布局下没有任何候选版本文件含 `VERSION = \'…\'` 字面量(候选: {_ver_cands})'
  119. ' ⇒ install.ps1/install.sh 会读不到包版本'))
  120. else:
  121. _ps1 = (ROOT / 'install.ps1').read_text(encoding='utf-8') if (ROOT / 'install.ps1').is_file() else ''
  122. _sh = (ROOT / 'install.sh').read_text(encoding='utf-8') if (ROOT / 'install.sh').is_file() else ''
  123. if _ps1 and 'app_common' not in _ps1:
  124. problems.append(('R6', 'install.ps1 未包含新布局的候选路径(app_common/...)⇒ 平台件搬家后会读不到版本'))
  125. if _sh and 'app_common' not in _sh:
  126. problems.append(('R6', 'install.sh 未包含新布局的候选路径(app_common/...)⇒ 平台件搬家后会读不到版本'))
  127. # R7 入口读 JSON 必须指定 UTF-8(PS 5.1 默认 ANSI)
  128. _ps1f = ROOT / 'install.ps1'
  129. if _ps1f.is_file():
  130. for _i, _ln in enumerate(_ps1f.read_text(encoding='utf-8').splitlines(), 1):
  131. if _ln.strip().startswith('#'):
  132. continue # 注释里提到 Get-Content/.json 不算
  133. if 'Get-Content' in _ln and '.json' in _ln and '-Encoding' not in _ln:
  134. problems.append(('R7', f'install.ps1:{_i} 读 JSON 未指定 -Encoding UTF8 ⇒ PS 5.1 会按 ANSI 解码中文'))
  135. # R8 CLI 兼容壳必须同面转发实现的公开名(否则 importlib 调用其函数会 AttributeError)
  136. for _shim in sorted((ROOT / 'scripts').glob('*.py')):
  137. _txt = _shim.read_text(encoding='utf-8')
  138. if '兼容转发(P' not in _txt:
  139. continue
  140. if 'globals().setdefault' not in _txt and 'sys.modules[__name__]' not in _txt:
  141. problems.append(('R8', f'scripts/{_shim.name} 是兼容壳但未转发实现公开名 '
  142. '⇒ 用 importlib 调其函数的调用方会 AttributeError'))
  143. # 迁移进度(不算失败)
  144. legacy = [p for p in list((ROOT / 'src').rglob('*.py')) + list((ROOT / 'scripts').rglob('*.py'))
  145. if p.is_file()]
  146. notes.append(f'尚未迁入模块目录的既有实现件: src/** {len(list((ROOT / "src").rglob("*.py")))} 件 · '
  147. f'scripts/** {len(list((ROOT / "scripts").rglob("*.py")))} 件(迁移进度按 docs/重构方案_模块化_v0.1.md 的 P1–P9 推进)')
  148. rc = 0
  149. if any(p[0] in ('R1',) for p in problems):
  150. rc = 5
  151. if any(p[0] == 'R2' for p in problems):
  152. rc = rc or 6
  153. if any(p[0] in ('R3', 'R4', 'R6', 'R7', 'R8') for p in problems):
  154. rc = rc or 7
  155. if any(p[0] == 'R5' for p in problems):
  156. rc = rc or 8
  157. if verbose:
  158. print('模块边界审计 · 登记模块 %d 个(%s)' % (len(mods), '、'.join(mods)))
  159. for n in notes:
  160. print(' [i]', n)
  161. if problems:
  162. for rule, msg in problems:
  163. print(' [X] %s %s' % (rule, msg))
  164. else:
  165. print(' [OK] 结构齐全 · 模块间只经 api 调用 · 依赖方向合规 · 公共层纯净 · 接口目标全在位')
  166. return rc, problems, notes, mods
  167. def main() -> int:
  168. brief = '--brief' in sys.argv
  169. rc, problems, notes, mods = audit(verbose=not brief)
  170. if brief:
  171. print('[OK] module_boundary_audit rc=0(登记 %d 模块)' % len(mods) if rc == 0
  172. else '[X] module_boundary_audit rc=%d(%d 条违规)' % (rc, len(problems)))
  173. return rc
  174. if __name__ == '__main__':
  175. raise SystemExit(main())