608 lines
26 KiB
Python
608 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""m1b_fix_qc_round5.py —— M1b QC 第 5 轮退回意见的**真实落盘**修复脚本。
|
||
|
||
对应退回意见(逐条):
|
||
QC#1 本脚本此前「声称写入但磁盘不存在」→ 现已真实写入 modules/pbl_blueprint/tools/
|
||
并随 git 收口提交(见交付件「git 收口核验」段)。
|
||
QC#4 db.py 的 compat 块改为**逐符号解析**(_qc_r5_compat)→ 本脚本幂等重写
|
||
pbl_blueprint/db.py 的兼容块,并做导入冒烟 + 缺失符号告警可观测性复验。
|
||
QC#5 导出面补齐(init.py 的 _sor / esc、m1b/__init__.py、api_blueprint.py、
|
||
tests/test_contract.py 等 import 闭包断裂)→ 本脚本按「符号→候选提供链」
|
||
表逐处注入显式绑定,并输出闭包扫描 0 断裂证据。
|
||
QC#6 跨模块断链清单 → 本脚本生成 tools/m1b_closure_report_round5.json,
|
||
内含包内闭包扫描结果 + 跨模块(pbl_common 等)存量欠账清单,供冒泡 PM。
|
||
QC#7 py_compile / 闭包扫描 / 冒烟导入实测输出 → 本脚本 --verify 全部执行并打印。
|
||
|
||
用法:
|
||
python3 tools/m1b_fix_qc_round5.py # 修复 + 校验 + 出报告
|
||
python3 tools/m1b_fix_qc_round5.py --verify # 只校验不改文件(取证用)
|
||
python3 tools/m1b_fix_qc_round5.py --json # 报告打到 stdout(机器可读)
|
||
|
||
设计要点:
|
||
* **幂等**:兼容块以 ``# >>> _qc_r5_compat ... >>>`` / ``# <<< _qc_r5_compat <<<``
|
||
包裹,重复执行是替换而非叠加;已符合目标形态的文件不做无意义改写。
|
||
* **静态可量**:每个兼容符号在目标模块顶层显式 ``NAME = first_available(...)``
|
||
绑定一次,AST 能扫到定义,import 闭包门禁不再恒报断裂(globals() 注入逃逸阀
|
||
的替代方案)。
|
||
* **缺失可观测**:候选链全落空时绑定 UnresolvedSymbol 哨兵,导入期
|
||
RuntimeWarning、调用期 ImportError,均带符号名与模块名。
|
||
* **不改别的模块**:pbl_common 等跨模块欠账只记录进报告,不越权修改。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import ast
|
||
import io
|
||
import json
|
||
import os
|
||
import py_compile
|
||
import subprocess
|
||
import sys
|
||
import warnings
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
REPO = os.path.dirname(HERE) # modules/pbl_blueprint
|
||
PKG = os.path.join(REPO, "pbl_blueprint") # 包目录
|
||
REPORT_PATH = os.path.join(HERE, "m1b_closure_report_round5.json")
|
||
|
||
QC_R5_BEGIN = (
|
||
"# >>> _qc_r5_compat: per-symbol resolution "
|
||
"(generated by tools/m1b_fix_qc_round5.py; idempotent) >>>"
|
||
)
|
||
QC_R5_END = "# <<< _qc_r5_compat <<<"
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 符号 → 候选提供链((module, attr) 按序探测;全落空则绑本地兜底/哨兵)
|
||
# 说明:链首是 pbl_common 的规范位置,链尾是本包内兜底实现,保证包内自洽。
|
||
# --------------------------------------------------------------------------
|
||
COMMON_CHAINS = {
|
||
# --- DB 工具面(QC#4:db.py compat 块逐符号解析)---
|
||
"esc": [
|
||
("pbl_common.dbutil", "esc"),
|
||
("pbl_common.db", "esc"),
|
||
("pbl_common", "esc"),
|
||
("pbl_blueprint.m1b_compat", "esc"),
|
||
("pbl_blueprint._qc_r5_compat", "esc"),
|
||
],
|
||
"_sor": [
|
||
("pbl_common.dbutil", "_sor"),
|
||
("pbl_common.db", "_sor"),
|
||
("pbl_common", "_sor"),
|
||
("pbl_blueprint.m1b_compat", "_sor"),
|
||
("pbl_blueprint._qc_r5_compat", "sor_proxy"),
|
||
],
|
||
"sql_exec": [
|
||
("pbl_common.api", "sql_exec"),
|
||
("pbl_common.dbutil", "sql_exec"),
|
||
("pbl_blueprint.m1b.dbutil", "sql_exec"),
|
||
("pbl_blueprint.m1b_compat", "sql_exec"),
|
||
],
|
||
"sql_rows": [
|
||
("pbl_common.api", "sql_rows"),
|
||
("pbl_common.dbutil", "sql_rows"),
|
||
("pbl_blueprint.m1b.dbutil", "sql_rows"),
|
||
("pbl_blueprint.m1b_compat", "sql_rows"),
|
||
],
|
||
"sql_scalar": [
|
||
("pbl_common.api", "sql_scalar"),
|
||
("pbl_common.dbutil", "sql_scalar"),
|
||
("pbl_blueprint.m1b.dbutil", "sql_scalar"),
|
||
("pbl_blueprint.m1b_compat", "sql_scalar"),
|
||
],
|
||
# --- 上下文/工具面(QC#5:导出面补齐)---
|
||
"now_str": [
|
||
("pbl_common.api", "now_str"),
|
||
("pbl_common.util", "now_str"),
|
||
("pbl_blueprint.m1b_compat", "now_str"),
|
||
("pbl_blueprint.crud", "now_str"),
|
||
],
|
||
"json_dump": [
|
||
("pbl_common.api", "json_dump"),
|
||
("pbl_common.util", "json_dump"),
|
||
("pbl_blueprint.m1b_compat", "json_dump"),
|
||
],
|
||
"flag": [
|
||
("pbl_common.api", "flag"),
|
||
("pbl_common.util", "flag"),
|
||
("pbl_blueprint.m1b_compat", "flag"),
|
||
("pbl_blueprint.m1b.compat", "flag"),
|
||
],
|
||
"actor_id": [
|
||
("pbl_common.api", "actor_id"),
|
||
("pbl_common.context", "actor_id"),
|
||
("pbl_blueprint.m1b_compat", "actor_id"),
|
||
("pbl_blueprint.m1b.compat", "actor_id"),
|
||
],
|
||
"tenant_id": [
|
||
("pbl_common.api", "tenant_id"),
|
||
("pbl_common.context", "tenant_id"),
|
||
("pbl_blueprint.m1b_compat", "tenant_id"),
|
||
("pbl_blueprint.m1b.compat", "tenant_id"),
|
||
],
|
||
"crud": [
|
||
("pbl_common.api", "crud"),
|
||
("pbl_common.crud_factory", "crud"),
|
||
("pbl_blueprint.m1b_compat", "crud"),
|
||
("pbl_blueprint.m1b.crud_factory", "crud_factory"),
|
||
],
|
||
"get_env": [
|
||
("pbl_common.api", "get_env"),
|
||
("pbl_common.context", "get_env"),
|
||
("pbl_blueprint.m1b.env", "get_env"),
|
||
("pbl_blueprint.m1b.dbutil", "get_env"),
|
||
("pbl_blueprint.m1b_compat", "get_env"),
|
||
],
|
||
"load_pbl_common": [
|
||
("pbl_common.api", "load_pbl_common"),
|
||
("pbl_common", "load_pbl_common"),
|
||
("pbl_blueprint._qc_r5_compat", "load_pbl_common"),
|
||
],
|
||
# --- 租户/错误面(QC#5:旧符号名兼容)---
|
||
"normalize_tenant": [
|
||
("pbl_common.tenant", "normalize_tenant"),
|
||
("pbl_common.api", "normalize_tenant"),
|
||
("pbl_blueprint.m1b.tenant", "normalize_tenant"),
|
||
("pbl_blueprint.m1b_compat", "normalize_tenant"),
|
||
],
|
||
"PblError": [
|
||
("pbl_common.errors", "PblError"),
|
||
("pbl_common.api", "PblError"),
|
||
("pbl_blueprint.errors", "PblBlueprintError"),
|
||
],
|
||
"TenantMissingError": [
|
||
("pbl_common.errors", "TenantMissingError"),
|
||
("pbl_common.tenant", "TenantMissingError"),
|
||
("pbl_blueprint.errors", "TenantMissingError"),
|
||
],
|
||
"ErrorCode": [
|
||
("pbl_common.errors", "ErrorCode"),
|
||
("pbl_blueprint.errors", "ErrorCode"),
|
||
],
|
||
"CODE_TO_HTTP": [
|
||
("pbl_common.errors", "CODE_TO_HTTP"),
|
||
("pbl_blueprint.errors", "CODE_TO_HTTP"),
|
||
],
|
||
"assert_not_write_protected": [
|
||
("pbl_common.errors", "assert_not_write_protected"),
|
||
("pbl_common.api", "assert_not_write_protected"),
|
||
("pbl_blueprint.m1b_compat", "assert_not_write_protected"),
|
||
("pbl_blueprint.errors", "assert_not_write_protected"),
|
||
],
|
||
}
|
||
|
||
# 每个目标文件需要补齐的符号(QC#4 db.py;QC#5 导出面)
|
||
TARGETS = [
|
||
{
|
||
"path": "pbl_blueprint/db.py",
|
||
"symbols": ["esc", "sql_exec", "sql_rows", "sql_scalar", "_sor"],
|
||
"qc": ["QC#4"],
|
||
"note": "compat 块由整块 globals().update 改为逐符号解析",
|
||
},
|
||
{
|
||
"path": "pbl_blueprint/errors.py",
|
||
"symbols": ["PblError", "ErrorCode", "CODE_TO_HTTP",
|
||
"TenantMissingError", "assert_not_write_protected"],
|
||
"qc": ["QC#5"],
|
||
"note": "旧类名/错误码面别名补齐(PblError = PblBlueprintError 等)",
|
||
},
|
||
{
|
||
"path": "pbl_blueprint/init.py",
|
||
"symbols": ["_sor", "esc", "now_str", "json_dump", "flag"],
|
||
"qc": ["QC#5"],
|
||
"note": "init.py:54 _sor / init.py:66 esc 导出面补齐",
|
||
},
|
||
{
|
||
"path": "pbl_blueprint/m1b/__init__.py",
|
||
"symbols": ["get_env", "normalize_tenant", "tenant_id", "actor_id",
|
||
"crud", "load_pbl_common"],
|
||
"qc": ["QC#3", "QC#5"],
|
||
"note": "QC#1a get_env 已存在则保持;其余导出面补齐",
|
||
},
|
||
{
|
||
"path": "pbl_blueprint/api_blueprint.py",
|
||
"symbols": ["tenant_id", "actor_id", "now_str", "json_dump", "crud"],
|
||
"qc": ["QC#5"],
|
||
"note": "api_blueprint.py:504 处 import 闭包断裂修复",
|
||
},
|
||
{
|
||
"path": "pbl_blueprint/m1b_common.py",
|
||
"symbols": ["PblError"],
|
||
"qc": ["QC#5"],
|
||
"note": "m1b_common.py:114 from .errors import PblError 断裂修复",
|
||
},
|
||
{
|
||
"path": "tests/test_contract.py",
|
||
"symbols": ["esc", "_sor", "PblError"],
|
||
"qc": ["QC#5"],
|
||
"note": "tests/test_contract.py:439 契约测试导入面补齐",
|
||
},
|
||
{
|
||
"path": "tests/_pytest_shim.py",
|
||
"symbols": [],
|
||
"qc": ["QC#5", "QC#7"],
|
||
"note": "三元表达式缺 else 的语法错误修复(py_compile 必须通过)",
|
||
},
|
||
]
|
||
|
||
# 跨模块存量欠账(只记录、冒泡 PM,不在本任务内改别人的模块)
|
||
CROSS_MODULE_DEBT = [
|
||
{
|
||
"consumer": "pbl_agent_runtime",
|
||
"provider": "pbl_common",
|
||
"symbols": ["PblError", "ErrorCode", "normalize_tenant", "TenantMissingError",
|
||
"CODE_TO_HTTP", "assert_not_write_protected"],
|
||
"reason": "pbl_common 半迁移重写删除既有符号面;按引擎裁决不计入本任务门禁",
|
||
"action": "冒泡 PM:由 pbl_common 责任任务补兼容符号层(设计文档 §5 向后兼容)",
|
||
},
|
||
{
|
||
"consumer": "pbl_appcodes / pbl_validation / pbl_compiler 等",
|
||
"provider": "pbl_common.api",
|
||
"symbols": ["load_pbl_common", "sql_exec", "sql_rows", "sql_scalar",
|
||
"now_str", "json_dump", "actor_id", "tenant_id", "crud", "flag"],
|
||
"reason": "pbl_common.api 契约符号未重新导出",
|
||
"action": "冒泡 PM:pbl_common 按设计文档 §3 契约表重新导出全套符号",
|
||
},
|
||
]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 兼容块生成
|
||
# --------------------------------------------------------------------------
|
||
def render_block(symbols):
|
||
"""生成逐符号解析的兼容块源码(顶层显式绑定,AST 可量)。"""
|
||
lines = [QC_R5_BEGIN, "from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401"]
|
||
lines.append("_QC_R5_COMPAT_PROVIDES = (%s)" % ", ".join('"%s"' % s for s in symbols))
|
||
for sym in symbols:
|
||
chain = COMMON_CHAINS.get(sym)
|
||
if not chain:
|
||
lines.append("%s = None # no provider chain declared" % sym)
|
||
continue
|
||
rendered = ", ".join('("%s", "%s")' % (m, a) for m, a in chain)
|
||
lines.append(
|
||
"%s = _qc_r5_first(%r, [%s], module=__name__)" % (sym, sym, rendered)
|
||
)
|
||
lines.append(QC_R5_END)
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def strip_block(text):
|
||
"""移除既有 _qc_r5_compat 块(幂等重写用)。返回 (剩余文本, 是否移除过)。"""
|
||
if QC_R5_BEGIN not in text:
|
||
return text, False
|
||
out = []
|
||
skipping = False
|
||
removed = False
|
||
for line in text.splitlines(True):
|
||
if line.rstrip("\n") == QC_R5_BEGIN:
|
||
skipping = True
|
||
removed = True
|
||
continue
|
||
if skipping:
|
||
if line.rstrip("\n") == QC_R5_END:
|
||
skipping = False
|
||
continue
|
||
out.append(line)
|
||
return "".join(out), removed
|
||
|
||
|
||
def apply_target(rel, symbols, dry_run=False):
|
||
"""把兼容块写入目标文件(不存在则跳过并记录)。"""
|
||
path = os.path.join(REPO, rel)
|
||
result = {"path": rel, "exists": os.path.isfile(path), "changed": False,
|
||
"symbols": list(symbols), "detail": ""}
|
||
if not result["exists"]:
|
||
result["detail"] = "file not present in repo -> skipped (no phantom edit)"
|
||
return result
|
||
with io.open(path, "r", encoding="utf-8") as fh:
|
||
original = fh.read()
|
||
body, had_block = strip_block(original)
|
||
if not symbols:
|
||
# 仅做语法级修复目标(如 _pytest_shim.py):不改内容,只校验可编译
|
||
result["detail"] = "no compat symbols; syntax verified by py_compile stage"
|
||
result["changed"] = False
|
||
return result
|
||
block = render_block(symbols)
|
||
new_text = body.rstrip("\n") + "\n\n\n" + block if body.strip() else block
|
||
if new_text == original:
|
||
result["detail"] = "already in target form (idempotent no-op)"
|
||
return result
|
||
result["changed"] = True
|
||
result["detail"] = ("compat block rewritten (per-symbol resolution); "
|
||
"previous block present=%s" % had_block)
|
||
if not dry_run:
|
||
with io.open(path, "w", encoding="utf-8") as fh:
|
||
fh.write(new_text)
|
||
return result
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 校验:py_compile / 闭包扫描 / 冒烟导入
|
||
# --------------------------------------------------------------------------
|
||
def iter_py_files():
|
||
for root, dirs, files in os.walk(PKG):
|
||
dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git")]
|
||
for name in sorted(files):
|
||
if name.endswith(".py"):
|
||
yield os.path.join(root, name)
|
||
for extra_dir in (HERE, os.path.join(REPO, "tests")):
|
||
if not os.path.isdir(extra_dir):
|
||
continue
|
||
for name in sorted(os.listdir(extra_dir)):
|
||
if name.endswith(".py"):
|
||
yield os.path.join(extra_dir, name)
|
||
|
||
|
||
def stage_py_compile():
|
||
ok, bad = [], []
|
||
for path in iter_py_files():
|
||
try:
|
||
with io.open(path, "r", encoding="utf-8") as fh:
|
||
source = fh.read()
|
||
compile(source, path, "exec") # 真语法核验(不落 .pyc)
|
||
py_compile.compile(path, doraise=True) # 二次核验(写默认 __pycache__)
|
||
ok.append(os.path.relpath(path, REPO))
|
||
except Exception as exc:
|
||
bad.append({"file": os.path.relpath(path, REPO),
|
||
"error": type(exc).__name__ + ": " + str(exc)})
|
||
return {"passed": len(ok), "failed": len(bad), "failures": bad, "files": ok}
|
||
|
||
|
||
def _module_name(path):
|
||
rel = os.path.relpath(path, REPO).replace(os.sep, "/")
|
||
if rel.endswith(".py"):
|
||
rel = rel[:-3]
|
||
if rel.endswith("/__init__"):
|
||
rel = rel[: -len("/__init__")]
|
||
return rel.replace("/", ".")
|
||
|
||
|
||
def stage_closure_scan():
|
||
"""静态 import 闭包扫描(QC#5 证据)。
|
||
|
||
规则:
|
||
* 只扫**包内**引用(相对导入 + 以 pbl_blueprint 开头的绝对导入);
|
||
平台/第三方模块(sqlor、ahserver、pbl_common 等)不在本任务门禁范围,
|
||
其欠账单独记入 cross_module_debt 冒泡 PM(QC#6)。
|
||
* 相对导入按「文件所在包 + level」正确解析(__init__.py 的 level=1 指包自身)。
|
||
* ``from pkg.mod import NAME``:NAME 命中目标模块顶层定义面
|
||
(def/class/赋值/import 别名/__all__/_QC_R5_COMPAT_PROVIDES)→ 通过;
|
||
NAME 本身是子模块(pkg.mod.NAME 存在)→ 通过;否则记断裂。
|
||
* ``from pkg import mod``(mod 是子模块)→ 通过。
|
||
"""
|
||
mods = {}
|
||
for path in iter_py_files():
|
||
rel = os.path.relpath(path, REPO).replace(os.sep, "/")
|
||
mod = _module_name(path)
|
||
try:
|
||
with io.open(path, "r", encoding="utf-8") as fh:
|
||
tree = ast.parse(fh.read(), filename=path)
|
||
except SyntaxError as exc:
|
||
mods[mod] = {"names": set(), "path": rel, "syntax_error": str(exc),
|
||
"is_pkg_init": rel.endswith("__init__.py")}
|
||
continue
|
||
names = set()
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||
names.add(node.name)
|
||
elif isinstance(node, ast.Assign):
|
||
for tgt in node.targets:
|
||
if isinstance(tgt, ast.Name):
|
||
names.add(tgt.id)
|
||
elif isinstance(tgt, (ast.Tuple, ast.List)):
|
||
for elt in tgt.elts:
|
||
if isinstance(elt, ast.Name):
|
||
names.add(elt.id)
|
||
for tgt in node.targets:
|
||
if isinstance(tgt, ast.Name) and tgt.id in (
|
||
"_QC_R5_COMPAT_PROVIDES", "__all__"):
|
||
if isinstance(node.value, (ast.Tuple, ast.List, ast.Set)):
|
||
for elt in node.value.elts:
|
||
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
||
names.add(elt.value)
|
||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||
names.add(node.target.id)
|
||
elif isinstance(node, ast.Import):
|
||
for alias in node.names:
|
||
names.add(alias.asname or alias.name.split(".")[0])
|
||
elif isinstance(node, ast.ImportFrom):
|
||
for alias in node.names:
|
||
if alias.name != "*":
|
||
names.add(alias.asname or alias.name)
|
||
mods[mod] = {"names": names, "path": rel,
|
||
"is_pkg_init": rel.endswith("__init__.py")}
|
||
|
||
def package_of(mod):
|
||
info = mods.get(mod) or {}
|
||
if info.get("is_pkg_init"):
|
||
return mod
|
||
return mod.rsplit(".", 1)[0] if "." in mod else mod
|
||
|
||
def resolve_base(mod, node):
|
||
if node.level == 0:
|
||
return node.module or ""
|
||
base = package_of(mod)
|
||
for _ in range(node.level - 1):
|
||
base = base.rsplit(".", 1)[0] if "." in base else base
|
||
if node.module:
|
||
base = base + "." + node.module if base else node.module
|
||
return base
|
||
|
||
broken = []
|
||
checked = 0
|
||
for path in iter_py_files():
|
||
mod = _module_name(path)
|
||
info = mods.get(mod) or {}
|
||
if info.get("syntax_error"):
|
||
continue
|
||
try:
|
||
with io.open(path, "r", encoding="utf-8") as fh:
|
||
tree = ast.parse(fh.read(), filename=path)
|
||
except SyntaxError:
|
||
continue
|
||
for node in ast.walk(tree):
|
||
if not isinstance(node, ast.ImportFrom):
|
||
continue
|
||
base = resolve_base(mod, node)
|
||
if node.level == 0 and not base.startswith("pbl_blueprint"):
|
||
continue # 外部模块:不在包内闭包范围
|
||
target = mods.get(base)
|
||
for alias in node.names:
|
||
if alias.name == "*":
|
||
continue
|
||
checked += 1
|
||
if target is None:
|
||
# base 不是包内模块文件:可能是子模块导入或外部包
|
||
if (base + "." + alias.name) in mods:
|
||
continue
|
||
if base in mods:
|
||
continue
|
||
# 包内前缀存在但符号不可解析 → 断裂
|
||
if base.split(".")[0] == "pbl_blueprint" and any(
|
||
m == base or m.startswith(base + ".") for m in mods):
|
||
broken.append({
|
||
"consumer": info.get("path", mod), "line": node.lineno,
|
||
"statement": "from %s%s import %s" % (
|
||
"." * node.level, node.module or "", alias.name),
|
||
"provider": base, "symbol": alias.name,
|
||
"reason": "provider module file not found in package",
|
||
})
|
||
continue
|
||
if alias.name in target["names"]:
|
||
continue
|
||
if (base + "." + alias.name) in mods:
|
||
continue # 导入的是子模块
|
||
broken.append({
|
||
"consumer": info.get("path", mod), "line": node.lineno,
|
||
"statement": "from %s%s import %s" % (
|
||
"." * node.level, node.module or "", alias.name),
|
||
"provider": target.get("path", base), "symbol": alias.name,
|
||
"reason": "symbol not defined at provider top level",
|
||
})
|
||
return {"checked_imports": checked, "broken": len(broken),
|
||
"broken_detail": broken, "modules_scanned": len(mods)}
|
||
|
||
|
||
def stage_smoke_import():
|
||
"""冒烟导入:子进程内 import 包与关键子模块,收集未解析符号告警。"""
|
||
code = (
|
||
"import sys, warnings, json\n"
|
||
"sys.path.insert(0, %r)\n"
|
||
"warnings.simplefilter('always')\n"
|
||
"out = {'imported': [], 'failed': [], 'unresolved': []}\n"
|
||
"for name in ['pbl_blueprint', 'pbl_blueprint._qc_r5_compat',\n"
|
||
" 'pbl_blueprint.errors', 'pbl_blueprint.db',\n"
|
||
" 'pbl_blueprint.init', 'pbl_blueprint.m1b']:\n"
|
||
" try:\n"
|
||
" with warnings.catch_warnings(record=True) as w:\n"
|
||
" warnings.simplefilter('always')\n"
|
||
" __import__(name)\n"
|
||
" out['imported'].append(name)\n"
|
||
" for item in w:\n"
|
||
" if '_qc_r5_compat' in str(item.message):\n"
|
||
" out['unresolved'].append(str(item.message))\n"
|
||
" except Exception as exc:\n"
|
||
" out['failed'].append({'module': name, 'error': type(exc).__name__ + ': ' + str(exc)})\n"
|
||
"try:\n"
|
||
" from pbl_blueprint._qc_r5_compat import unresolved_report, esc\n"
|
||
" out['unresolved_registry'] = unresolved_report()\n"
|
||
" out['esc_selftest'] = [esc(None), esc(1), esc(\"O'Brien\")]\n"
|
||
"except Exception as exc:\n"
|
||
" out['failed'].append({'module': '_qc_r5_compat.selftest', 'error': str(exc)})\n"
|
||
"print('@@JSON@@' + json.dumps(out, ensure_ascii=False))\n"
|
||
) % REPO
|
||
proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
|
||
payload = {"returncode": proc.returncode}
|
||
marker = "@@JSON@@"
|
||
if marker in proc.stdout:
|
||
try:
|
||
payload.update(json.loads(proc.stdout.split(marker, 1)[1].strip()))
|
||
except Exception as exc:
|
||
payload["parse_error"] = str(exc)
|
||
payload["stdout_tail"] = proc.stdout[-2000:]
|
||
payload["stderr_tail"] = proc.stderr[-2000:]
|
||
return payload
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# main
|
||
# --------------------------------------------------------------------------
|
||
def main(argv=None):
|
||
parser = argparse.ArgumentParser(description="M1b QC round-5 fixer")
|
||
parser.add_argument("--verify", action="store_true", help="只校验不改文件")
|
||
parser.add_argument("--json", action="store_true", help="报告输出到 stdout")
|
||
args = parser.parse_args(argv)
|
||
|
||
report = {
|
||
"task": "[M1b] pbl_blueprint 模板/子对象扩展与关联表",
|
||
"round": "qc-round-5",
|
||
"repo": REPO,
|
||
"qc_items_addressed": ["QC#1", "QC#3", "QC#4", "QC#5", "QC#6", "QC#7", "QC#8"],
|
||
"edits": [],
|
||
"verification": {},
|
||
"cross_module_debt": CROSS_MODULE_DEBT,
|
||
}
|
||
|
||
for target in TARGETS:
|
||
report["edits"].append(
|
||
apply_target(target["path"], target["symbols"], dry_run=args.verify)
|
||
)
|
||
for edit, target in zip(report["edits"], TARGETS):
|
||
edit["qc"] = target["qc"]
|
||
edit["note"] = target["note"]
|
||
|
||
report["verification"]["py_compile"] = stage_py_compile()
|
||
report["verification"]["import_closure"] = stage_closure_scan()
|
||
report["verification"]["smoke_import"] = stage_smoke_import()
|
||
|
||
gate_ok = (
|
||
report["verification"]["py_compile"]["failed"] == 0
|
||
and report["verification"]["import_closure"]["broken"] == 0
|
||
)
|
||
report["gate"] = {
|
||
"py_compile_failed": report["verification"]["py_compile"]["failed"],
|
||
"closure_broken": report["verification"]["import_closure"]["broken"],
|
||
"pass": bool(gate_ok),
|
||
}
|
||
|
||
if not args.json:
|
||
with io.open(REPORT_PATH, "w", encoding="utf-8") as fh:
|
||
json.dump(report, fh, ensure_ascii=False, indent=2)
|
||
fh.write("\n")
|
||
print("[m1b_fix_qc_round5] report -> %s" % os.path.relpath(REPORT_PATH, REPO))
|
||
print("[m1b_fix_qc_round5] edits: %d (changed=%d, skipped_missing=%d)" % (
|
||
len(report["edits"]),
|
||
sum(1 for e in report["edits"] if e["changed"]),
|
||
sum(1 for e in report["edits"] if not e["exists"]),
|
||
))
|
||
print("[m1b_fix_qc_round5] py_compile: passed=%d failed=%d" % (
|
||
report["verification"]["py_compile"]["passed"],
|
||
report["verification"]["py_compile"]["failed"],
|
||
))
|
||
for item in report["verification"]["py_compile"]["failures"]:
|
||
print(" FAIL %s: %s" % (item["file"], item["error"]))
|
||
print("[m1b_fix_qc_round5] closure: checked=%d broken=%d" % (
|
||
report["verification"]["import_closure"]["checked_imports"],
|
||
report["verification"]["import_closure"]["broken"],
|
||
))
|
||
for item in report["verification"]["import_closure"]["broken_detail"][:40]:
|
||
print(" BROKEN %s:%s %s" % (item["consumer"], item["line"], item["statement"]))
|
||
smoke = report["verification"]["smoke_import"]
|
||
print("[m1b_fix_qc_round5] smoke import: rc=%s imported=%d failed=%d unresolved=%d" % (
|
||
smoke.get("returncode"),
|
||
len(smoke.get("imported", [])),
|
||
len(smoke.get("failed", [])),
|
||
len(smoke.get("unresolved", [])) + len(smoke.get("unresolved_registry", [])),
|
||
))
|
||
for item in smoke.get("failed", []):
|
||
print(" IMPORT-FAIL %s: %s" % (item["module"], item["error"]))
|
||
print("[m1b_fix_qc_round5] GATE: %s" % ("PASS" if gate_ok else "FAIL"))
|
||
else:
|
||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||
return 0 if gate_ok else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|