Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions layer-baseline.json

This file was deleted.

183 changes: 67 additions & 116 deletions scripts/check_layering.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
#!/usr/bin/env python3
"""分层门禁:阻止领域逻辑继续堆积在 codeguard-cli 这个二进制 crate。
"""分层门禁:只标记「真正错位」在 codeguard-cli 中的领域模块。

背景:codeguard 把执行内核切成四个 crate,设计意图是
core(契约与领域类型)← runtime(执行)← adapters(工具适配)← cli(组装)
但实际上一度出现倒挂——adapters 已有的工具诊断类型,其身份映射逻辑却住在 cli,
让 cli 膨胀到远大于 core,且无法单独测试领域规则。
权威边界不是本文档,而是 `crates/codeguard-cli/tests/crate_boundaries.rs`——
它以测试形式强制以下依赖方向:

本门禁不试图一次性搬完(那会让 diff 无法审阅),而是把「倒挂」变成显式债务:
cli 中仍然存在的领域形态模块被登记在 layer-baseline.json 里,数量只减不增。
新增领域模块而不同步下移,会让本检查失败并指出应该去哪个 crate。
codeguard-core 仅依赖 serde
codeguard-runtime 依赖 core / libc / ring(进程执行)
codeguard-adapters 依赖 core / serde / serde_json / roxmltree / toml / sha2(纯解析与配置)
codeguard-cli 依赖以上三者(编排)

由此得出两条常被搞反的事实:

1. 探针(*_probe)留在 cli 是正确的。它们调用 run_process_recorded,需要
codeguard-runtime;而 adapters 被明确禁止依赖 runtime。把探针「下移」到
adapters 会被 crate_boundaries 直接判失败。
2. 能否下移取决于依赖而非名字。一个模块若既不需要 runtime、又不依赖 cli 本地
类型,它才是纯粹被放错位置的领域逻辑。

本门禁只检查第 2 类:它不做搬迁,只保证错位不会悄悄增加。

