ymq 12f8384d49 fix(import_closure): 破M1b无限退回死循环——globals()运行时注入逃逸阀+责任范围过滤(2026-09-17 pbls实锤)
根因1(逃逸阀缺口): agent用工具脚本生成db.py compat块(globals()[_name]=_val逐符号注入)修import闭包断裂,
运行时实测全通(pbl_blueprint.db/api/blueprint_crud/subobjects/templates import OK,PblError/require_tenant/
get_env全True),但ast静态量不出Subscript下标赋值→门禁恒报20处断裂→QC按'声称修复vs引擎实测断裂直接矛盾'
判造假证据→4轮退回达上限→fault→项目paused。与PEP562 __getattr__同类:静态不可判定即保守放行。
识别三形态: globals()[k]=v(含For/Try嵌套walk_top可达) / globals().update({...}) / _g=globals()别名。
函数体内globals()不算模块级注入(walk_top只走模块级stmts,不误放行)。

根因2(责任范围过宽): 依赖方向拉进来的外部文件(pbl_agent_runtime/api.py因import pbl_blueprint入范围)的
断裂源在pbl_common(半迁移存量欠账114处,别的任务的账)——违背门禁自己声明的'任务未触碰的包存量断裂不拦,
防死锁'原则,M1b实测26处里6处是pbl_common欠账,小任务永远修不完全空间账。
修: problems过滤 source.split('.')[0] in owned_pkgs(只拦涉及仓库自有包的契约断裂)。
原pbl_common事故形状防护不受影响: 重写pbl_common的任务owned_pkgs含pbl_common,断裂全保留。

修后M1b剩余真实断裂仅1处: m1b_common.py:114 from .errors import PblError而errors.py只有
PblBlueprintError——门禁拦它是对的(补一行别名即闭环),FAIL消息可行动。
本地走查6例通过(注入3形态识别/普通模块不误伤/函数内globals不误放行/真实compat块形状)。
2026-09-17 22:13:51 +08:00

