Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## 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:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+204
View File
@@ -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())
+166
View File
@@ -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())
+309
View File
@@ -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())
+369
View File
@@ -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())
+151
View File
@@ -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())