## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
"""CASAN Check 1 — không được có credential nào nằm phơi trong repo.
|
||||
|
||||
Team Gamma chủ trì check này (hạn: 30/08). Viết sẵn từ 21/08 để chạy được liên
|
||||
tục trong lúc chuyển API key sang Keyring (R02-T05), thay vì tới ngày cổng mới
|
||||
chạy lần đầu rồi mới biết còn sót.
|
||||
|
||||
Quét gì:
|
||||
* file cấu hình đã commit: ``*.json`` ``*.jsonl`` ``*.yaml`` ``*.yml`` ``*.env``
|
||||
* mã nguồn Python — chỗ gán chuỗi cho biến tên như api_key / token / secret
|
||||
|
||||
Tìm hai loại:
|
||||
1. Chuỗi có hình dạng credential thật (sk-…, ghp_…, xoxb-…, AKIA…, JWT…)
|
||||
2. Trường tên nhạy cảm mà giá trị không rỗng và không phải placeholder
|
||||
|
||||
Bỏ qua: chuỗi rỗng, placeholder ("your-key-here", "changeme"…), giá trị hằng
|
||||
không phải bí mật (Ollama đòi có api_key nhưng bỏ qua nội dung).
|
||||
|
||||
Chạy: python scripts/audit_security.py [--json]
|
||||
Mã thoát: 0 = sạch, 1 = có phát hiện.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
# console Windows hay là cp932/cp1258; ép UTF-8 để không chết giữa báo cáo
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "build",
|
||||
"dist", ".pytest_cache", ".mypy_cache", "cowork-local-gitea"}
|
||||
CONFIG_SUFFIX = {".json", ".jsonl", ".yaml", ".yml", ".env"}
|
||||
|
||||
# tên trường coi là nhạy cảm
|
||||
SENSITIVE = re.compile(
|
||||
r"(api[_-]?key|secret|token|password|passwd|client[_-]?secret|"
|
||||
r"access[_-]?key|private[_-]?key|credential)", re.I)
|
||||
|
||||
# hình dạng credential thật — bắt được kể cả khi tên trường vô hại
|
||||
SHAPES = [
|
||||
("OpenAI", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}")),
|
||||
("Anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
|
||||
("GitHub", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")),
|
||||
("Slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}")),
|
||||
("AWS", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
||||
("Google", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
||||
("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.")),
|
||||
("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
||||
]
|
||||
|
||||
#: Dòng có dấu này được bỏ qua — lối thoát chuẩn cho mẫu thử, tài liệu, hằng
|
||||
#: đặt tên chứa "secret". Bắt buộc ghi lý do sau dấu hai chấm.
|
||||
ALLOW_MARK = re.compile(r"#\s*casan:\s*allow")
|
||||
|
||||
#: Giá trị là KHOÁ i18n / tên hằng, không phải bí mật. Bắt bằng hình dạng
|
||||
#: "a.b.c" hoặc "a_b_c" chứ không phải bằng danh sách đen từng chữ.
|
||||
LOOKS_LIKE_KEY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
|
||||
|
||||
#: Credential thật gần như luôn dài hơn thế này. Ngưỡng để loại dữ liệu test
|
||||
#: kiểu api_key="x" — báo động giả làm cả đội thôi đọc báo cáo.
|
||||
MIN_SECRET_LEN = 12
|
||||
|
||||
#: Giá trị là hằng liệt kê, không phải bí mật: mức độ cảnh báo, bật/tắt…
|
||||
ENUMISH = {"warning", "warn", "error", "info", "debug", "critical", "on", "off",
|
||||
"true", "false", "yes", "no", "allow", "deny", "block", "ask",
|
||||
"always", "never", "auto", "default", "disabled", "enabled"}
|
||||
|
||||
# giá trị vô hại — không tính là phát hiện
|
||||
PLACEHOLDER = re.compile(
|
||||
r"^(|ollama|none|null|changeme|your[_\- ]?(api[_\- ]?)?key([_\- ]?here)?|"
|
||||
r"<[^>]*>|\{\{.*\}\}|\$\{.*\}|xxx+|\*+|placeholder|todo|example|test|dummy|"
|
||||
r"sk-\.\.\.|\.\.\.)$", re.I)
|
||||
|
||||
# gán chuỗi trong Python: api_key = "..."
|
||||
PY_ASSIGN = re.compile(
|
||||
r"""["']?(\w*(?:api[_-]?key|secret|token|password|credential)\w*)["']?\s*[:=]\s*"""
|
||||
r"""["']([^"']*)["']""", re.I)
|
||||
|
||||
|
||||
def _is_placeholder(value: str) -> bool:
|
||||
v = value.strip()
|
||||
if PLACEHOLDER.match(v) or v.lower() in ENUMISH:
|
||||
return True
|
||||
if LOOKS_LIKE_KEY.match(v): # "monitoring.action_secret_in_output"
|
||||
return True
|
||||
# quá ngắn để là credential thật
|
||||
return len(v) < MIN_SECRET_LEN
|
||||
|
||||
|
||||
def _walk():
|
||||
for path in REPO.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if any(part in SKIP_DIRS for part in path.parts):
|
||||
continue
|
||||
if path.suffix in CONFIG_SUFFIX or path.suffix == ".py":
|
||||
yield path
|
||||
|
||||
|
||||
def scan() -> list[dict]:
|
||||
findings: list[dict] = []
|
||||
for path in _walk():
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
rel = path.relative_to(REPO).as_posix()
|
||||
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
if ALLOW_MARK.search(line):
|
||||
continue
|
||||
# 1. hình dạng credential thật
|
||||
for label, pattern in SHAPES:
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
findings.append({
|
||||
"file": rel, "line": lineno, "kind": f"{label} credential",
|
||||
"evidence": m.group(0)[:12] + "…",
|
||||
})
|
||||
|
||||
# 2. trường nhạy cảm có giá trị
|
||||
for m in PY_ASSIGN.finditer(line):
|
||||
field, value = m.group(1), m.group(2)
|
||||
if not SENSITIVE.search(field) or _is_placeholder(value):
|
||||
continue
|
||||
findings.append({
|
||||
"file": rel, "line": lineno,
|
||||
"kind": f"trường '{field}' có giá trị",
|
||||
"evidence": value[:6] + "…" if len(value) > 6 else value,
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _self_test() -> int:
|
||||
"""Một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó
|
||||
biết tìm. Cắm mẫu xấu và mẫu vô hại, xem có phân biệt đúng không."""
|
||||
import tempfile
|
||||
|
||||
bad = {
|
||||
"OpenAI": '"api_key": "sk-proj-abc123def456ghi789jkl012mno"', # casan: allow - mau thu cua chinh script
|
||||
"GitHub": 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', # casan: allow - mau thu cua chinh script
|
||||
"AWS": 'aws = "AKIAIOSFODNN7EXAMPLE"', # casan: allow - mau thu cua chinh script
|
||||
"Anthropic": '"api_key": "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxx"', # casan: allow - mau thu cua chinh script
|
||||
}
|
||||
ok = {
|
||||
"rỗng": '"api_key": ""',
|
||||
"placeholder": '"api_key": "your-key-here"',
|
||||
"ollama": '"api_key": "ollama"',
|
||||
"test ngắn": 'api_key = "x"',
|
||||
"hằng liệt kê": '"secret_in_output": "warning"',
|
||||
}
|
||||
global REPO
|
||||
keep = REPO
|
||||
passed = True
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
REPO = Path(tmp)
|
||||
for label, line in {**bad, **ok}.items():
|
||||
(REPO / "probe.py").write_text(line + "\n", encoding="utf-8")
|
||||
found = bool(scan())
|
||||
want = label in bad
|
||||
mark = "OK " if found == want else "SAI"
|
||||
if found != want:
|
||||
passed = False
|
||||
verb = "bắt được" if found else "bỏ qua"
|
||||
print(f" [{mark}] {label:14} -> {verb}")
|
||||
REPO = keep
|
||||
print()
|
||||
print("Tự kiểm: " + ("script phân biệt đúng." if passed
|
||||
else "*** script phân biệt SAI ***"))
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="CASAN Check 1 — quét credential lộ")
|
||||
ap.add_argument("--json", action="store_true", help="in kết quả dạng JSON")
|
||||
ap.add_argument("--self-test", action="store_true",
|
||||
help="cắm credential giả vào file tạm, kiểm script có bắt được")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.self_test:
|
||||
return _self_test()
|
||||
|
||||
findings = scan()
|
||||
if args.json:
|
||||
print(json.dumps(findings, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
n_files = sum(1 for _ in _walk())
|
||||
print(f"CASAN Check 1 — quét {n_files} file trong {REPO.name}/")
|
||||
if not findings:
|
||||
print("\n0 credential lưu plaintext. PASS.")
|
||||
else:
|
||||
print(f"\n*** {len(findings)} phát hiện ***\n")
|
||||
for f in findings:
|
||||
print(f" {f['file']}:{f['line']}")
|
||||
print(f" {f['kind']} — {f['evidence']}")
|
||||
return 1 if findings else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,166 @@
|
||||
"""AST-based Static Analysis Guard for Clean Architecture Enforcement.
|
||||
|
||||
Scans designated Python packages (such as `domain/` and `application/`) to ensure
|
||||
they remain 100% Pure Python and do not import presentation/GUI frameworks (PySide6, PyQt)
|
||||
or concrete application shells.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, NamedTuple, Set
|
||||
|
||||
# Ensure UTF-8 output on standard console streams across diverse Windows locales (CP932, etc.)
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
||||
try:
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class ImportViolation(NamedTuple):
|
||||
file_path: Path
|
||||
line_number: int
|
||||
imported_module: str
|
||||
rule_description: str
|
||||
|
||||
|
||||
# Disallowed top-level package names in pure business/domain layers
|
||||
FORBIDDEN_MODULE_PREFIXES: Set[str] = {
|
||||
"PySide6",
|
||||
"PySide2",
|
||||
"PyQt6",
|
||||
"PyQt5",
|
||||
"ui",
|
||||
"app",
|
||||
}
|
||||
|
||||
# Default directories that must strictly adhere to Clean Architecture
|
||||
DEFAULT_SCAN_DIRS: List[str] = [
|
||||
"domain",
|
||||
"application",
|
||||
]
|
||||
|
||||
|
||||
class ArchitectureImportVisitor(ast.NodeVisitor):
|
||||
"""AST visitor that checks all Import and ImportFrom statements against forbidden prefixes."""
|
||||
|
||||
def __init__(self, file_path: Path, forbidden: Set[str]) -> None:
|
||||
self.file_path = file_path
|
||||
self.forbidden = forbidden
|
||||
self.violations: List[ImportViolation] = []
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
# Check direct `import x, y` statements
|
||||
for alias in node.names:
|
||||
root_module = alias.name.split(".")[0]
|
||||
if root_module in self.forbidden:
|
||||
self.violations.append(
|
||||
ImportViolation(
|
||||
file_path=self.file_path,
|
||||
line_number=node.lineno,
|
||||
imported_module=alias.name,
|
||||
rule_description=f"Direct import of GUI/shell module '{alias.name}' is prohibited.",
|
||||
)
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
# Check `from x import y` statements
|
||||
if node.module:
|
||||
root_module = node.module.split(".")[0]
|
||||
if root_module in self.forbidden:
|
||||
self.violations.append(
|
||||
ImportViolation(
|
||||
file_path=self.file_path,
|
||||
line_number=node.lineno,
|
||||
imported_module=node.module,
|
||||
rule_description=f"Import from GUI/shell module '{node.module}' is prohibited.",
|
||||
)
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def scan_file(file_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
||||
"""Parse a single Python file into AST and return all detected architecture import violations."""
|
||||
try:
|
||||
source_code = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source_code, filename=str(file_path))
|
||||
except (SyntaxError, UnicodeDecodeError) as exc:
|
||||
print(f"[Syntax/Read Warning] Could not parse {file_path}: {exc}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
visitor = ArchitectureImportVisitor(file_path, forbidden)
|
||||
visitor.visit(tree)
|
||||
return visitor.violations
|
||||
|
||||
|
||||
def scan_directory(dir_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
||||
"""Recursively scan all Python files in a directory."""
|
||||
violations: List[ImportViolation] = []
|
||||
if not dir_path.exists():
|
||||
return violations
|
||||
|
||||
for py_file in dir_path.rglob("*.py"):
|
||||
if py_file.is_file() and "__pycache__" not in py_file.parts:
|
||||
violations.extend(scan_file(py_file, forbidden))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point for CI/pre-commit quality gate checks."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Clean Architecture Import Guard: Verifies zero GUI/Qt dependencies in domain/app layers."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--paths",
|
||||
nargs="*",
|
||||
default=DEFAULT_SCAN_DIRS,
|
||||
help="Paths or directories to scan (defaults to 'domain' and 'application')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
default=".",
|
||||
help="Root workspace directory",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
root_dir = Path(args.root).resolve()
|
||||
all_violations: List[ImportViolation] = []
|
||||
|
||||
print(f"[Clean Arch Guard] Scanning root: {root_dir}")
|
||||
|
||||
for target in args.paths:
|
||||
target_path = (root_dir / target).resolve()
|
||||
if not target_path.exists():
|
||||
# If the layer directory does not exist yet (during early migration), skip cleanly
|
||||
print(f"[Clean Arch Guard] Directory '{target}' does not exist yet (skipped).")
|
||||
continue
|
||||
|
||||
if target_path.is_file():
|
||||
all_violations.extend(scan_file(target_path, FORBIDDEN_MODULE_PREFIXES))
|
||||
else:
|
||||
all_violations.extend(scan_directory(target_path, FORBIDDEN_MODULE_PREFIXES))
|
||||
|
||||
if all_violations:
|
||||
print("\n[FAIL] CLEAN ARCHITECTURE VIOLATIONS DETECTED:")
|
||||
print("=" * 70)
|
||||
for v in all_violations:
|
||||
rel_path = v.file_path.relative_to(root_dir) if v.file_path.is_relative_to(root_dir) else v.file_path
|
||||
print(f" • {rel_path}:{v.line_number} -> Forbidden import: '{v.imported_module}'")
|
||||
print(f" Reason: {v.rule_description}")
|
||||
print("=" * 70)
|
||||
print(f"Total Violations: {len(all_violations)}")
|
||||
return 1
|
||||
|
||||
print("\n[PASS] CLEAN ARCHITECTURE CHECK: 0 forbidden imports detected.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lines-of-Code (LOC) Quality Guard (EPIC R10 - CASAN Gate S).
|
||||
|
||||
Enforces the Single Responsibility Principle by ensuring that no production
|
||||
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
|
||||
|
||||
# Ensure stdout handles UTF-8 on Windows consoles without codec crash
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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",
|
||||
# ``i18n/`` và ``theme/`` từng là 13 file rời nằm thẳng ở thư mục gốc nên
|
||||
# được quét theo diện "module gốc"; gom vào gói rồi thì phải khai ở đây,
|
||||
# không thì chúng lặng lẽ tuột khỏi tầm quét.
|
||||
"i18n", "theme",
|
||||
]
|
||||
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": 466,
|
||||
"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."""
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
return len(content.splitlines())
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Failed to read {file_path}: {exc}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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_file_lines(full)
|
||||
|
||||
if SCAN_ROOT_MODULES:
|
||||
for full in sorted(root_dir.glob("*.py")):
|
||||
yield full.name, count_file_lines(full)
|
||||
|
||||
|
||||
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 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(
|
||||
description="Verify that production source files do not exceed the LOC ceiling."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-lines",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_LINES,
|
||||
help=f"Maximum allowed lines per file (default: {DEFAULT_MAX_LINES})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dirs",
|
||||
nargs="+",
|
||||
default=DEFAULT_TARGET_DIRS,
|
||||
help=f"Target directories to scan (default: {' '.join(DEFAULT_TARGET_DIRS)})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=str,
|
||||
default=str(Path(__file__).resolve().parent.parent),
|
||||
help="Root repository directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Enable verbose output listing all scanned files",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
root_dir = Path(args.root).resolve()
|
||||
|
||||
print("=" * 70)
|
||||
print(f"CASAN Guard 'S' (Single Responsibility): Checking file length <= {args.max_lines} LOC")
|
||||
print(f"Scanning target directories: {args.dirs}")
|
||||
print("=" * 70)
|
||||
|
||||
total_files, violations = scan_directories(
|
||||
root_dir=root_dir,
|
||||
target_dirs=args.dirs,
|
||||
max_lines=args.max_lines,
|
||||
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):
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unified CASAN Quality Gate Orchestrator (EPIC R10 - Quality Assurance).
|
||||
|
||||
Runs all verification gates to validate architecture, security, single responsibility,
|
||||
and test suite compliance before merging PRs or cutting a release.
|
||||
|
||||
Verification Stages (CASAN):
|
||||
1. [C] Clean Architecture Guard (scripts/check_imports.py)
|
||||
2. [A] Atomic & Secrets Audit (scripts/audit_security.py)
|
||||
3. [S] Single Responsibility / LOC Guard (scripts/check_loc.py)
|
||||
4. [O] Orphan Module Guard (scripts/check_orphan_modules.py)
|
||||
5. [A/N] Automated Tests & No-Regression Suite (pytest)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
# Ensure stdout handles UTF-8 on Windows consoles without codec crash
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run_stage(title: str, cmd: List[str], cwd: Path) -> Tuple[bool, float, str]:
|
||||
"""Execute a single quality gate command and measure elapsed duration.
|
||||
|
||||
Returns:
|
||||
A tuple of (success_boolean, elapsed_seconds, combined_output)
|
||||
"""
|
||||
print(f"\n>> Running Gate: {title} ...")
|
||||
start_time = time.time()
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
success = proc.returncode == 0
|
||||
output = proc.stdout + ("\n" + proc.stderr if proc.stderr else "")
|
||||
return success, elapsed, output
|
||||
except Exception as exc:
|
||||
elapsed = time.time() - start_time
|
||||
return False, elapsed, f"Exception occurred while running {cmd}: {exc}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main CLI orchestrator for CASAN quality gates."""
|
||||
parser = argparse.ArgumentParser(description="Run CASAN Quality Gates on the repository.")
|
||||
parser.add_argument(
|
||||
"--skip-tests",
|
||||
action="store_true",
|
||||
help="Skip running pytest (run static and architectural guards only)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="store_true",
|
||||
help="Print detailed command output for passing gates as well",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 75)
|
||||
print("COWORK LOCAL - CASAN QUALITY GATE RUNNER")
|
||||
print("=" * 75)
|
||||
|
||||
stages = [
|
||||
(
|
||||
"C - Clean Architecture Boundary Check",
|
||||
[sys.executable, str(REPO_ROOT / "scripts" / "check_imports.py")],
|
||||
),
|
||||
(
|
||||
"A - Secrets & Plaintext Credentials Audit",
|
||||
[sys.executable, str(REPO_ROOT / "scripts" / "audit_security.py")],
|
||||
),
|
||||
(
|
||||
"S - Single Responsibility LOC Limit (<= 400 LOC)",
|
||||
[sys.executable, str(REPO_ROOT / "scripts" / "check_loc.py"), "--max-lines", "400"],
|
||||
),
|
||||
# Ba cổng trên không cổng nào bắt được mã chết: một file không ai import
|
||||
# vẫn đúng chiều phụ thuộc, vẫn không có credential, vẫn dưới 400 dòng.
|
||||
# Cổng O đi tìm đúng khoảng trống đó.
|
||||
(
|
||||
"O - Orphan Module Guard (moi module phai co noi import)",
|
||||
[sys.executable, str(REPO_ROOT / "scripts" / "check_orphan_modules.py")],
|
||||
),
|
||||
]
|
||||
|
||||
if not args.skip_tests:
|
||||
stages.append(
|
||||
(
|
||||
"A/N - Automated Pytest Suite (No-Regression)",
|
||||
[sys.executable, "-m", "pytest", "-q"],
|
||||
)
|
||||
)
|
||||
|
||||
results = []
|
||||
all_passed = True
|
||||
total_start = time.time()
|
||||
|
||||
for title, cmd in stages:
|
||||
success, elapsed, output = run_stage(title, cmd, cwd=REPO_ROOT)
|
||||
results.append((title, success, elapsed, output))
|
||||
|
||||
if success:
|
||||
print(f" [PASS] {title} ({elapsed:.2f}s)")
|
||||
if args.verbose:
|
||||
print(output.strip())
|
||||
else:
|
||||
all_passed = False
|
||||
print(f" [FAIL] {title} ({elapsed:.2f}s)")
|
||||
print("\n--- Output ---")
|
||||
print(output.strip())
|
||||
print("--------------")
|
||||
|
||||
total_elapsed = time.time() - total_start
|
||||
|
||||
print("\n" + "=" * 75)
|
||||
print("QUALITY GATE SUMMARY REPORT")
|
||||
print("=" * 75)
|
||||
for title, success, elapsed, _ in results:
|
||||
status_str = "[PASS]" if success else "[FAIL]"
|
||||
print(f" {status_str:<8} | {elapsed:>6.2f}s | {title}")
|
||||
|
||||
print("-" * 75)
|
||||
print(f"Total Execution Time: {total_elapsed:.2f}s")
|
||||
|
||||
if all_passed:
|
||||
print("\nALL CASAN QUALITY GATES PASSED! Ready for PR merge or release.")
|
||||
return 0
|
||||
else:
|
||||
print("\nQUALITY GATE FAILED! Please resolve the issues above before proceeding.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user