# -*- coding: utf-8 -*- """M1b import 闭包核验器(静态,零执行)。 对 modules/ 下所有 .py 做 AST 扫描,解析 `from X import a, b` / `import X` 中 **可在本仓库定位到源文件**的跨文件符号引用,逐条核对被引符号是否真的存在于 目标模块的导出面(顶层 def/class/赋值名 + `__all__` + 该模块自身 import 进来的 再导出名 + `try/except ImportError` 兼容块里的名字)。 输出: * 逐条断裂清单 `[path, lineno, symbol, kind]`(kind = missing_symbol / missing_module) * 按 (kind, symbol) 聚合的汇总 * 分区统计:本模块(pbl_blueprint)内 vs 跨模块(pbl_common / pbl_agent_runtime / ...) 用法: python3 tools/m1b_import_closure.py # 全 modules/ 扫描 python3 tools/m1b_import_closure.py --self # 只扫 pbl_blueprint 包内 python3 tools/m1b_import_closure.py --json # 额外输出 JSON 明细 """ from __future__ import print_function import ast import io import json import os import sys from collections import defaultdict HERE = os.path.dirname(os.path.abspath(__file__)) REPO = os.path.dirname(HERE) # modules/pbl_blueprint MODULES = os.path.dirname(REPO) # modules/ SELF_PKG = os.path.join(REPO, "pbl_blueprint") # -------------------------------------------------------------------------- # 模块名 -> 源文件路径 索引 # -------------------------------------------------------------------------- def build_index(): """{dotted.module: filepath},覆盖 modules/ 下所有包与顶层模块。""" idx = {} for entry in sorted(os.listdir(MODULES)): root = os.path.join(MODULES, entry) if not os.path.isdir(root) or entry.startswith("."): continue # 包目录:modules/{entry}/{entry}/__init__.py pkg = os.path.join(root, entry.replace("-", "_")) if os.path.isfile(os.path.join(pkg, "__init__.py")): base = entry.replace("-", "_") idx[base] = os.path.join(pkg, "__init__.py") for dirpath, dirnames, filenames in os.walk(pkg): dirnames[:] = [d for d in dirnames if d not in ("__pycache__", ".git")] for fn in filenames: if not fn.endswith(".py"): continue full = os.path.join(dirpath, fn) rel = os.path.relpath(full, os.path.dirname(pkg)) mod = rel[:-3].replace(os.sep, ".") if mod.endswith(".__init__"): mod = mod[: -len(".__init__")] idx[mod] = full # 顶层单文件模块:modules/{entry}.py 不存在,跳过 return idx # -------------------------------------------------------------------------- # 单文件导出面 # -------------------------------------------------------------------------- _EXPORT_CACHE = {} def _names_from_body(body, out, depth=0): """收集顶层(含 try/if 块内)定义与导入的名字。""" for node in body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): out.add(node.name) elif isinstance(node, ast.Assign): for t in node.targets: _collect_targets(t, out) elif isinstance(node, ast.AnnAssign): _collect_targets(node.target, out) elif isinstance(node, ast.AugAssign): _collect_targets(node.target, out) elif isinstance(node, ast.Import): for al in node.names: out.add((al.asname or al.name).split(".")[0]) elif isinstance(node, ast.ImportFrom): for al in node.names: if al.name == "*": out.add("*") else: out.add(al.asname or al.name) elif isinstance(node, (ast.Try, ast.If, ast.With, ast.For, ast.While)): sub = getattr(node, "body", []) or [] for attr in ("orelse", "finalbody"): sub += getattr(node, attr, []) or [] for h in getattr(node, "handlers", []) or []: sub += h.body or [] _names_from_body(sub, out, depth + 1) def _collect_targets(target, out): if isinstance(target, ast.Name): out.add(target.id) elif isinstance(target, (ast.Tuple, ast.List)): for e in target.elts: _collect_targets(e, out) elif isinstance(target, ast.Starred): _collect_targets(target.value, out) def exports_of(path): """返回 (names:set, has_star:bool, all_list:list|None)。""" key = path if key in _EXPORT_CACHE: return _EXPORT_CACHE[key] names, all_list = set(), None try: with io.open(path, encoding="utf-8") as f: src = f.read() tree = ast.parse(src, filename=path) except Exception as e: _EXPORT_CACHE[key] = (set(), False, None, "PARSE_ERROR: %s" % e) return _EXPORT_CACHE[key] _names_from_body(tree.body, names) # __all__ 字面量 for node in tree.body: if isinstance(node, ast.Assign): for t in node.targets: if isinstance(t, ast.Name) and t.id == "__all__": try: all_list = [ast.literal_eval(e) for e in node.value.elts] names.update(all_list) except Exception: pass parse_err = None res = (names, "*" in names, all_list, parse_err) _EXPORT_CACHE[key] = res return res # -------------------------------------------------------------------------- # 解析 import 语句 # -------------------------------------------------------------------------- def resolve(modname, index, cur_file): """把 dotted 模块名解析成源文件路径;解析不到返回 None(视为外部依赖)。""" if modname in index: return index[modname] # 相对当前包的猜测(from . import x 已由 level 处理) return None def scan_file(path, index, breaks): try: with io.open(path, encoding="utf-8") as f: src = f.read() tree = ast.parse(src, filename=path) except Exception as e: breaks.append((path, 0, "" % e, "parse_error")) return pkg_root = None for mod, fp in index.items(): if fp == path: pkg_root = mod.rsplit(".", 1)[0] if "." in mod else mod break for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): level = node.level or 0 mod = node.module or "" if level: # 相对导入:按当前文件所在包回溯 base = path for _ in range(level): base = os.path.dirname(base) rel = mod.replace(".", os.sep) if mod else "" cand_pkg = os.path.join(base, rel, "__init__.py") if rel else os.path.join(base, "__init__.py") cand_mod = os.path.join(base, rel + ".py") if rel else None if os.path.isfile(cand_pkg): target = cand_pkg elif cand_mod and os.path.isfile(cand_mod): target = cand_mod else: target = None tname = ("" % ("." * level, mod)) else: target = resolve(mod, index, path) tname = mod if target is None: # 外部依赖(标准库/三方/未落库模块)——不计入本仓库闭包断裂, # 但若形如 pbl_* / 本仓库已知顶层包前缀,则记 missing_module。 if mod.startswith("pbl_") or (level and False): for al in node.names: breaks.append((path, node.lineno, "%s.%s" % (tname, al.name), "missing_module")) continue names, has_star, all_list, perr = exports_of(target) if perr: breaks.append((target, 0, perr, "parse_error")) if has_star: continue for al in node.names: if al.name == "*": continue if al.name not in names: breaks.append((path, node.lineno, al.name, "missing_symbol:%s" % tname)) elif isinstance(node, ast.Import): for al in node.names: if resolve(al.name, index, path) is None and al.name.startswith("pbl_"): breaks.append((path, node.lineno, al.name, "missing_module")) def main(): argv = sys.argv[1:] self_only = "--self" in argv as_json = "--json" in argv index = build_index() targets = [] if self_only: for dirpath, dirnames, filenames in os.walk(SELF_PKG): dirnames[:] = [d for d in dirnames if d != "__pycache__"] for fn in filenames: if fn.endswith(".py"): targets.append(os.path.join(dirpath, fn)) for extra in ("tests", "tools", "scripts"): d = os.path.join(REPO, extra) if os.path.isdir(d): for dirpath, dirnames, filenames in os.walk(d): dirnames[:] = [x for x in dirnames if x != "__pycache__"] for fn in filenames: if fn.endswith(".py"): targets.append(os.path.join(dirpath, fn)) else: for mod, fp in index.items(): targets.append(fp) breaks = [] for fp in sorted(set(targets)): scan_file(fp, index, breaks) in_self, cross = [], [] for b in breaks: p = b[0] if p.startswith(REPO + os.sep) or p == REPO: in_self.append(b) else: cross.append(b) def dump(title, rows): print("\n=== %s (%d) ===" % (title, len(rows))) agg = defaultdict(list) for path, line, sym, kind in rows: agg[(kind, sym)].append("%s:%s" % (os.path.relpath(path, MODULES), line)) for (kind, sym), locs in sorted(agg.items()): print(" %-22s %-40s x%-3d %s" % (kind, sym, len(locs), locs[0])) dump("本模块 pbl_blueprint 内断裂", in_self) dump("跨模块断裂(他模块职责,冒泡 PM)", cross) if as_json: out = os.path.join(REPO, "tools", "m1b_closure_report.json") with io.open(out, "w", encoding="utf-8") as f: json.dump({ "self_breaks": [list(b) for b in in_self], "cross_breaks": [list(b) for b in cross], "self_count": len(in_self), "cross_count": len(cross), }, f, ensure_ascii=False, indent=2) print("\n[json] %s" % out) print("\nTOTAL self=%d cross=%d" % (len(in_self), len(cross))) return 0 if not in_self else 2 if __name__ == "__main__": sys.exit(main())