# -*- coding: utf-8 -*- """契约四处同步机械核验(QC 退回 #1/#3/#4/#5 的防复发闸门)。 核验项 ------ 1. 契约函数 10 个:m4a_contract.CONTRACT_FUNCTIONS ↔ __init__.py 导出 ↔ init.py 注册 ↔ wwwroot/api/*.dspy 文件 ↔ scripts/load_path.py PATHS,五处集合完全相等; 2. wwwroot/api/ 下不存在未注册端点,PATHS 不指向不存在文件; 3. 每个 .dspy 的 debug 行必须是 f-string(禁普通字符串占位); 4. .dspy 禁项审计:无 import / print / uuid; 5. 所有 .py py_compile 通过; 6. 交付文件清单(deliver 用)与磁盘实际存在文件一致——本脚本直接产出真实清单。 用法:python3 scripts/check_contract_sync.py [--json out.json] """ import ast import json import os import re import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) WORKSPACE_ROOT = os.path.dirname(os.path.dirname(ROOT)) # 机构工作空间根(清单路径基准) sys.path.insert(0, ROOT) sys.path.insert(0, os.path.dirname(ROOT)) API_DIR = os.path.join(ROOT, "wwwroot", "api") PKG_DIR = os.path.join(ROOT, "pbl_agent_runtime") FORBIDDEN_IN_DSPY = ("import ", "print(", "uuid") def _contract_names(): from pbl_agent_runtime.m4a_contract import CONTRACT_FUNCTIONS return list(CONTRACT_FUNCTIONS) def _dspy_files(): if not os.path.isdir(API_DIR): return [] return sorted(f[:-5] for f in os.listdir(API_DIR) if f.endswith(".dspy")) def _load_path_entries(): path = os.path.join(ROOT, "scripts", "load_path.py") with open(path, encoding="utf-8") as handle: tree = ast.parse(handle.read()) for node in tree.body: if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "PATHS": out = [] for elt in node.value.elts: out.append((elt.elts[0].value, elt.elts[1].value)) return out return [] def _init_exports(): path = os.path.join(PKG_DIR, "__init__.py") with open(path, encoding="utf-8") as handle: tree = ast.parse(handle.read()) exported = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): for alias in node.names: exported.add(alias.asname or alias.name) all_list = set() for node in tree.body: if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "__all__": all_list = {elt.value for elt in node.value.elts} return exported, all_list def _init_registers(): """从 init.py 静态解析注册名(CONTRACT_FUNCTIONS 循环 + EXTRA_FUNCTIONS)。""" path = os.path.join(PKG_DIR, "init.py") with open(path, encoding="utf-8") as handle: source = handle.read() names = set() match = re.search(r"EXTRA_FUNCTIONS\s*=\s*\(([^)]*)\)", source, re.S) if match: names |= set(re.findall(r"'([^']+)'|\"([^\"]+)\"", match.group(1)) and [a or b for a, b in re.findall(r"'([^']*)'|\"([^\"]*)\"", match.group(1))]) return names, source def main(): problems = [] contracts = _contract_names() dspy = _dspy_files() entries = _load_path_entries() entry_paths = [p for p, _ in entries] entry_dspy = sorted(p.split("/")[-1][:-5] for p in entry_paths if p.endswith(".dspy")) # 1) 集合一致性 if sorted(contracts) != sorted(dspy): problems.append("contract vs dspy mismatch: only_contract=%s only_dspy=%s" % (sorted(set(contracts) - set(dspy)), sorted(set(dspy) - set(contracts)))) if sorted(contracts) != sorted(entry_dspy): problems.append("contract vs load_path mismatch: only_contract=%s only_path=%s" % (sorted(set(contracts) - set(entry_dspy)), sorted(set(entry_dspy) - set(contracts)))) # 2) PATHS 指向的文件必须真实存在 for path, role in entries: # PATHS 是运行时 URL 路径 /{module}/wwwroot相对路径,磁盘落点在 ROOT/wwwroot/ rel = path.lstrip("/") parts = rel.split("/", 1) rel_in_module = parts[1] if len(parts) == 2 and parts[0] == "pbl_agent_runtime" else rel full = os.path.join(ROOT, "wwwroot", rel_in_module) if not os.path.isfile(full): problems.append("load_path points to missing file: %s" % path) if role not in ("any", "user", "admin"): problems.append("load_path bad role %r for %s" % (role, path)) # 3)+4) dspy 内容审计 for name in dspy: full = os.path.join(API_DIR, "%s.dspy" % name) with open(full, encoding="utf-8") as handle: text = handle.read() lines = text.splitlines() debug_lines = [ln for ln in lines if ln.strip().startswith("debug(")] if not debug_lines: problems.append("%s.dspy: no debug line" % name) for ln in debug_lines: if "debug(f'" not in ln and 'debug(f"' not in ln: problems.append("%s.dspy: debug is not f-string -> %s" % (name, ln.strip())) if "params_kw" not in ln and "result" not in ln: problems.append("%s.dspy: debug missing real params -> %s" % (name, ln.strip())) for bad in FORBIDDEN_IN_DSPY: for idx, ln in enumerate(lines, 1): if bad in ln: problems.append("%s.dspy:%d forbidden token %r" % (name, idx, bad)) if not any(ln.strip().startswith("return ") for ln in lines): problems.append("%s.dspy: missing explicit return" % name) if name not in text: problems.append("%s.dspy: does not call contract function %s" % (name, name)) # 5) __init__ 导出 api + 契约对象 exported, all_list = _init_exports() if "api" not in exported or "api" not in all_list: problems.append("__init__.py must export `api` (QC #1)") for name in ("load_pbl_agent_runtime", "self_check"): if name not in exported: problems.append("__init__.py missing export: %s" % name) _, init_source = _init_registers() if "def api" in init_source and "_ApiAccessor" not in init_source: problems.append("init.py api must stay compatible with attribute access") # 6) py_compile 全部 .py compiled = 0 for base, dirs, files in os.walk(ROOT): dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git", "var")] for fname in files: if not fname.endswith(".py"): continue full = os.path.join(base, fname) try: with open(full, encoding="utf-8") as handle: tree = ast.parse(handle.read(), filename=full) compile(tree, full, "exec") if not [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Assign, ast.Import, ast.ImportFrom, ast.If, ast.Try, ast.With, ast.For, ast.While, ast.Expr, ast.AnnAssign))]: problems.append("empty module (no effective statement): %s" % os.path.relpath(full, ROOT)) compiled += 1 except SyntaxError as exc: problems.append("syntax error %s: %s" % (os.path.relpath(full, ROOT), exc)) except Exception as exc: problems.append("compile failed %s: %s" % (os.path.relpath(full, ROOT), exc)) # 7) 真实交付文件清单(存在性过滤,QC #5) # 清单文件自身不计入清单(否则字节数自引用抖动,QC#5 核验永远对不上) manifest_rel = os.path.abspath(os.path.join(ROOT, "scripts", "m4a_manifest.json")) inventory = [] for base, dirs, files in os.walk(ROOT): dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git", "var")] for fname in sorted(files): full = os.path.join(base, fname) if os.path.abspath(full) == manifest_rel: continue if not os.path.isfile(full): continue rel = os.path.relpath(full, WORKSPACE_ROOT) with open(full, encoding="utf-8", errors="ignore") as handle: line_count = sum(1 for _ in handle) inventory.append({"path": rel.replace(os.sep, "/"), "bytes": os.path.getsize(full), "lines": line_count}) inventory.sort(key=lambda item: item["path"]) report = { "success": not problems, "contract_count": len(contracts), "contracts": contracts, "dspy_endpoints": dspy, "load_path_entries": len(entries), "py_compiled": compiled, "problems": problems, "inventory_count": len(inventory), "inventory": inventory, } print(json.dumps({k: v for k, v in report.items() if k != "inventory"}, ensure_ascii=False, indent=2)) if "--json" in sys.argv: out = sys.argv[sys.argv.index("--json") + 1] with open(out, "w", encoding="utf-8") as handle: json.dump(report, handle, ensure_ascii=False, indent=2) print("inventory written ->", out) return 0 if not problems else 1 if __name__ == "__main__": sys.exit(main())