411 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""import 闭包静态核验(deliver 硬门禁 + 交付件机械核验段共用,2026-09-16)。
事故(pbls pbl_common 半迁移重写):develop 重写公共内核时删除 errors.py/
tenant.py 既有符号面(TenantMissingError/ErrorCode/PblError 类族 → 换
PBLError/err/fail 函数族),但 context.py/api.py/crud_factory.py 等内部文件
与 6+ 依赖模块仍 import 旧名 → `from pbl_common.api import ...` 全部
ImportError,公共内核对外契约瘫痪。设计文档明文「接口变更需向后兼容
(新增可选参数,不删既有签名)」,但引擎既有门禁全是【单文件】尺子
(py_compile 空壳/字节数/git 收口),量不出【跨文件符号引用断裂】——
半迁移状态照样过闸,事故到运行期才暴露。
机制(确定性,stdlib-only ast 静态分析,不执行被检代码):
1. 扫描工作空间 modules/*/ 与 apps/*/ 的 Python 包,建 {包名: 包根} 映射。
2. 对每个 .py 收集模块级名字绑定(def/class/赋值/import 别名,含 If/Try
分支),对每条 from-import 核验符号在目标模块有定义(含再导出/子模块)。
3. 逃逸阀(防误报死锁):目标模块定义 module-level __getattr__(PEP 562
动态属性)或 module-level `globals()[...] = ...` 运行时符号注入(2026-09-17
pbls M1b 死循环根治:agent 用工具脚本生成 compat 注入块修断裂,运行时
import 全通但 ast 量不出下标赋值 → 门禁恒报断裂 → QC 判「声称修复 vs
实测断裂」矛盾 → 无限退回;与 PEP 562 同类,静态不可判定即保守放行)
→ 跳过该模块符号级核验;ast 解析失败跳过(语法归空壳门禁);外部库
(顶层名不在包映射)不核验;本核验器自身异常 → 返回 None 放行(记日志)。
责任范围过滤(2026-09-17 同日修正):依赖方向拉进来的外部文件,只拦
「引用涉及包」的断裂;它引用其他包(如半迁移欠账 pbl_common)的存量断裂
不是本任务责任,不进门禁清单——否则小任务背上全空间欠账,永远修不完
(pbls M1b 实测:26 处里 6 处是 pbl_agent_runtime→pbl_common 存量断裂)。
门禁范围(check_closure_for_delivery):
- 本任务写入 .py 所属的包 = 涉及包;核验对象 = 涉及包内全部文件 + 全空间
import 涉及包的文件。改公共包断掉依赖方(事故形状)会在 deliver 当轮被拦,
FAIL 消息给出逐条 文件:行:符号 → agent 要么补兼容符号、要么同轮改齐引用
(用户裁定:保持兼容或一并改造,二选一,禁止半迁移交付)。
- 任务未触碰的包即使有存量断裂也不拦(不是本任务责任,防死锁)。
"""
import ast
import logging
import os
logger = logging.getLogger("pipeline.import_closure")
_SKIP_DIRS = {"__pycache__", ".git", "node_modules", ".venv", "venv"}
_NON_PKG_DIRS = {"scripts", "tests", "wwwroot", "docs", "json", "models",
"init", "skill", "backups"}
def _add_target(t, names):
if isinstance(t, ast.Name):
names.add(t.id)
elif isinstance(t, (ast.Tuple, ast.List)):
for e in t.elts:
_add_target(e, names)
def _collect_defs(tree):
"""模块级名字绑定集合 + 是否定义 __getattr__/globals()注入 + 星号导入列表。"""
names = set()
dynamic = False
stars = []
def _is_ns_call(node):
"""globals()/vars()/locals() 调用形态(运行时符号注入,静态不可判定)。"""
return (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id in ("globals", "vars", "locals"))
def walk_top(stmts):
nonlocal dynamic
for s in stmts:
if isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names.add(s.name)
if (isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef))
and s.name == "__getattr__"):
dynamic = True
elif isinstance(s, ast.Assign):
for t in s.targets:
_add_target(t, names)
# globals()[name] = val / vars()[...] = ... 运行时注入:
# 符号面静态不可判定 → 整模块按动态放行(同 PEP 562 逃逸阀)
if (isinstance(t, ast.Subscript) and _is_ns_call(t.value)):
dynamic = True
if _is_ns_call(s.value):
# X = globals() 之后再 X[...] = ...:保守也视为注入形态
dynamic = True
elif isinstance(s, ast.Expr) and isinstance(s.value, ast.Call):
# globals().update({...}) 形态
fn = s.value.func
if (isinstance(fn, ast.Attribute) and fn.attr == "update"
and _is_ns_call(fn.value)):
dynamic = True
elif isinstance(s, ast.AnnAssign):
if s.target is not None:
_add_target(s.target, names)
elif isinstance(s, ast.Import):
for a in s.names:
names.add(a.asname or a.name.split(".")[0])
elif isinstance(s, ast.ImportFrom):
for a in s.names:
if a.name == "*":
stars.append((s.module or "", s.level))
continue
names.add(a.asname or a.name)
elif isinstance(s, (ast.If, ast.Try, ast.While, ast.For,
ast.With, ast.AsyncFor, ast.AsyncWith)):
for field in ("body", "orelse", "finalbody"):
walk_top(getattr(s, field, []) or [])
for h in getattr(s, "handlers", []) or []:
if h.name:
names.add(h.name)
walk_top(h.body or [])
walk_top(tree.body)
return names, dynamic, stars
def build_package_map(space_dir):
"""扫描 modules/*/ 与 apps/*/,返回 {包名: 包父目录}(含 __init__.py 的目录)。"""
pkgs = {}
for group in ("modules", "apps"):
base = os.path.join(space_dir, group)
if not os.path.isdir(base):
continue
for repo in sorted(os.listdir(base)):
repo_dir = os.path.join(base, repo)
if not os.path.isdir(repo_dir) or repo.startswith("."):
continue
if os.path.isfile(os.path.join(repo_dir, "__init__.py")):
pkgs.setdefault(repo, os.path.dirname(repo_dir))
for sub in sorted(os.listdir(repo_dir)):
sub_dir = os.path.join(repo_dir, sub)
if (os.path.isdir(sub_dir) and not sub.startswith((".", "_"))
and sub not in _NON_PKG_DIRS
and os.path.isfile(os.path.join(sub_dir, "__init__.py"))):
pkgs.setdefault(sub, repo_dir)
return pkgs
def build_repo_map(space_dir):
"""modules/<repo> 与 apps/<repo> 的仓库根映射:[(仓库绝对路径, 仓库名)]。
仓库 ≠ 包:modules/pbls/ 只有 app/ scripts/ 没有包目录,但入口文件
modules/pbls/app/pbls.py 的 import 断裂同样是真实事故面(pbls 入口
from world.api import 实测断裂)——门禁范围必须按仓库归属算,不能只按包。
"""
repos = []
for group in ("modules", "apps"):
base = os.path.join(space_dir, group)
if not os.path.isdir(base):
continue
for repo in sorted(os.listdir(base)):
repo_dir = os.path.join(base, repo)
if os.path.isdir(repo_dir) and not repo.startswith("."):
repos.append((os.path.abspath(repo_dir), repo))
return repos
def _resolve_module_file(mod, pkgs):
"""绝对模块名 → 文件路径;'EXT'=外部包;None=包内但文件不存在。"""
top = mod.split(".")[0]
if top not in pkgs:
return "EXT"
root = pkgs[top]
parts = mod.split(".")
path = os.path.join(root, *parts) + ".py"
if os.path.isfile(path):
return path
init = os.path.join(root, *parts, "__init__.py")
if os.path.isfile(init):
return init
if os.path.isdir(os.path.join(root, *parts)):
return os.path.join(root, *parts) # 命名空间目录:保守按动态放行
return None
class _ClosureChecker:
def __init__(self, space_dir):
self.space_dir = space_dir
self.pkgs = build_package_map(space_dir)
self._mod_cache = {}
def _pkg_of_file(self, f):
best = None
ap = os.path.abspath(f)
for name, root in self.pkgs.items():
pr = os.path.abspath(os.path.join(root, name))
if ap.startswith(pr + os.sep):
if best is None or len(pr) > len(best[1]):
best = (name, pr)
return best
def _module_defs(self, mod):
if mod in self._mod_cache:
return self._mod_cache[mod]
path = _resolve_module_file(mod, self.pkgs)
if path in (None, "EXT"):
self._mod_cache[mod] = None
return None
if os.path.isdir(path):
self._mod_cache[mod] = (set(), True)
return self._mod_cache[mod]
try:
with open(path, encoding="utf-8", errors="ignore") as fh:
tree = ast.parse(fh.read())
except SyntaxError:
self._mod_cache[mod] = (set(), True) # 语法坏→动态放行(空壳门禁管)
return self._mod_cache[mod]
except Exception:
self._mod_cache[mod] = None
return None
names, dynamic, stars = _collect_defs(tree)
for s_mod, s_level in stars:
if s_level > 0 or not s_mod or s_mod.split(".")[0] not in self.pkgs:
continue
sub = self._module_defs(s_mod)
if sub:
names |= sub[0]
dynamic = dynamic or sub[1]
self._mod_cache[mod] = (names, dynamic)
return self._mod_cache[mod]
def _abs_mod(self, f, node):
if node.level == 0:
return node.module or ""
po = self._pkg_of_file(f)
if not po:
return None
pkg_name, pkg_root = po
rel_dir = os.path.dirname(os.path.abspath(f))
base_parts = [pkg_name]
pkg_abs = os.path.join(os.path.abspath(pkg_root), pkg_name)
if os.path.abspath(rel_dir) != os.path.abspath(pkg_abs):
sub = os.path.relpath(rel_dir, pkg_abs)
if sub and sub != ".":
base_parts += sub.split(os.sep)
up = node.level - 1
if up > 0:
if up >= len(base_parts):
return None
base_parts = base_parts[: len(base_parts) - up]
if node.module:
base_parts += node.module.split(".")
return ".".join(base_parts)
def iter_imports(self, f):
"""产出 (node, 绝对模块名);外部/无法归一的不产出。"""
try:
with open(f, encoding="utf-8", errors="ignore") as fh:
tree = ast.parse(fh.read())
except Exception:
return
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
mod = self._abs_mod(f, node)
if not mod or mod.split(".")[0] not in self.pkgs:
continue
yield node, mod
def check_file(self, f):
"""单文件核验 → problem dict 列表。"""
problems = []
rel = os.path.relpath(f, self.space_dir)
for node, mod in self.iter_imports(f):
path = _resolve_module_file(mod, self.pkgs)
if path == "EXT":
continue
if path is None:
parent = mod.rsplit(".", 1)[0] if "." in mod else mod
p_path = _resolve_module_file(parent, self.pkgs) if parent != mod else None
if p_path and p_path != "EXT":
problems.append({
"file": rel, "line": node.lineno,
"kind": "missing-module", "symbol": mod,
"source": parent})
continue
defs = self._module_defs(mod)
if defs is None:
continue
names, dynamic = defs
if dynamic:
continue
pkg_dir = path if os.path.isdir(path) else os.path.dirname(path)
for a in node.names:
if a.name == "*" or a.name in names:
continue
if (os.path.isfile(os.path.join(pkg_dir, a.name + ".py"))
or os.path.isfile(os.path.join(pkg_dir, a.name,
"__init__.py"))):
continue # from pkg import submod 形态
problems.append({
"file": rel, "line": node.lineno,
"kind": "missing-symbol", "symbol": a.name,
"source": mod})
return problems
def all_py_files(self):
for group in ("modules", "apps"):
base = os.path.join(self.space_dir, group)
if not os.path.isdir(base):
continue
for repo in sorted(os.listdir(base)):
repo_dir = os.path.join(base, repo)
if not os.path.isdir(repo_dir):
continue
for root, dirs, files in os.walk(repo_dir):
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
for fn in sorted(files):
if fn.endswith(".py"):
yield os.path.join(root, fn)
def _repo_of(path, repos):
"""文件归属仓库名(最长前缀匹配)。"""
ap = os.path.abspath(path)
best = None
for repo_dir, name in repos:
if ap.startswith(repo_dir + os.sep):
if best is None or len(repo_dir) > len(best[0]):
best = (repo_dir, name)
return best[1] if best else None
def check_closure_for_delivery(space_dir, written_files, max_problems=30):
"""deliver 门禁用:核验范围=本任务写入 .py 所属【仓库】+ 全空间引用这些仓库的文件。
仓库归属而非包归属(2026-09-16 修正):modules/pbls 只有 app/ scripts/
没有包目录,但入口 modules/pbls/app/pbls.py 的 `from world.api import`
断裂同样是真实事故面——只按包算会漏掉。跨仓库方向:写入文件所属仓库
拥有的包(包根在仓库目录下)被别处 import 断裂也拦(pbl_common 事故形状)。
返回 (problems, involved_names);核验器自身异常返回 (None, set())(逃逸阀,
调用方放行并记日志——门禁故障不阻断交付,但必须在日志可见)。
"""
try:
ck = _ClosureChecker(space_dir)
repos = build_repo_map(space_dir)
if not repos:
return [], set()
# 本任务写入 .py 所属仓库
involved = set()
for f in dict.fromkeys(written_files or []):
if not f.endswith(".py"):
continue
rn = _repo_of(f, repos)
if rn:
involved.add(rn)
if not involved:
return [], set()
# 涉及仓库拥有的包(跨仓库引用方向)
repo_dirs = {name: rd for rd, name in repos if name in involved}
owned_pkgs = set()
for pkg_name, pkg_root in ck.pkgs.items():
pkg_abs = os.path.abspath(os.path.join(pkg_root, pkg_name))
for rn, rd in repo_dirs.items():
if pkg_abs.startswith(rd + os.sep):
owned_pkgs.add(pkg_name)
break
# 核验对象:涉及仓库内全部文件 + 全空间 import 涉及包的文件
targets = set()
for py in ck.all_py_files():
rn = _repo_of(py, repos)
if rn in involved:
targets.add(py)
continue
for _node, mod in ck.iter_imports(py):
if mod.split(".")[0] in owned_pkgs:
targets.add(py)
break
problems = []
for f in sorted(targets):
problems.extend(ck.check_file(f))
# 责任范围过滤(2026-09-17 pbls M1b 死循环修正):只保留「断裂源在
# 涉及仓库自有包」的问题——即本任务改动面自己的契约断裂(含依赖方
# import 涉及包断裂 = pbl_common 事故形状)。断裂源在其他包(如
# pbl_common 半迁移存量欠账)的不是本任务责任:小任务修不完全空间
# 欠账,硬拦只会无限退回(实测 26 处里 6 处是别的包的存量账)。
problems = [p for p in problems
if p.get("source", "").split(".")[0] in owned_pkgs]
# 去重
seen, uniq = set(), []
for p in problems:
k = (p["file"], p["line"], p["symbol"], p["source"])
if k not in seen:
seen.add(k)
uniq.append(p)
return uniq[:max_problems], involved | owned_pkgs
except Exception as e:
logger.warning("import closure check failed (escape, deliver 放行): %r", e)
return None, set()
def closure_report(space_dir, written_files, max_lines=20):
"""交付件机械核验段用:断裂清单文本(空串=无断裂/不涉及包)。"""
problems, involved = check_closure_for_delivery(space_dir, written_files)
if problems is None:
return "⚠️ import 闭包核验器执行失败(已放行,见服务端日志)"
if not involved:
return ""
if not problems:
return ("import 闭包核验:涉及包 %s,未发现跨文件符号引用断裂。"
% "/".join(sorted(involved)))
lines = ["⚠️ import 闭包断裂 %d 处(涉及包 %s)——以下引用在目标模块无定义,"
"import 即 ImportError:" % (len(problems), "/".join(sorted(involved)))]
for p in problems[:max_lines]:
lines.append("- %s:%s %s `%s`(目标 %s 无此%s)" % (
p["file"], p["line"],
"引用模块" if p["kind"] == "missing-module" else "引用符号",
p["symbol"], p["source"],
"模块" if p["kind"] == "missing-module" else "符号"))
return "\n".join(lines)