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)