Gate O — module production phải có ít nhất một nơi import --------------------------------------------------------- Ba cổng đang có đều không bắt được mã chết, đúng như 1.400 dòng ở commit trước đã chứng minh. Gate O dựng đồ thị import bằng AST từ `__init__`/`__main__`/`app`, theo cả import muộn trong thân hàm. Hai ngoại lệ tự động để `ALLOWLIST` không phải chép lại cùng một lý do nhiều lần: `__init__.py` của gói mà mọi thành viên đều dormant, và module chỉ được chính mã dormant đã miễn trừ import. Cổng cũng đếm tuổi 9 seam chưa nối dây (nhãn `SEAM · dựng <ngày>`) và nhắc khi quá 30 ngày. Chỉ [WARN], không làm CI đỏ: để nó đỏ thì CI sẽ đỏ vào một buổi sáng mà không ai sửa gì, và cách nhanh nhất để xanh lại là sửa ngày. Cổng LOC — quét 366 file thay vì 191 ------------------------------------ `DEFAULT_TARGET_DIRS` chỉ có 4 gói Clean Architecture, nên một file 944 dòng trong `ui/` vẫn qua cổng. Nay quét cả `ui/`, `core/`, `providers/`, `security/`, `mcp_servers/` và các module ở thư mục gốc. 18 file đã dài hơn 400 dòng từ trước nằm trong `LEGACY_ALLOWANCE` — bánh cóc chỉ quay một chiều, và nó đo DÒNG MÃ chứ không đo dòng vật lý. Bánh cóc chỉ hỏi một câu, "file này có đang để thêm việc vào không?", mà viết thêm một docstring thì không. Đếm dòng vật lý ở đó biến cổng thành thứ phạt người viết tài liệu, và cách dễ nhất để làm nó xanh lại sẽ là xoá bớt chú thích. Trần 400 vẫn đếm dòng vật lý — đó là hợp đồng đã chốt của cổng S. CI -- Ghim tên thư mục checkout là `cowork_local`: nhiều test characterization sinh tiến trình con `python -c "from cowork_local... import ..."`, mà tiến trình con chỉ import được khi trên sys.path có thư mục mang đúng tên gói. Checkout vào thư mục tên khác làm 73 test đỏ vì lý do không liên quan tới mã. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
370 lines
18 KiB
Python
370 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Cổng chất lượng: không để module production nào không ai import (CASAN Gate O).
|
|
|
|
Vì sao cần cổng này
|
|
-------------------
|
|
Ba cổng đang có (Clean Architecture / Secrets / LOC) đều **không bắt được mã
|
|
chết**: một file không ai import vẫn qua cả ba, vì nó đúng chiều phụ thuộc,
|
|
không chứa credential, và ngắn hơn 400 dòng. Đợt kiểm chứng ngày 29/08 tìm ra
|
|
**1.400 dòng mã trùng lặp chết** lọt qua đúng theo cách đó — hai bản tách song
|
|
song của cùng một god-file cùng được giữ lại sau một lần merge, trong đó hai
|
|
file còn không import nổi (``GraphQaMixin`` không tồn tại,
|
|
``ui.calendar_view`` đã bị xoá). Không ai phát hiện vì không có gì đi tìm.
|
|
|
|
Cách làm
|
|
--------
|
|
Dựng đồ thị import tĩnh bằng AST, bắt đầu từ ``__init__`` / ``__main__`` /
|
|
``app``, đi theo cả import tuyệt đối lẫn tương đối, kể cả import nằm trong thân
|
|
hàm (mã này dùng import muộn rất nhiều). Import một module con cũng chạy
|
|
``__init__.py`` của mọi gói cha, nên các gói cha đó cũng được coi là tới được.
|
|
|
|
Module không tới được mà KHÔNG nằm trong ``ALLOWLIST`` thì cổng đỏ. Hai
|
|
ngoại lệ tự động, để danh sách miễn trừ không phải chép lại cùng một lý do
|
|
nhiều lần: ``__init__.py`` của một gói mà mọi thành viên đều dormant, và
|
|
module chỉ được chính mã dormant đã miễn trừ import.
|
|
|
|
Seam chưa nối dây
|
|
------------------
|
|
Một phần ``ALLOWLIST`` là *seam*: hợp đồng dựng trước để hai nhóm làm song
|
|
song, chờ bên kia nối vào. Những file đó mang nhãn ``SEAM · dựng <ngày>``
|
|
trong docstring đầu file, và cổng này đếm tuổi của chúng. Quá
|
|
``SEAM_MAX_AGE_DAYS`` thì in [WARN] — chỉ nhắc, không làm cổng đỏ.
|
|
|
|
Danh sách miễn trừ
|
|
------------------
|
|
``ALLOWLIST`` là mã dormant đã có TỪ TRƯỚC đợt refactor (xem
|
|
``docs/architecture/dormant-code.md``) cộng các seam đã dựng nhưng chưa nối
|
|
dây. Đây là danh sách **chỉ được co lại**: xoá hoặc nối dây một mục thì gỡ nó
|
|
khỏi đây, đừng bao giờ thêm mục mới để làm cổng xanh trở lại.
|
|
|
|
Chạy: python scripts/check_orphan_modules.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import os
|
|
import re
|
|
import sys
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Set, Tuple
|
|
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
PACKAGE = "cowork_local"
|
|
|
|
#: Không phải mã production — không quét.
|
|
SKIP_TOP = {
|
|
".git", ".gitea", ".vibeflow-preview", "__pycache__", "assets", "config",
|
|
"docs", "scripts", "skill_library", "slides", "tests", "tools",
|
|
}
|
|
|
|
#: Điểm bắt đầu: mọi thứ tới được từ đây là mã đang sống.
|
|
ROOTS = (PACKAGE, f"{PACKAGE}.__main__", f"{PACKAGE}.app")
|
|
|
|
#: Bao nhiêu ngày thì một seam chưa nối dây đáng được nhắc lại.
|
|
#:
|
|
#: Seam là hợp đồng dựng trước để hai nhóm làm song song — hợp lý trong vài
|
|
#: tuần, nhưng quá lâu thì nó không còn là hợp đồng nữa mà thành mã chết có lời
|
|
#: biện hộ. 30 ngày là một chu kỳ epic của dự án này: qua một chu kỳ mà vẫn
|
|
#: chưa ai nối thì phải quyết — nối, hoặc xoá.
|
|
#:
|
|
#: Chỉ nhắc, KHÔNG làm cổng đỏ: để nó đỏ thì CI sẽ đỏ vào một buổi sáng mà
|
|
#: không ai sửa gì cả, và cách nhanh nhất để xanh lại là sửa ngày.
|
|
SEAM_MAX_AGE_DAYS = 30
|
|
|
|
#: Nhận dạng nhãn seam trong docstring đầu file.
|
|
_SEAM_RE = re.compile(r"SEAM \u00b7 d\u1ef1ng (\d{4})-(\d{2})-(\d{2})")
|
|
|
|
#: Mã dormant được chấp nhận, kèm lý do. CHỈ ĐƯỢC CO LẠI.
|
|
ALLOWLIST: Dict[str, str] = {
|
|
# --- dormant từ trước refactor (docs/architecture/dormant-code.md) -----
|
|
"core/account_excel.py": "quản lý tài khoản — chưa bật, dormant từ trước R01",
|
|
"core/accounts.py": "quản lý tài khoản — chưa bật, dormant từ trước R01",
|
|
"core/codebase_memory_ui.py": "phần UI của codebase-memory — chưa bật",
|
|
"core/custom_agents.py": "bản agent tự tạo cũ, đã thay bằng core/co4e.py",
|
|
"core/graph_server.py": "máy chủ HTTP phục vụ đồ thị D3 — chỉ dùng khi bật cờ",
|
|
"core/groups.py": "nhóm người dùng — chưa bật, dormant từ trước R01",
|
|
"security/action_validator.py": "lớp bọc mỏng quanh command_risk_classifier",
|
|
"security/attachment_validator.py": "lớp bọc mỏng quanh command_risk_classifier",
|
|
"security/prompt_validator.py": "lớp bọc mỏng quanh command_risk_classifier",
|
|
"ui/accounts_tab.py": "màn quản lý tài khoản — chưa bật",
|
|
"ui/agent_manager_tab.py": "đã thay bằng presentation/monitoring/tabs/agents_admin_tab.py",
|
|
"ui/flow_dialog.py": "trình sửa luồng cũ, đã thay bằng Co4E Studio",
|
|
"ui/login_dialog.py": "bản này không có lớp đăng nhập",
|
|
"ui/mcp_servers_dialog.py": "đã thay bằng ui/connectors_panel.py",
|
|
"ui/skill_manager_tab.py": "đã thay bằng ui/skills_dialog.py",
|
|
# --- chạy bằng tiến trình con, không ai import ------------------------
|
|
"mcp_servers/ms365_server.py": "chạy bằng subprocess (state.py)",
|
|
"mcp_servers/project_context_server.py": "chạy bằng subprocess",
|
|
"mcp_servers/project_context/server.py": "chạy bằng subprocess",
|
|
"mcp_servers/project_context/foundation.py": "gói MCP chạy bằng subprocess",
|
|
"mcp_servers/project_context/registry.py": "gói MCP chạy bằng subprocess",
|
|
"mcp_servers/project_context/runtime.py": "gói MCP chạy bằng subprocess",
|
|
"mcp_servers/project_context/providers/change.py": "gói MCP chạy bằng subprocess",
|
|
"mcp_servers/project_context/providers/issue.py": "gói MCP chạy bằng subprocess",
|
|
"mcp_servers/project_context/providers/knowledge.py": "gói MCP chạy bằng subprocess",
|
|
"mcp_servers/project_context/tools/change_context.py": "gói MCP chạy bằng subprocess",
|
|
"mcp_servers/project_context/tools/issue_context.py": "gói MCP chạy bằng subprocess",
|
|
"mcp_servers/project_context/tools/knowledge_search.py": "gói MCP chạy bằng subprocess",
|
|
# --- seam đã dựng, chưa nối dây (xem F-05 trong báo cáo đối chiếu) ----
|
|
"application/workflows/co4e_workflow_service.py": "R07-T06 — bootstrap chưa gọi build_co4e_tab",
|
|
"presentation/co4e/co4e_tab.py": "factory chờ bootstrap.py nối, hạn đã ghi trong file",
|
|
"domain/security/tool_policy.py": "hình dạng dữ liệu, chờ nối vào ToolPolicyGateway",
|
|
"domain/workflows/run_record.py": "DTO, dùng khi Co4EWorkflowService được nối dây",
|
|
"domain/agents/agent_event_codec.py": "shim tạm, bỏ khi chat_panel dùng event có kiểu",
|
|
"infrastructure/filesystem/execution_workspace.py": "R06-T03 — chưa có call site",
|
|
"infrastructure/sandbox/sandbox_capabilities.py": "ma trận năng lực sandbox, chưa nối",
|
|
"infrastructure/config/settings_facade.py": "R02-T03 — chưa có call site",
|
|
"infrastructure/config/config_repository.py": "Protocol, chỉ dùng làm chú thích kiểu",
|
|
# --- vỏ chuyển tiếp (strangler-fig) ------------------------------------
|
|
# Vỏ chuyển tiếp tồn tại để giữ ĐƯỜNG IMPORT CŨ chạy được, nên việc mã mới
|
|
# không import nó là trạng thái ĐÚNG chứ không phải thiếu sót. Ba vỏ còn
|
|
# lại (ui/chat_panel.py, ui/monitoring_tab.py, core/tools.py) hiện vẫn tới
|
|
# được vì presentation/ đang nhập ngược qua chúng — đi vòng như thế tạo chu
|
|
# trình import, và khi nào gỡ nốt thì chúng cũng xuống đây.
|
|
"ui/composer.py": "vỏ chuyển tiếp R08-T02, giữ đường import cũ",
|
|
# --- hạ tầng test / công cụ -------------------------------------------
|
|
"conftest.py": "pytest tự nạp, không ai import",
|
|
}
|
|
|
|
|
|
def _discover() -> Dict[str, str]:
|
|
"""{tên module đầy đủ: đường dẫn tương đối} cho mọi file production."""
|
|
out: Dict[str, str] = {}
|
|
for dirpath, dirnames, filenames in os.walk(REPO_ROOT):
|
|
rel_dir = os.path.relpath(dirpath, REPO_ROOT).replace("\\", "/")
|
|
if rel_dir == ".":
|
|
rel_dir = ""
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_TOP]
|
|
else:
|
|
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
|
for name in filenames:
|
|
if not name.endswith(".py"):
|
|
continue
|
|
rel = f"{rel_dir}/{name}" if rel_dir else name
|
|
parts = rel[:-3].split("/")
|
|
if parts[-1] == "__init__":
|
|
parts = parts[:-1]
|
|
out[".".join([PACKAGE] + parts) if parts else PACKAGE] = rel
|
|
return out
|
|
|
|
|
|
def _targets(module: str, node: ast.AST, modules: Dict[str, str]) -> List[str]:
|
|
"""Các module trong gói mà một câu lệnh import trỏ tới."""
|
|
names: List[str] = []
|
|
if isinstance(node, ast.Import):
|
|
names = [a.name for a in node.names]
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if node.level:
|
|
parts = module.split(".")
|
|
# gói (__init__) tự nó là tiền tố; module thường thì lấy gói cha
|
|
if modules.get(module, "").endswith("__init__.py"):
|
|
pkg = parts
|
|
else:
|
|
pkg = parts[:-1]
|
|
up = node.level - 1
|
|
if up:
|
|
pkg = pkg[: len(pkg) - up]
|
|
base = ".".join(pkg)
|
|
head = f"{base}.{node.module}" if node.module else base
|
|
names = [head] + [f"{head}.{a.name}" for a in node.names]
|
|
elif node.module:
|
|
names = [node.module] + [f"{node.module}.{a.name}" for a in node.names]
|
|
resolved = []
|
|
for n in names:
|
|
if n in modules:
|
|
resolved.append(n)
|
|
elif n.rsplit(".", 1)[0] in modules:
|
|
resolved.append(n.rsplit(".", 1)[0])
|
|
return resolved
|
|
|
|
|
|
def _reach(modules: Dict[str, str], edges: Dict[str, Set[str]],
|
|
roots) -> Set[str]:
|
|
"""Tập module tới được từ ``roots`` theo đồ thị import."""
|
|
seen: Set[str] = set()
|
|
stack = [r for r in roots if r in modules]
|
|
while stack:
|
|
module = stack.pop()
|
|
if module in seen:
|
|
continue
|
|
seen.add(module)
|
|
stack.extend(edges.get(module, ()))
|
|
# Import một module con cũng chạy __init__.py của mọi gói cha.
|
|
parts = module.split(".")
|
|
for i in range(1, len(parts)):
|
|
parent = ".".join(parts[:i])
|
|
if parent in modules and parent not in seen:
|
|
stack.append(parent)
|
|
return seen
|
|
|
|
|
|
def find_orphans() -> List[str]:
|
|
"""Đường dẫn các module production không tới được từ điểm bắt đầu."""
|
|
modules = _discover()
|
|
edges: Dict[str, Set[str]] = {}
|
|
for module, rel in modules.items():
|
|
try:
|
|
tree = ast.parse((REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace"))
|
|
except SyntaxError:
|
|
edges[module] = set()
|
|
continue
|
|
found: Set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
found.update(_targets(module, node, modules))
|
|
edges[module] = found
|
|
|
|
seen = _reach(modules, edges, ROOTS)
|
|
return sorted(modules[m] for m in set(modules) - seen)
|
|
|
|
|
|
def find_dormant_reachable() -> Set[str]:
|
|
"""Module chỉ tới được từ một mục trong ``ALLOWLIST``.
|
|
|
|
Một module mà người import duy nhất là mã dormant đã được miễn trừ thì
|
|
dormant vì ĐÚNG LÝ DO ẤY — bắt nó phải có dòng miễn trừ riêng chỉ nhân đôi
|
|
cùng một thông tin, và tệ hơn là khiến người ta ngại tách file trong vùng
|
|
dormant. Đổi lại, khi mục dormant kia được nối dây hoặc bị xoá, cả nhánh
|
|
này tự động theo — sống theo hoặc bị báo lên, không có dòng miễn trừ cũ
|
|
nào ở lại che mắt.
|
|
"""
|
|
modules = _discover()
|
|
rel_to_mod = {rel: mod for mod, rel in modules.items()}
|
|
edges: Dict[str, Set[str]] = {}
|
|
for module, rel in modules.items():
|
|
try:
|
|
tree = ast.parse((REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace"))
|
|
except SyntaxError:
|
|
edges[module] = set()
|
|
continue
|
|
found: Set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
found.update(_targets(module, node, modules))
|
|
edges[module] = found
|
|
|
|
dormant_roots = [rel_to_mod[rel] for rel in ALLOWLIST if rel in rel_to_mod]
|
|
from_real = _reach(modules, edges, ROOTS)
|
|
from_dormant = _reach(modules, edges, dormant_roots)
|
|
# Bỏ chính các mục ALLOWLIST — chúng đã có dòng miễn trừ riêng, kể lại
|
|
# ở đây chỉ làm nhiễu.
|
|
return {modules[m] for m in from_dormant - from_real} - set(ALLOWLIST)
|
|
|
|
|
|
def seam_ages(today: Optional[date] = None) -> List[Tuple[str, int, bool]]:
|
|
"""(đường dẫn, số ngày dormant, có nhãn không) cho mọi seam trong ALLOWLIST.
|
|
|
|
Seam được nhận ra bằng chính nhãn ``SEAM · dựng <ngày>`` trong docstring
|
|
đầu file chứ không bằng một danh sách thứ hai ở đây: hai danh sách là hai
|
|
chỗ phải nhớ cập nhật, và chỗ thứ hai bao giờ cũng là chỗ bị quên.
|
|
|
|
Ngày trong nhãn là ngày file được thêm vào repo (lấy từ git lúc đặt nhãn),
|
|
không phải một hạn ai đó tự đặt.
|
|
"""
|
|
today = today or date.today()
|
|
out: List[Tuple[str, int, bool]] = []
|
|
for rel in sorted(ALLOWLIST):
|
|
path = REPO_ROOT / rel
|
|
if not path.is_file():
|
|
continue
|
|
head = path.read_text(encoding="utf-8", errors="replace")[:4000]
|
|
m = _SEAM_RE.search(head)
|
|
if m is None:
|
|
continue
|
|
made = date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
age = (today - made).days
|
|
out.append((rel, age, age > SEAM_MAX_AGE_DAYS))
|
|
return out
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--list-allowed", action="store_true",
|
|
help="In danh sách miễn trừ rồi thoát")
|
|
args = parser.parse_args()
|
|
|
|
if args.list_allowed:
|
|
for rel, why in sorted(ALLOWLIST.items()):
|
|
print(f" {rel:62} {why}")
|
|
return 0
|
|
|
|
print("=" * 70)
|
|
print("CASAN Guard 'O' (Orphan): module production phải có ít nhất 1 nơi import")
|
|
print("=" * 70)
|
|
|
|
orphans = find_orphans()
|
|
orphan_set = set(orphans)
|
|
|
|
def _dormant_package(rel: str) -> bool:
|
|
"""``__init__.py`` của một gói mà KHÔNG module nào bên trong tới được.
|
|
|
|
Gói kiểu đó dormant vì thành viên của nó dormant — báo riêng nó ra chỉ
|
|
nhân đôi cùng một phát hiện. Gói còn dù chỉ một module đang sống thì
|
|
``__init__.py`` cũng phải sống theo (import module con chạy nó), nên
|
|
luật này không che được mã chết thật.
|
|
"""
|
|
if not rel.endswith("__init__.py"):
|
|
return False
|
|
pkg = rel[: -len("__init__.py")]
|
|
members = [m for m in _discover().values()
|
|
if m.startswith(pkg) and m != rel]
|
|
return bool(members) and all(
|
|
m in orphan_set or m in ALLOWLIST for m in members)
|
|
|
|
via_dormant = find_dormant_reachable()
|
|
unexpected = [o for o in orphans
|
|
if o not in ALLOWLIST
|
|
and o not in via_dormant
|
|
and not _dormant_package(o)]
|
|
stale = sorted(set(ALLOWLIST) - orphan_set)
|
|
|
|
if stale:
|
|
print(f"\n[INFO] {len(stale)} mục trong ALLOWLIST nay đã có nơi import — gỡ khỏi danh sách:")
|
|
for rel in stale:
|
|
print(f" - {rel}")
|
|
|
|
if unexpected:
|
|
print(f"\n[FAIL] {len(unexpected)} module production không ai import:")
|
|
for rel in unexpected:
|
|
print(f" x {rel}")
|
|
print("\nXoá chúng, hoặc nối dây, hoặc thêm vào ALLOWLIST kèm lý do")
|
|
print("(scripts/check_orphan_modules.py) nếu đó là mã dormant có chủ ý.")
|
|
return 1
|
|
|
|
seams = seam_ages()
|
|
overdue = [(rel, age) for rel, age, late in seams if late]
|
|
if overdue:
|
|
print(f"\n[WARN] {len(overdue)}/{len(seams)} seam đã dựng quá {SEAM_MAX_AGE_DAYS} "
|
|
f"ngày mà chưa nối dây — nối, hoặc xoá:")
|
|
for rel, age in sorted(overdue, key=lambda x: -x[1]):
|
|
print(f" ! {rel}: {age} ngày")
|
|
elif seams:
|
|
oldest = max(age for _, age, _ in seams)
|
|
print(f"\n[INFO] {len(seams)} seam chưa nối dây, cái lâu nhất {oldest} ngày "
|
|
f"(nhắc khi quá {SEAM_MAX_AGE_DAYS}).")
|
|
|
|
total = len(_discover())
|
|
# __init__.py của gói toàn thành viên dormant đã có luật riêng ở trên —
|
|
# không kể lại lần nữa.
|
|
implicit = sorted(r for r in via_dormant if not _dormant_package(r))
|
|
if implicit:
|
|
print(f"\n[INFO] {len(implicit)} module chỉ được mã dormant import — "
|
|
f"dormant theo cùng lý do, không cần dòng miễn trừ riêng:")
|
|
for rel in implicit:
|
|
print(f" - {rel}")
|
|
print(f"\n[PASS] {total - len(orphans)}/{total} module production đều có nơi import "
|
|
f"({len(orphans)} mục dormant đã được miễn trừ).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|