feat(gates): thêm cổng CASAN thứ tư (Gate O) và mở cổng LOC ra cả cây mã

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>
This commit is contained in:
2026-08-30 10:36:52 +09:00
co-authored by Claude Opus 5
parent a71085b39e
commit e5c184ce07
4 changed files with 602 additions and 30 deletions
+203 -28
View File
@@ -2,13 +2,27 @@
"""Lines-of-Code (LOC) Quality Guard (EPIC R10 - CASAN Gate S).
Enforces the Single Responsibility Principle by ensuring that no production
Python file in Clean Architecture packages exceeds the configured limit (400 LOC).
Python file exceeds the configured limit (400 LOC).
Phạm vi quét là TOÀN BỘ cây mã production, không chỉ bốn gói Clean
Architecture: ``ui/``, ``core/``, ``providers/``, ``security/``,
``mcp_servers/`` và các module nằm thẳng ở thư mục gốc đều được tính. Trước
đợt mở rộng này, 18 file dài hơn 400 dòng (dài nhất 944) vẫn qua cổng chỉ vì
chúng nằm ngoài bốn gói kia.
18 file đó không thể sửa hết trong một lần, nên chúng nằm trong
``LEGACY_ALLOWANCE`` với trần riêng bằng đúng số dòng hiện tại — một bánh cóc
chỉ quay một chiều: nợ cũ được giữ nguyên nhưng không được phình thêm, và mỗi
lần file co bớt thì cổng in ra lời nhắc hạ con số xuống.
"""
from __future__ import annotations
import argparse
import ast
import io
import os
import sys
import tokenize
from pathlib import Path
from typing import List, Tuple
@@ -19,10 +33,52 @@ if hasattr(sys.stdout, "reconfigure"):
except Exception:
pass
# Default target directories strictly subjected to the 400 LOC constraint
DEFAULT_TARGET_DIRS = ["domain", "application", "infrastructure", "presentation"]
# Toàn bộ cây mã production. Bốn gói Clean Architecture là phần cổng này canh
# từ đầu; ``ui``/``core``/``providers``/``security``/``mcp_servers`` được đưa
# vào sau đợt đối chiếu AS-IS/TO-BE — trước đó chúng nằm ngoài tầm quét, nên
# một file 944 dòng vẫn qua cổng chỉ vì nó không nằm trong bốn gói kia.
DEFAULT_TARGET_DIRS = [
"domain", "application", "infrastructure", "presentation",
"ui", "core", "providers", "security", "mcp_servers",
]
DEFAULT_MAX_LINES = 400
#: Các file ``.py`` nằm thẳng ở thư mục gốc cũng được quét (không đệ quy) —
#: ``app.py``, ``state.py``, ``theme*.py``… đều là mã production.
SCAN_ROOT_MODULES = True
#: Nợ cũ: file đã dài hơn 400 dòng TỪ TRƯỚC khi cổng mở rộng sang ``ui``/
#: ``core``/``providers``. Giá trị là số dòng tại thời điểm ghi nhận và đóng
#: vai trò trần riêng của từng file — đây là bánh cóc CHỈ QUAY MỘT CHIỀU:
#:
#: * file vượt quá trần riêng -> cổng đỏ (đang làm nợ cũ tệ thêm)
#: * file co xuống dưới trần -> [INFO] nhắc hạ con số xuống
#: * file co xuống <= 400 dòng -> [INFO] nhắc gỡ hẳn khỏi danh sách
#:
#: Không bao giờ thêm mục mới vào đây để làm cổng xanh trở lại: file mới viết
#: phải dưới 400 dòng ngay từ đầu. Nới một con số cũng vậy — cách duy nhất
#: đúng là tách file.
LEGACY_ALLOWANCE = {
"ui/workspace_tab.py": 566,
"ui/widgets.py": 505,
"ui/task_editor_dialog.py": 627,
"ui/accounts_tab.py": 559,
"core/skills.py": 405,
"core/chat_agent.py": 419,
"ui/flow_dialog.py": 483,
"core/tasks.py": 339,
"core/co4e.py": 330,
"core/task_executors.py": 347,
"ui/help_agent_widget.py": 313,
"core/structure_graph.py": 346,
"ui/cowork_tab.py": 255,
"providers/base.py": 224,
"ui/co4e_tab.py": 180,
"providers/openai_compat.py": 279,
"core/doc_extract.py": 283,
"ui/connectors_panel.py": 281,
}
def count_file_lines(file_path: Path) -> int:
"""Read a python file and return total physical line count."""
@@ -34,43 +90,149 @@ def count_file_lines(file_path: Path) -> int:
return 0
def scan_directories(
root_dir: Path, target_dirs: List[str], max_lines: int, verbose: bool = False
) -> Tuple[int, List[Tuple[str, int]]]:
"""Recursively scan target packages for files exceeding the maximum LOC limit.
def iter_source_files(root_dir: Path, target_dirs: List[str]):
"""Sinh (đường dẫn tương đối, số dòng) cho mọi file production cần quét.
Returns:
A tuple of (total_files_scanned, list_of_violations_as_(relative_path, line_count))
Ngoài các gói trong ``target_dirs``, quét thêm các ``.py`` nằm thẳng ở thư
mục gốc (``app.py``, ``state.py``, ``theme*.py``…) — chúng cũng là mã chạy
thật nhưng không thuộc gói nào, nên trước đây không ai canh.
"""
total_files = 0
violations: List[Tuple[str, int]] = []
for target in target_dirs:
dir_path = root_dir / target
if not dir_path.is_dir():
if verbose:
print(f"[INFO] Skipping missing directory: {target}")
continue
for current_root, dirnames, files in os.walk(dir_path):
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for file_name in sorted(files):
if file_name.endswith(".py"):
full = Path(current_root) / file_name
yield full.relative_to(root_dir).as_posix(), count_file_lines(full)
for current_root, _, files in os.walk(dir_path):
for file_name in files:
if not file_name.endswith(".py"):
continue
if SCAN_ROOT_MODULES:
for full in sorted(root_dir.glob("*.py")):
yield full.name, count_file_lines(full)
full_path = Path(current_root) / file_name
rel_path = full_path.relative_to(root_dir).as_posix()
lines = count_file_lines(full_path)
total_files += 1
if verbose:
print(f" {rel_path}: {lines} lines")
def iter_code_sizes(root_dir: Path, target_dirs: List[str]):
"""Như :func:`iter_source_files` nhưng đếm DÒNG MÃ — dành cho bánh cóc."""
for target in target_dirs:
dir_path = root_dir / target
if not dir_path.is_dir():
continue
for current_root, dirnames, files in os.walk(dir_path):
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for file_name in sorted(files):
if file_name.endswith(".py"):
full = Path(current_root) / file_name
yield full.relative_to(root_dir).as_posix(), count_code_lines(full)
if lines > max_lines:
violations.append((rel_path, lines))
if SCAN_ROOT_MODULES:
for full in sorted(root_dir.glob("*.py")):
yield full.name, count_code_lines(full)
def count_code_lines(file_path: Path) -> int:
"""Số dòng MÃ của một file: bỏ docstring, chú thích và dòng trống.
Dùng riêng cho bánh cóc ``LEGACY_ALLOWANCE``, không dùng cho trần 400 dòng.
Lý do: bánh cóc có một câu hỏi duy nhất — "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ý
ở đây 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 — đúng thứ không ai muốn.
Trần 400 dòng thì VẪN đếm dòng vật lý: đó là hợp đồng đã chốt của cổng
S từ đầu, đổi cách đo là âm thầm nới nó ra cho mọi file.
"""
try:
src = file_path.read_text(encoding="utf-8", errors="ignore")
except OSError as exc:
print(f"[WARN] Failed to read {file_path}: {exc}", file=sys.stderr)
return 0
skip: set = set()
try:
tree = ast.parse(src)
except SyntaxError:
return len(src.splitlines())
for node in ast.walk(tree):
if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
ast.AsyncFunctionDef)):
continue
body = getattr(node, "body", None)
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
and isinstance(body[0].value.value, str):
skip.update(range(body[0].lineno, body[0].end_lineno + 1))
try:
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
if tok.type == tokenize.COMMENT:
skip.add(tok.start[0])
except (tokenize.TokenError, IndentationError):
pass
return sum(1 for i, line in enumerate(src.splitlines(), 1)
if i not in skip and line.strip())
def scan_directories(
root_dir: Path, target_dirs: List[str], max_lines: int, verbose: bool = False
) -> Tuple[int, List[Tuple[str, int]]]:
"""Quét cây mã production, đối chiếu với trần chung và trần riêng của nợ cũ.
Trả về ``(số file đã quét, danh sách vi phạm)``. Một file bị tính là vi
phạm khi nó vượt trần chung VÀ không có trong ``LEGACY_ALLOWANCE``, hoặc
khi nó có trong danh sách nợ cũ nhưng đã phình quá con số ghi ở đó.
"""
total_files = 0
violations: List[Tuple[str, int]] = []
code_sizes = dict(iter_code_sizes(root_dir, target_dirs))
for rel_path, lines in iter_source_files(root_dir, target_dirs):
total_files += 1
if verbose:
print(f" {rel_path}: {lines} lines")
allowance = LEGACY_ALLOWANCE.get(rel_path)
if allowance is None:
if lines > max_lines:
violations.append((rel_path, lines))
else:
# Nợ cũ: đo bằng dòng mã, không đo bằng dòng vật lý.
code = code_sizes.get(rel_path, lines)
if code > allowance:
violations.append((rel_path, code))
return total_files, violations
def audit_legacy(root_dir: Path, target_dirs: List[str], max_lines: int) -> List[str]:
"""Các dòng nhắc về ``LEGACY_ALLOWANCE`` — chỉ để báo, không làm cổng đỏ.
Bánh cóc chỉ có nghĩa khi con số được siết lại mỗi lần file co bớt; nếu
không ai nhắc thì nó đứng yên mãi ở mức của lần ghi đầu tiên.
Hai thước đo, mỗi thước trả lời một câu khác nhau:
* **Gỡ hẳn khỏi danh sách** chỉ đúng khi file đã xuống dưới trần đo bằng
DÒNG VẬT LÝ — vì đó mới là thước của trần 400. Nhắc gỡ một file 950
dòng chỉ vì phần mã của nó dưới 400 là lời khuyên sai: gỡ xong cổng đỏ
ngay.
* **Hạ con số xuống** đo bằng DÒNG MÃ, cùng thước với chính bánh cóc.
"""
code = dict(iter_code_sizes(root_dir, target_dirs))
physical = dict(iter_source_files(root_dir, target_dirs))
notes: List[str] = []
for rel_path, allowance in sorted(LEGACY_ALLOWANCE.items()):
lines = code.get(rel_path)
if lines is None:
notes.append(f"{rel_path}: file khong con ton tai - go khoi LEGACY_ALLOWANCE")
elif physical.get(rel_path, lines) <= max_lines:
notes.append(f"{rel_path}: nay chi {physical[rel_path]} dong - go khoi LEGACY_ALLOWANCE")
elif lines < allowance:
notes.append(f"{rel_path}: {lines} dong ma (tran dang ghi {allowance}) - ha con so xuong {lines}")
return notes
def main() -> int:
"""CLI entry point for the LOC guard script."""
parser = argparse.ArgumentParser(
@@ -115,14 +277,27 @@ def main() -> int:
verbose=args.verbose,
)
notes = audit_legacy(root_dir, args.dirs, args.max_lines)
if notes:
print(f"\n[INFO] {len(notes)} muc trong LEGACY_ALLOWANCE co the siet lai:")
for note in notes:
print(f" - {note}")
if violations:
print(f"\n[FAIL] Found {len(violations)} oversized file(s) (> {args.max_lines} LOC):")
for file_path, lines in sorted(violations, key=lambda x: x[1], reverse=True):
print(f" ❌ {file_path}: {lines} lines (exceeds limit by {lines - args.max_lines})")
allowance = LEGACY_ALLOWANCE.get(file_path)
if allowance:
print(f" ❌ {file_path}: {lines} dong ma - no cu ghi la {allowance}, "
f"nay phinh them {lines - allowance}. Tach bot, dung noi con so.")
else:
print(f" ❌ {file_path}: {lines} lines (exceeds limit by {lines - args.max_lines})")
print("\nAction Required: Refactor oversized files into smaller single-responsibility modules.")
return 1
print(f"\n[PASS] All {total_files} production files in {args.dirs} satisfy <= {args.max_lines} LOC limit.")
legacy = len(LEGACY_ALLOWANCE)
print(f"\n[PASS] All {total_files} production files in {args.dirs} satisfy <= {args.max_lines} LOC limit "
f"({legacy} file no cu duoc mien tru, khong file nao phinh them).")
return 0