From 2e8d2abec7a1cc6bc2f1317a6ebd10060b34b181 Mon Sep 17 00:00:00 2001 From: ymq Date: Thu, 17 Sep 2026 11:42:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(import-closure):=20=E6=A0=B8=E9=AA=8C?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E4=BB=8E=E5=8C=85=E5=BD=92=E5=B1=9E=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=BB=93=E5=BA=93=E5=BD=92=E5=B1=9E=E2=80=94=E2=80=94?= =?UTF-8?q?modules/pbls=E5=8F=AA=E6=9C=89app/scripts=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E5=8C=85=E7=9B=AE=E5=BD=95,=E5=85=A5=E5=8F=A3modules/pbls/app/?= =?UTF-8?q?pbls.py=E7=9A=84import=E6=96=AD=E8=A3=82(from=20world.api?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E6=96=AD)=E6=98=AF=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E4=BA=8B=E6=95=85=E9=9D=A2,=E5=8F=AA=E6=8C=89=E5=8C=85?= =?UTF-8?q?=E7=AE=97=E4=BC=9A=E6=BC=8F;=E8=B7=A8=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E6=96=B9=E5=90=91=E6=8C=89=E5=86=99=E5=85=A5=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=89=80=E5=B1=9E=E4=BB=93=E5=BA=93=E6=8B=A5=E6=9C=89=E7=9A=84?= =?UTF-8?q?=E5=8C=85=E7=AE=97(pbl=5Fcommon=E4=BA=8B=E6=95=85=E5=BD=A2?= =?UTF-8?q?=E7=8A=B6=E4=B8=8D=E5=8F=98)=E3=80=82=E5=8D=95=E6=B5=8B+3?= =?UTF-8?q?=E4=BE=8B=3D14=E5=85=A8=E7=BB=BF(=E5=90=AB=E6=97=A0=E5=8C=85?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E5=85=A5=E5=8F=A3=E7=BA=B3=E5=85=A5/?= =?UTF-8?q?=E6=96=AD=E8=A3=82=E6=8A=93=E5=88=B0/=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E4=B8=8D=E8=AF=AF=E6=8A=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_service/import_closure.py | 71 ++++++++++++++++++++++++------ test_import_closure.py | 12 +++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/pipeline_service/import_closure.py b/pipeline_service/import_closure.py index 607a19d..ff81cd6 100644 --- a/pipeline_service/import_closure.py +++ b/pipeline_service/import_closure.py @@ -108,6 +108,25 @@ def build_package_map(space_dir): return pkgs +def build_repo_map(space_dir): + """modules/ 与 apps/ 的仓库根映射:[(仓库绝对路径, 仓库名)]。 + + 仓库 ≠ 包: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] @@ -263,35 +282,61 @@ class _ClosureChecker: yield os.path.join(root, fn) -def check_closure_for_delivery(space_dir, written_files, max_problems=30): - """deliver 门禁用:核验范围=本任务写入 .py 所属包 + 全空间引用这些包的文件。 +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 - 返回 (problems, involved_pkgs);核验器自身异常返回 (None, set())(逃逸阀, + +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) - if not ck.pkgs: + repos = build_repo_map(space_dir) + if not repos: return [], set() - # 本任务写入 .py 所属包 + # 本任务写入 .py 所属仓库 involved = set() for f in dict.fromkeys(written_files or []): if not f.endswith(".py"): continue - po = ck._pkg_of_file(f) - if po: - involved.add(po[0]) + rn = _repo_of(f, repos) + if rn: + involved.add(rn) if not involved: return [], set() - # 核验对象:涉及包内全部文件 + 全空间 import 涉及包的文件 + # 涉及仓库拥有的包(跨仓库引用方向) + 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(): - po = ck._pkg_of_file(py) - if po and po[0] in involved: + 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 involved: + if mod.split(".")[0] in owned_pkgs: targets.add(py) break problems = [] @@ -304,7 +349,7 @@ def check_closure_for_delivery(space_dir, written_files, max_problems=30): if k not in seen: seen.add(k) uniq.append(p) - return uniq[:max_problems], involved + return uniq[:max_problems], involved | owned_pkgs except Exception as e: logger.warning("import closure check failed (escape, deliver 放行): %r", e) return None, set() diff --git a/test_import_closure.py b/test_import_closure.py index 38c51ab..a14f2ca 100644 --- a/test_import_closure.py +++ b/test_import_closure.py @@ -109,5 +109,17 @@ w("modules/pbl_rel/pbl_rel/bad.py", "from . import NOT_THERE\n") probs8, _ = check_closure_for_delivery(ROOT, [os.path.join(ROOT, "modules/pbl_rel/pbl_rel/bad.py")]) check("相对导入断裂也抓", any(p["symbol"] == "NOT_THERE" for p in (probs8 or [])), str(probs8)) +# ── 用例 8:仓库归属——无包目录的仓库(modules/pbls 形态)入口断裂也拦 ── +w("modules/pbls/app/pbls.py", "from world.api import load_world\nfrom pbl_common.kernel import PblError\n") +w("modules/world/world/__init__.py", "def load_world(env=None):\n return True\n") +w("modules/world/__init__.py", "from .world import load_world\n") +probs9, involved9 = check_closure_for_delivery( + ROOT, [os.path.join(ROOT, "modules/pbls/app/pbls.py")]) +check("无包仓库入口纳入核验", "pbls" in involved9, str(involved9)) +check("抓到 world.api 断裂", any(p["symbol"] == "world.api" for p in (probs9 or [])), + str(probs9)) +check("pbl_common.kernel 存在不误报", not any("pbl_common.kernel" in str(p) for p in (probs9 or [])), + str(probs9)) + print("\nFAILS:", FAILS or "NONE") sys.exit(1 if FAILS else 0)