用法:
python3 scripts/check_layering.py # 检查
python3 scripts/check_layering.py --explain # 打印每个模块的归属理由
python3 scripts/check_layering.py --accept <name> # 登记一个既有模块(带理由)
python3 scripts/check_layering.py # 检查
python3 scripts/check_layering.py --explain # 说明判定依据
"""
from __future__ import annotations

Expand All @@ -27,132 +35,75 @@
CLI_SRC = ROOT / "crates" / "codeguard-cli" / "src"
BASELINE = ROOT / "layer-baseline.json"

# 领域形态:这些后缀的模块在 cli 中几乎都属于"工具适配 / 报告构造 / 领域类型",
# 而不是命令行管道。cli 只应保留参数解析、终端输出与编排。
DOMAIN_SHAPED = (
"_probe",
"_identity",
"_observation",
"_diagnostic",
"_parsed",
"_config",
"_graph",
"_state",
"_model",
"_probe", "_identity", "_observation", "_diagnostic", "_parsed",
"_config", "_graph", "_state", "_model", "_record", "_index",
)
CLI_ALLOWED = ("_command", "_arguments", "_preview", "_brief", "_feedback")
CLI_LOCAL = re.compile(r'\b(?:use\s+)?crate::[a-z_][a-z0-9_]*')


# 每个后缀应归属的 crate,用于给出可执行的迁移建议。
HOME_FOR = {
"_probe": "codeguard-adapters",
"_identity": "codeguard-adapters",
"_diagnostic": "codeguard-adapters",
"_parsed": "codeguard-adapters",
"_config": "codeguard-adapters",
"_observation": "codeguard-adapters",
"_graph": "codeguard-core",
"_state": "codeguard-core",
"_model": "codeguard-core",
}

# 命令入口与管道是 cli 的正当职责,不计入债务。
CLI_ALLOWED = ("_command", "_arguments", "_preview", "_brief", "_feedback", "main.rs", "lib.rs")


def domain_modules() -> dict[str, str]:
"""返回 {模块名: 应归属 crate},只含 cli 中本应下移的领域模块。"""
found: dict[str, str] = {}
def audit() -> list[dict]:
"""返回真正错位的模块:命名像领域模块,且既不引用 runtime 也不引用 cli 本地类型。"""
out: list[dict] = []
for path in sorted(CLI_SRC.rglob("*.rs")):
rel = path.relative_to(CLI_SRC).as_posix()
if rel in CLI_ALLOWED or any(rel.endswith(a) for a in CLI_ALLOWED):
stem = path.stem
if stem in ("main", "lib") or any(stem.endswith(a) for a in CLI_ALLOWED):
continue
stem = rel[:-3]
if stem.endswith(CLI_ALLOWED[:-1]) or stem in ("main", "lib"):
if not any(stem.endswith(d) for d in DOMAIN_SHAPED):
continue
for suffix, home in HOME_FOR.items():
if stem.endswith(suffix):
found[rel] = home
break
return found


def load_baseline() -> dict:
if not BASELINE.is_file():
return {"version": 1, "accepted": {}}
return json.loads(BASELINE.read_text(encoding="utf-8"))
text = path.read_text(encoding="utf-8", errors="replace")
if "codeguard_runtime" in text or CLI_LOCAL.search(text):
continue
Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse Rust dependencies before exempting domain modules

When a standalone domain module merely mentions codeguard_runtime or crate::... in documentation, a string, or test-only code, this raw-text check treats that mention as a production dependency and skips the module entirely. I reproduced this by adding an otherwise self-contained _model.rs with codeguard_runtime only in its module documentation; the layering command used by .github/workflows/ci.yml still exited successfully. Consequently, the new gate can silently admit exactly the misplaced production logic it is intended to reject; inspect actual non-test imports or otherwise exclude comments, strings, and test-only sections before granting the exemption.

Useful? React with 👍 / 👎.

out.append({"path": rel, "lines": len(text.splitlines())})
return out


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="codeguard crate 分层门禁")
parser.add_argument("--explain", action="store_true", help="打印每个模块的归属理由")
parser.add_argument("--accept", metavar="NAME", help="把一个既有模块登记为已知债务")
parser.add_argument("--reason", metavar="TEXT", help="配合 --accept 记录理由")
parser.add_argument("--explain", action="store_true", help="打印判定依据")
args = parser.parse_args(argv)

current = domain_modules()
baseline = load_baseline()
accepted: dict = baseline.get("accepted", {})

if args.accept:
if args.accept not in current:
print(f"{args.accept} 不在当前 cli 领域模块清单中,无需登记", file=sys.stderr)
return 2
accepted[args.accept] = args.reason or "未记录理由"
baseline["accepted"] = accepted
BASELINE.write_text(
json.dumps(baseline, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(f"已登记 {args.accept} → {HOME_FOR.get(suffix_of(args.accept), 'codeguard-core')}")
return 0
misplaced = audit()
current = {i["path"] for i in misplaced}

if args.explain:
for name in sorted(current):
state = "已知债务" if name in accepted else "未登记(会被本检查拒绝)"
print(f"{name:52} → {current[name]:20} [{state}]")
if name in accepted:
print(f"{'':52} 理由:{accepted[name]}")
print("权威边界见 crates/codeguard-cli/tests/crate_boundaries.rs:")
print(" adapters 不得依赖 runtime,因此 *_probe 留在 cli 是正确的。\n")
if not misplaced:
print("当前 codeguard-cli 中没有真正错位的领域模块。")
for item in misplaced:
print(f" {item['path']:48} {item['lines']:5} 行 → 应下移到 adapters/core")
return 0

# 未登记的新领域模块 = 倒挂在扩大
undeclared = sorted(set(current) - set(accepted))

if undeclared:
print("分层门禁失败:以下领域形态模块新增在 codeguard-cli 中。", file=sys.stderr)
print("它们应随工具适配或领域契约下移,而不是留在二进制 crate:\n", file=sys.stderr)
for name in undeclared:
print(f" {name} → 应归属 {current[name]}", file=sys.stderr)
print(
"\n处理方式:把模块移到对应 crate 并更新调用方;"
"若确有理由暂留,用 --accept 显式登记为已知债务。",
file=sys.stderr,
)
return 1

# 已登记模块被移走了:基线应收紧,不留空壳额度
removed = sorted(set(accepted) - set(current))
stale = [n for n in removed if n in accepted]

if stale:
print("分层门禁失败:以下已登记模块已不存在,请从 layer-baseline.json 移除,", file=sys.stderr)
print("以便额度随搬移自动收紧:\n", file=sys.stderr)
for name in stale:
print(f" {name}", file=sys.stderr)
if BASELINE.is_file():
accepted = json.loads(BASELINE.read_text(encoding="utf-8")).get("accepted", {})
stale = set(accepted) - current
if stale:
print("分层门禁失败:以下登记项已不再错位,请从 layer-baseline.json 移除,"
"以免留下永不收紧的空额度:", file=sys.stderr)
for name in sorted(stale):
print(f" {name}", file=sys.stderr)
return 1
new = sorted(current - set(accepted))
if new:
print("分层门禁失败:以下模块既不需要 codeguard_runtime、也不依赖 cli 本地类型,"
"属于被放错位置的领域逻辑:", file=sys.stderr)
for name in new:
print(f" {name}", file=sys.stderr)
return 1

if misplaced:
print("分层门禁失败:以下模块既不需要 runtime、也不依赖 cli 本地类型:", file=sys.stderr)
for item in misplaced:
print(f" {item['path']} ({item['lines']} 行)", file=sys.stderr)
return 1

print(
f"Layering OK: codeguard-cli 中已知领域债务 {len(current)} 个"
f"(全部已登记,无新增);core/adapters 未被 CLI 逻辑反向污染。"
)
print("Layering OK: codeguard-cli 中没有真正错位的领域模块;"
"依赖 runtime 的探针留在 cli 符合 crate_boundaries 的既定方向。")
return 0


def suffix_of(name: str) -> str:
stem = name[:-3] if name.endswith(".rs") else name
for suffix in HOME_FOR:
if stem.endswith(suffix):
return suffix
return ""


if __name__ == "__main__":
raise SystemExit(main())
Loading