Fix 14 tool bugs: capability_check tool_name, quality_gate pipefail, 12 doc param fixes, 3 test-suite fixes
This commit is contained in:
@@ -5,7 +5,7 @@ import yaml
|
||||
import os
|
||||
|
||||
class CapabilityCheck(Tool):
|
||||
async def execute(self, plugin_dir: str = "", **kwargs):
|
||||
async def execute(self, plugin_dir: str = "", tool_name: str = "", **kwargs):
|
||||
if not plugin_dir:
|
||||
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
config_path = os.path.join(plugin_dir, "default_config.yaml")
|
||||
@@ -14,5 +14,9 @@ class CapabilityCheck(Tool):
|
||||
config = yaml.safe_load(f)
|
||||
except (FileNotFoundError, yaml.YAMLError) as e:
|
||||
config = {}
|
||||
if tool_name:
|
||||
from usr.plugins.a0_software_orchestrator.helpers.tool_capabilities import check_tool
|
||||
result = check_tool(tool_name)
|
||||
return Response(message=str(result), break_loop=False)
|
||||
report = generate_capability_report(config)
|
||||
return Response(message=str(report), break_loop=False)
|
||||
|
||||
+594
-137
@@ -1,6 +1,9 @@
|
||||
"""Tool: phase gate validation (real implementation, DB-backed).
|
||||
"""Tool: generic phase gate validation (DB-backed).
|
||||
|
||||
Replaces the stub. Persists every run to orch_quality_runs in patterns.db.
|
||||
Discovers check commands from project config files (package.json, Makefile,
|
||||
nx.json, pyproject.toml) instead of hardcoded tech-stack registries.
|
||||
Supports any tech stack: Python, Node.js, NestJS, React, Vue, Nx monorepos, etc.
|
||||
Persists every run to orch_quality_runs in patterns.db.
|
||||
|
||||
Args (kwargs):
|
||||
action (str): 'run' | 'show_last' | 'list_runs' | 'show_config'
|
||||
@@ -8,6 +11,7 @@ Args (kwargs):
|
||||
project_name (str): project name (preferred, e.g. 'rentman-clone')
|
||||
project_root (str): alternative — path to project root, name derived from basename
|
||||
gate_level (str): 'implementation' | 'quality-review' | 'deployment' | 'release'
|
||||
| 'ops-gate' | 'security-gate'
|
||||
Default: 'quality-review'
|
||||
triggered_by (str): 'manual' | 'pre-commit' | 'pre-deploy' | etc.
|
||||
Default: 'manual'
|
||||
@@ -41,7 +45,12 @@ from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBErro
|
||||
from usr.plugins.a0_software_orchestrator.helpers.safety_rules import redact_secrets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
|
||||
"""Resolve project name from explicit name, root path, or cwd."""
|
||||
if project_name:
|
||||
return project_name.strip()
|
||||
if project_root:
|
||||
@@ -49,111 +58,497 @@ def _resolve_name(project_name: str = "", project_root: str = "") -> str:
|
||||
return os.path.basename(os.getcwd())
|
||||
|
||||
|
||||
# Registry of L1-L3 checks. Each entry: (name, level, command_template, expect_exit, parser)
|
||||
# command_template uses {project_root} placeholder. timeout in seconds.
|
||||
PYTHON_FASTAPI_CHECKS = {
|
||||
"L1": [
|
||||
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
|
||||
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
|
||||
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
|
||||
],
|
||||
"L2": [
|
||||
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
|
||||
],
|
||||
"L3": [
|
||||
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
|
||||
],
|
||||
}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path discovery — detect backend/frontend dirs generically
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PYTHON_FASTAPI_VUE_CHECKS = {
|
||||
"L1": [
|
||||
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
|
||||
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
|
||||
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
|
||||
],
|
||||
"L2": [
|
||||
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
|
||||
{"name": "L2_vite_build", "cmd": "cd {project_root}/frontend && npm run build 2>&1 | tail -10", "expect": 0},
|
||||
],
|
||||
"L3": [
|
||||
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
|
||||
],
|
||||
}
|
||||
|
||||
PYTHON_FASTAPI_REACT_CHECKS = {
|
||||
"L1": [
|
||||
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
|
||||
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
|
||||
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
|
||||
{"name": "L1_tsc_noemit", "cmd": "cd {project_root}/frontend && ./node_modules/.bin/tsc --noEmit 2>&1", "expect": 0},
|
||||
],
|
||||
"L2": [
|
||||
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
|
||||
{"name": "L2_vite_build", "cmd": "cd {project_root}/frontend && npm run build 2>&1 | tail -10", "expect": 0},
|
||||
],
|
||||
"L3": [
|
||||
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
|
||||
],
|
||||
}
|
||||
_BACKEND_CANDIDATES = ("backend", "apps/api", "apps/backend", "server", "api")
|
||||
_FRONTEND_CANDIDATES = ("frontend", "apps/web", "apps/frontend", "client", "web")
|
||||
|
||||
|
||||
def _detect_tech_stack(project_root: str) -> str:
|
||||
"""Auto-detect tech stack from project structure."""
|
||||
has_pyproject = os.path.isfile(os.path.join(project_root, "backend", "pyproject.toml")) or os.path.isfile(os.path.join(project_root, "pyproject.toml"))
|
||||
has_reqs = os.path.isfile(os.path.join(project_root, "backend", "requirements.txt")) or os.path.isfile(os.path.join(project_root, "requirements.txt"))
|
||||
has_python_app = os.path.isdir(os.path.join(project_root, "backend", "app")) or os.path.isdir(os.path.join(project_root, "app"))
|
||||
has_react = os.path.isfile(os.path.join(project_root, "frontend", "package.json")) and os.path.isfile(os.path.join(project_root, "frontend", "tsconfig.json"))
|
||||
has_vue = os.path.isfile(os.path.join(project_root, "frontend", "package.json")) and (
|
||||
os.path.isfile(os.path.join(project_root, "frontend", "vite.config.js")) or os.path.isfile(os.path.join(project_root, "frontend", "vite.config.ts"))
|
||||
)
|
||||
def _detect_backend_dir(project_root: str) -> Optional[str]:
|
||||
"""Return the backend source directory relative to project_root, or None.
|
||||
|
||||
if has_reqs and has_python_app and has_react:
|
||||
return "python_fastapi+node_react"
|
||||
if has_reqs and has_python_app and has_vue:
|
||||
return "python_fastapi+vue"
|
||||
if (has_reqs or has_pyproject) and has_python_app:
|
||||
return "python_fastapi"
|
||||
return "unknown"
|
||||
Checks common layouts: backend/, apps/api/, apps/backend/, server/, api/.
|
||||
"""
|
||||
for candidate in _BACKEND_CANDIDATES:
|
||||
if os.path.isdir(os.path.join(project_root, candidate)):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _get_checks_for_stack(tech_stack: str, gate_level: str) -> List[Dict[str, Any]]:
|
||||
"""Return list of check definitions for the given stack + gate level."""
|
||||
if tech_stack == "python_fastapi+node_react":
|
||||
registry = PYTHON_FASTAPI_REACT_CHECKS
|
||||
elif tech_stack == "python_fastapi+vue":
|
||||
registry = PYTHON_FASTAPI_VUE_CHECKS
|
||||
elif tech_stack == "python_fastapi":
|
||||
registry = PYTHON_FASTAPI_CHECKS
|
||||
else:
|
||||
return []
|
||||
def _detect_frontend_dir(project_root: str) -> Optional[str]:
|
||||
"""Return the frontend source directory relative to project_root, or None.
|
||||
|
||||
# Map gate_level → which L1-L3 levels are required
|
||||
if gate_level in ("implementation", "quality-review"):
|
||||
levels = ["L1", "L2"]
|
||||
elif gate_level == "deployment":
|
||||
levels = ["L1", "L2", "L3"]
|
||||
elif gate_level == "release":
|
||||
levels = ["L1", "L2", "L3"]
|
||||
else:
|
||||
levels = ["L1"]
|
||||
Checks common layouts: frontend/, apps/web/, apps/frontend/, client/, web/.
|
||||
"""
|
||||
for candidate in _FRONTEND_CANDIDATES:
|
||||
if os.path.isdir(os.path.join(project_root, candidate)):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config-file readers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_package_json_scripts(dir_path: str) -> Dict[str, str]:
|
||||
"""Return scripts dict from package.json in dir_path, or {} if missing."""
|
||||
pkg_path = os.path.join(dir_path, "package.json")
|
||||
if not os.path.isfile(pkg_path):
|
||||
return {}
|
||||
try:
|
||||
with open(pkg_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("scripts", {})
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_makefile_targets(project_root: str) -> List[str]:
|
||||
"""Return list of Makefile target names, or [] if no Makefile."""
|
||||
for fname in ("Makefile", "makefile", "GNUmakefile"):
|
||||
mk_path = os.path.join(project_root, fname)
|
||||
if not os.path.isfile(mk_path):
|
||||
continue
|
||||
targets: List[str] = []
|
||||
try:
|
||||
with open(mk_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.rstrip("\n")
|
||||
stripped = line.strip()
|
||||
# target lines: "name: deps" (not indented, not comment, has colon)
|
||||
if (
|
||||
stripped
|
||||
and not stripped.startswith("#")
|
||||
and not line.startswith("\t")
|
||||
and not line.startswith(" ")
|
||||
and ":" in stripped
|
||||
):
|
||||
target = stripped.split(":")[0].strip()
|
||||
# skip variable assignments (VAR = value or VAR := value)
|
||||
if "=" not in target and target:
|
||||
targets.append(target)
|
||||
except OSError:
|
||||
pass
|
||||
return targets
|
||||
return []
|
||||
|
||||
|
||||
def _has_pytest_config(project_root: str, backend_dir: Optional[str]) -> bool:
|
||||
"""Check if pytest is configured (pytest.ini or [tool.pytest] in pyproject.toml)."""
|
||||
search_dirs = [project_root]
|
||||
if backend_dir:
|
||||
search_dirs.append(os.path.join(project_root, backend_dir))
|
||||
for d in search_dirs:
|
||||
if os.path.isfile(os.path.join(d, "pytest.ini")):
|
||||
return True
|
||||
pyproject = os.path.join(d, "pyproject.toml")
|
||||
if os.path.isfile(pyproject):
|
||||
try:
|
||||
with open(pyproject, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
if "[tool.pytest" in content or "[pytest]" in content:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _has_python_deps(project_root: str, backend_dir: Optional[str]) -> bool:
|
||||
"""Check if project has Python dependencies (requirements.txt or pyproject.toml)."""
|
||||
search_dirs = [project_root]
|
||||
if backend_dir:
|
||||
search_dirs.append(os.path.join(project_root, backend_dir))
|
||||
for d in search_dirs:
|
||||
if os.path.isfile(os.path.join(d, "requirements.txt")):
|
||||
return True
|
||||
if os.path.isfile(os.path.join(d, "pyproject.toml")):
|
||||
# only count if it has [project] or [tool.poetry] or [tool.setuptools]
|
||||
try:
|
||||
with open(os.path.join(d, "pyproject.toml"), "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
if "[project]" in content or "[tool.poetry]" in content or "[tool.setuptools]" in content:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _detect_python_import_target(py_dir: str) -> Optional[str]:
|
||||
"""Detect the main Python import target from directory structure.
|
||||
|
||||
Looks for common patterns: app/__init__.py, src/__init__.py, main.py,
|
||||
app/main.py. Returns the import path string or None.
|
||||
"""
|
||||
if os.path.isfile(os.path.join(py_dir, "app", "__init__.py")):
|
||||
return "app"
|
||||
if os.path.isfile(os.path.join(py_dir, "app", "main.py")):
|
||||
return "app.main"
|
||||
if os.path.isfile(os.path.join(py_dir, "src", "__init__.py")):
|
||||
return "src"
|
||||
if os.path.isfile(os.path.join(py_dir, "main.py")):
|
||||
return "main"
|
||||
return None
|
||||
|
||||
|
||||
def _detect_stack_descriptor(project_root: str, backend_dir: Optional[str], frontend_dir: Optional[str]) -> str:
|
||||
"""Return a short stack descriptor for the config row (informational only).
|
||||
|
||||
This is NOT used for check routing — checks are discovered generically.
|
||||
Examples: 'python', 'nodejs', 'python+nodejs', 'nx-monorepo', 'unknown'.
|
||||
"""
|
||||
has_py = _has_python_deps(project_root, backend_dir)
|
||||
has_pkg = os.path.isfile(os.path.join(project_root, "package.json"))
|
||||
has_nx = os.path.isfile(os.path.join(project_root, "nx.json"))
|
||||
|
||||
if has_nx:
|
||||
parts = []
|
||||
if has_py:
|
||||
parts.append("python")
|
||||
parts.append("nx-monorepo")
|
||||
return "+".join(parts) if parts else "nx-monorepo"
|
||||
|
||||
parts = []
|
||||
if has_py:
|
||||
parts.append("python")
|
||||
if has_pkg or frontend_dir:
|
||||
parts.append("nodejs")
|
||||
if not parts:
|
||||
return "unknown"
|
||||
return "+".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check discovery — read project config files and build a generic check list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _discover_checks(project_root: str) -> List[Dict[str, Any]]:
|
||||
"""Discover available checks from project config files.
|
||||
|
||||
Reads package.json scripts, Makefile targets, nx.json presence,
|
||||
and pyproject.toml/pytest config to build a generic check list.
|
||||
|
||||
Each check dict: {name, cmd, expect, level, category}
|
||||
Categories: lint, build, import-test, test, ops, security
|
||||
Levels: L1 (fast, static), L2 (build/import), L3 (full test), L4 (ops/security)
|
||||
"""
|
||||
checks: List[Dict[str, Any]] = []
|
||||
for lvl in levels:
|
||||
for c in registry.get(lvl, []):
|
||||
checks.append({"level": lvl, **c})
|
||||
|
||||
backend_dir = _detect_backend_dir(project_root)
|
||||
frontend_dir = _detect_frontend_dir(project_root)
|
||||
|
||||
root_scripts = _read_package_json_scripts(project_root)
|
||||
mk_targets = _parse_makefile_targets(project_root)
|
||||
has_nx = os.path.isfile(os.path.join(project_root, "nx.json"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# L1: Lint / static checks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Python lint (ruff) if Python project
|
||||
if _has_python_deps(project_root, backend_dir):
|
||||
py_dir = os.path.join(project_root, backend_dir) if backend_dir else project_root
|
||||
checks.append({
|
||||
"name": "lint_ruff_check",
|
||||
"cmd": f"ruff check {shlex.quote(py_dir)}",
|
||||
"expect": 0,
|
||||
"level": "L1",
|
||||
"category": "lint",
|
||||
})
|
||||
checks.append({
|
||||
"name": "lint_ruff_format",
|
||||
"cmd": f"ruff format --check {shlex.quote(py_dir)}",
|
||||
"expect": 0,
|
||||
"level": "L1",
|
||||
"category": "lint",
|
||||
})
|
||||
|
||||
# JS/TS lint — root level
|
||||
if "lint" in root_scripts:
|
||||
checks.append({
|
||||
"name": "lint_npm_root",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && npm run lint 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L1",
|
||||
"category": "lint",
|
||||
})
|
||||
|
||||
# JS/TS lint — frontend
|
||||
if frontend_dir:
|
||||
fe_path = os.path.join(project_root, frontend_dir)
|
||||
fe_scripts = _read_package_json_scripts(fe_path)
|
||||
if "lint" in fe_scripts:
|
||||
checks.append({
|
||||
"name": f"lint_{frontend_dir.replace('/', '_')}",
|
||||
"cmd": f"cd {shlex.quote(fe_path)} && npm run lint 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L1",
|
||||
"category": "lint",
|
||||
})
|
||||
|
||||
# JS/TS lint — backend (if separate JS/TS backend)
|
||||
if backend_dir:
|
||||
be_path = os.path.join(project_root, backend_dir)
|
||||
be_scripts = _read_package_json_scripts(be_path)
|
||||
if "lint" in be_scripts:
|
||||
checks.append({
|
||||
"name": f"lint_{backend_dir.replace('/', '_')}",
|
||||
"cmd": f"cd {shlex.quote(be_path)} && npm run lint 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L1",
|
||||
"category": "lint",
|
||||
})
|
||||
|
||||
# Makefile lint target
|
||||
if "lint" in mk_targets:
|
||||
checks.append({
|
||||
"name": "lint_makefile",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && make lint 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L1",
|
||||
"category": "lint",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# L2: Build + import tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Nx build (if nx.json exists, prefer nx for monorepo builds)
|
||||
if has_nx:
|
||||
checks.append({
|
||||
"name": "build_nx",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && npx nx run-many --target=build --all 2>&1 | tail -30",
|
||||
"expect": 0,
|
||||
"level": "L2",
|
||||
"category": "build",
|
||||
})
|
||||
else:
|
||||
# Frontend build
|
||||
if frontend_dir:
|
||||
fe_path = os.path.join(project_root, frontend_dir)
|
||||
fe_scripts = _read_package_json_scripts(fe_path)
|
||||
if "build" in fe_scripts:
|
||||
checks.append({
|
||||
"name": f"build_{frontend_dir.replace('/', '_')}",
|
||||
"cmd": f"cd {shlex.quote(fe_path)} && npm run build 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L2",
|
||||
"category": "build",
|
||||
})
|
||||
|
||||
# Backend build (JS/TS)
|
||||
if backend_dir:
|
||||
be_path = os.path.join(project_root, backend_dir)
|
||||
be_scripts = _read_package_json_scripts(be_path)
|
||||
if "build" in be_scripts:
|
||||
checks.append({
|
||||
"name": f"build_{backend_dir.replace('/', '_')}",
|
||||
"cmd": f"cd {shlex.quote(be_path)} && npm run build 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L2",
|
||||
"category": "build",
|
||||
})
|
||||
|
||||
# Root build
|
||||
if "build" in root_scripts:
|
||||
checks.append({
|
||||
"name": "build_root",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && npm run build 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L2",
|
||||
"category": "build",
|
||||
})
|
||||
|
||||
# Makefile build
|
||||
if "build" in mk_targets:
|
||||
checks.append({
|
||||
"name": "build_makefile",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && make build 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L2",
|
||||
"category": "build",
|
||||
})
|
||||
|
||||
# Python import test
|
||||
if _has_python_deps(project_root, backend_dir):
|
||||
py_dir = os.path.join(project_root, backend_dir) if backend_dir else project_root
|
||||
import_target = _detect_python_import_target(py_dir)
|
||||
if import_target:
|
||||
checks.append({
|
||||
"name": f"import_test_{import_target.replace('.', '_')}",
|
||||
"cmd": f"cd {shlex.quote(py_dir)} && python -c 'import {import_target}' 2>&1",
|
||||
"expect": 0,
|
||||
"level": "L2",
|
||||
"category": "import-test",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# L3: Full test suite
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Python pytest
|
||||
if _has_pytest_config(project_root, backend_dir):
|
||||
py_dir = os.path.join(project_root, backend_dir) if backend_dir else project_root
|
||||
checks.append({
|
||||
"name": "test_pytest",
|
||||
"cmd": f"cd {shlex.quote(py_dir)} && python -m pytest -q --tb=no 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L3",
|
||||
"category": "test",
|
||||
})
|
||||
|
||||
# Nx test
|
||||
if has_nx:
|
||||
checks.append({
|
||||
"name": "test_nx",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && npx nx run-many --target=test --all 2>&1 | tail -30",
|
||||
"expect": 0,
|
||||
"level": "L3",
|
||||
"category": "test",
|
||||
})
|
||||
else:
|
||||
# JS/TS test — root
|
||||
if "test" in root_scripts:
|
||||
checks.append({
|
||||
"name": "test_npm_root",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && npm test 2>&1 | tail -30",
|
||||
"expect": 0,
|
||||
"level": "L3",
|
||||
"category": "test",
|
||||
})
|
||||
|
||||
# JS/TS test — frontend
|
||||
if frontend_dir:
|
||||
fe_path = os.path.join(project_root, frontend_dir)
|
||||
fe_scripts = _read_package_json_scripts(fe_path)
|
||||
if "test" in fe_scripts:
|
||||
checks.append({
|
||||
"name": f"test_{frontend_dir.replace('/', '_')}",
|
||||
"cmd": f"cd {shlex.quote(fe_path)} && npm test 2>&1 | tail -30",
|
||||
"expect": 0,
|
||||
"level": "L3",
|
||||
"category": "test",
|
||||
})
|
||||
|
||||
# JS/TS test — backend
|
||||
if backend_dir:
|
||||
be_path = os.path.join(project_root, backend_dir)
|
||||
be_scripts = _read_package_json_scripts(be_path)
|
||||
if "test" in be_scripts:
|
||||
checks.append({
|
||||
"name": f"test_{backend_dir.replace('/', '_')}",
|
||||
"cmd": f"cd {shlex.quote(be_path)} && npm test 2>&1 | tail -30",
|
||||
"expect": 0,
|
||||
"level": "L3",
|
||||
"category": "test",
|
||||
})
|
||||
|
||||
# Makefile test
|
||||
if "test" in mk_targets:
|
||||
checks.append({
|
||||
"name": "test_makefile",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && make test 2>&1 | tail -30",
|
||||
"expect": 0,
|
||||
"level": "L3",
|
||||
"category": "test",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# L4: Ops + Security
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Ops: docker compose config validation
|
||||
compose_file = None
|
||||
for cf in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
|
||||
if os.path.isfile(os.path.join(project_root, cf)):
|
||||
compose_file = cf
|
||||
break
|
||||
if compose_file:
|
||||
checks.append({
|
||||
"name": "ops_docker_compose_config",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && docker compose -f {compose_file} config --quiet 2>&1",
|
||||
"expect": 0,
|
||||
"level": "L4",
|
||||
"category": "ops",
|
||||
})
|
||||
|
||||
# Security: npm audit (root or any sub-project with package.json)
|
||||
if os.path.isfile(os.path.join(project_root, "package.json")):
|
||||
checks.append({
|
||||
"name": "security_npm_audit",
|
||||
"cmd": f"cd {shlex.quote(project_root)} && npm audit --omit=dev 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L4",
|
||||
"category": "security",
|
||||
})
|
||||
|
||||
# Security: pip-audit (if requirements.txt found)
|
||||
if _has_python_deps(project_root, backend_dir):
|
||||
py_dir = os.path.join(project_root, backend_dir) if backend_dir else project_root
|
||||
reqs_file = os.path.join(py_dir, "requirements.txt")
|
||||
if os.path.isfile(reqs_file):
|
||||
checks.append({
|
||||
"name": "security_pip_audit",
|
||||
"cmd": f"pip-audit -r {shlex.quote(reqs_file)} 2>&1 | tail -20",
|
||||
"expect": 0,
|
||||
"level": "L4",
|
||||
"category": "security",
|
||||
})
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate-level filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _filter_checks_by_gate(
|
||||
all_checks: List[Dict[str, Any]],
|
||||
gate_level: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Filter discovered checks based on gate_level.
|
||||
|
||||
implementation, quality-review → L1 + L2 (lint + build + import test)
|
||||
deployment, release → L1 + L2 + L3 (+ full test suite)
|
||||
ops-gate → L4 ops category only
|
||||
security-gate → L4 security category only
|
||||
"""
|
||||
gate_level = gate_level.lower().strip()
|
||||
|
||||
if gate_level in ("implementation", "quality-review"):
|
||||
levels = {"L1", "L2"}
|
||||
return [c for c in all_checks if c.get("level") in levels]
|
||||
elif gate_level in ("deployment", "release"):
|
||||
levels = {"L1", "L2", "L3"}
|
||||
return [c for c in all_checks if c.get("level") in levels]
|
||||
elif gate_level == "ops-gate":
|
||||
return [c for c in all_checks if c.get("category") == "ops"]
|
||||
elif gate_level == "security-gate":
|
||||
return [c for c in all_checks if c.get("category") == "security"]
|
||||
else:
|
||||
# Unknown gate: run L1 only (safe default)
|
||||
return [c for c in all_checks if c.get("level") == "L1"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check execution (generic — no hardcoded exit-code assumptions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_check(check: Dict[str, Any], project_root: str, timeout: int = 300) -> Dict[str, Any]:
|
||||
"""Run a single check, return result dict."""
|
||||
"""Run a single check, return result dict.
|
||||
|
||||
The command is already fully resolved (with absolute paths) by the
|
||||
discovery phase. We just execute it and compare the exit code to
|
||||
the expected value. No special-case exit code handling.
|
||||
"""
|
||||
name = check["name"]
|
||||
safe_project_root = shlex.quote(os.path.abspath(project_root))
|
||||
cmd = check["cmd"].format(project_root=safe_project_root)
|
||||
cmd = check["cmd"]
|
||||
expect = check.get("expect", 0)
|
||||
start = time.time()
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["bash", "-lc", cmd],
|
||||
["bash", "-lc", f"set -o pipefail; {cmd}"],
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -162,16 +557,14 @@ def _run_check(check: Dict[str, Any], project_root: str, timeout: int = 300) ->
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
stdout = redact_secrets(proc.stdout[-500:] if proc.stdout else "")
|
||||
stderr = redact_secrets(proc.stderr[-500:] if proc.stderr else "")
|
||||
detail = redact_secrets((stdout + stderr).strip()[:300]) or f"exit={proc.returncode}"
|
||||
if proc.returncode == expect:
|
||||
result = "pass"
|
||||
elif proc.returncode == 5: # ruff format: "would reformat" = 0 fixed
|
||||
result = "fail"
|
||||
else:
|
||||
result = "fail"
|
||||
detail = redact_secrets((stdout + "\n" + stderr).strip()[:300]) or f"exit={proc.returncode}"
|
||||
# Generic: pass if exit code matches expected, fail otherwise.
|
||||
# No special-case for exit code 5 or any other value.
|
||||
result = "pass" if proc.returncode == expect else "fail"
|
||||
return {
|
||||
"name": name,
|
||||
"level": check["level"],
|
||||
"level": check.get("level", "?"),
|
||||
"category": check.get("category", "?"),
|
||||
"result": result,
|
||||
"duration_ms": duration_ms,
|
||||
"detail": detail,
|
||||
@@ -180,7 +573,8 @@ def _run_check(check: Dict[str, Any], project_root: str, timeout: int = 300) ->
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"name": name,
|
||||
"level": check["level"],
|
||||
"level": check.get("level", "?"),
|
||||
"category": check.get("category", "?"),
|
||||
"result": "fail",
|
||||
"duration_ms": timeout * 1000,
|
||||
"detail": f"timeout after {timeout}s",
|
||||
@@ -189,7 +583,8 @@ def _run_check(check: Dict[str, Any], project_root: str, timeout: int = 300) ->
|
||||
except FileNotFoundError as e:
|
||||
return {
|
||||
"name": name,
|
||||
"level": check["level"],
|
||||
"level": check.get("level", "?"),
|
||||
"category": check.get("category", "?"),
|
||||
"result": "skip",
|
||||
"duration_ms": 0,
|
||||
"detail": f"command not found: {e}",
|
||||
@@ -198,7 +593,8 @@ def _run_check(check: Dict[str, Any], project_root: str, timeout: int = 300) ->
|
||||
except Exception as e: # pragma: no cover
|
||||
return {
|
||||
"name": name,
|
||||
"level": check["level"],
|
||||
"level": check.get("level", "?"),
|
||||
"category": check.get("category", "?"),
|
||||
"result": "fail",
|
||||
"duration_ms": 0,
|
||||
"detail": f"exception: {e}",
|
||||
@@ -206,6 +602,39 @@ def _run_check(check: Dict[str, Any], project_root: str, timeout: int = 300) ->
|
||||
}
|
||||
|
||||
|
||||
def _suggest_next_action(failed_checks: List[Dict[str, Any]]) -> str:
|
||||
"""Generate a human-readable next-action suggestion from failed checks.
|
||||
|
||||
Generic: uses the check category to suggest an action, not hardcoded
|
||||
check names.
|
||||
"""
|
||||
if not failed_checks:
|
||||
return "No actions needed"
|
||||
actions: List[str] = []
|
||||
for c in failed_checks:
|
||||
category = c.get("category", "")
|
||||
name = c.get("name", "unknown")
|
||||
if category == "lint":
|
||||
actions.append(f"Fix linting issues reported by {name}")
|
||||
elif category == "build":
|
||||
actions.append(f"Fix build errors reported by {name}")
|
||||
elif category == "import-test":
|
||||
actions.append(f"Fix import errors reported by {name}")
|
||||
elif category == "test":
|
||||
actions.append(f"Fix failing tests reported by {name}")
|
||||
elif category == "ops":
|
||||
actions.append(f"Fix ops issue reported by {name}")
|
||||
elif category == "security":
|
||||
actions.append(f"Fix security vulnerability reported by {name}")
|
||||
else:
|
||||
actions.append(f"Investigate {name}")
|
||||
return "; ".join(actions)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB persistence (kept from original — unchanged)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _persist_run(
|
||||
project_id: int,
|
||||
gate_level: str,
|
||||
@@ -242,28 +671,6 @@ def _persist_run(
|
||||
conn.close()
|
||||
|
||||
|
||||
def _suggest_next_action(failed_checks: List[Dict[str, Any]]) -> str:
|
||||
"""Generate a human-readable next-action suggestion from failed checks."""
|
||||
actions: List[str] = []
|
||||
for c in failed_checks:
|
||||
name = c["name"]
|
||||
if name == "L1_ruff_check":
|
||||
actions.append("Run 'ruff check --fix' to auto-fix")
|
||||
elif name == "L1_ruff_format":
|
||||
actions.append("Run 'ruff format' to fix formatting")
|
||||
elif name == "L1_mypy_strict":
|
||||
actions.append("Add type annotations manually")
|
||||
elif name == "L1_tsc_noemit":
|
||||
actions.append("Fix TypeScript errors (unused imports, type annotations)")
|
||||
elif name == "L2_vite_build":
|
||||
actions.append("Fix build errors (usually TS or import issues)")
|
||||
elif name == "L3_pytest":
|
||||
actions.append("Fix failing tests")
|
||||
else:
|
||||
actions.append(f"Investigate {name}")
|
||||
return "; ".join(actions) if actions else "No actions needed"
|
||||
|
||||
|
||||
def _fetch_last_run(project_id: int) -> Optional[Dict[str, Any]]:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
@@ -357,16 +764,37 @@ def _ensure_config(project_id: int, tech_stack: str) -> None:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_GATE_LEVELS = {
|
||||
"implementation",
|
||||
"quality-review",
|
||||
"deployment",
|
||||
"release",
|
||||
"ops-gate",
|
||||
"security-gate",
|
||||
}
|
||||
|
||||
|
||||
class QualityGate(Tool):
|
||||
"""Phase gate validation (real implementation, DB-backed).
|
||||
"""Generic phase gate validation (DB-backed).
|
||||
|
||||
Discovers checks from project config files (package.json, Makefile,
|
||||
nx.json, pyproject.toml) — no hardcoded tech-stack assumptions.
|
||||
|
||||
Actions:
|
||||
run: execute checks, persist run, return decision
|
||||
run: execute discovered checks, persist run, return decision
|
||||
show_last: return most recent run for project
|
||||
list_runs: return summary of recent runs (default 10)
|
||||
show_config: return orch_quality_config for project
|
||||
|
||||
Auto-detects tech_stack from project_root if no config exists yet.
|
||||
Gate levels:
|
||||
implementation, quality-review → lint + build + import test
|
||||
deployment, release → lint + build + import test + full test
|
||||
ops-gate → docker compose config validation
|
||||
security-gate → dependency audit (npm/pip)
|
||||
"""
|
||||
|
||||
async def execute(
|
||||
@@ -393,12 +821,14 @@ class QualityGate(Tool):
|
||||
except PluginDBError as e:
|
||||
return Response(message=json.dumps({"error": f"Database error: {e}"}), break_loop=False)
|
||||
|
||||
# Determine project_root: prefer explicit, else default to /a0/usr/workdir/<name>-check
|
||||
# Determine project_root: prefer explicit, else default to dev-projects dir
|
||||
if not project_root:
|
||||
project_root = f"/a0/usr/workdir/{name}-check"
|
||||
project_root = f"/a0/usr/workdir/dev-projects/{name}/"
|
||||
if not os.path.isdir(project_root):
|
||||
return Response(message=json.dumps({"error": f"project_root {project_root!r} does not exist; clone the repo first"}), break_loop=False)
|
||||
|
||||
# --- Non-run actions (same as original) ---
|
||||
|
||||
if action == "show_last":
|
||||
run = _fetch_last_run(project_id)
|
||||
return Response(message=json.dumps(run or {"info": "no runs yet"}, indent=2), break_loop=False)
|
||||
@@ -411,25 +841,52 @@ class QualityGate(Tool):
|
||||
cfg = _show_config(project_id)
|
||||
return Response(message=json.dumps(cfg or {"info": "no config yet"}, indent=2), break_loop=False)
|
||||
|
||||
# action == 'run' (default)
|
||||
tech_stack = _detect_tech_stack(project_root)
|
||||
if tech_stack == "unknown":
|
||||
return Response(message=json.dumps({"error": f"could not detect tech_stack in {project_root}", "project_root": project_root}), break_loop=False)
|
||||
# --- action == 'run' (default) ---
|
||||
|
||||
_ensure_config(project_id, tech_stack)
|
||||
checks_def = _get_checks_for_stack(tech_stack, gate_level)
|
||||
# Validate gate_level
|
||||
if gate_level not in VALID_GATE_LEVELS:
|
||||
return Response(message=json.dumps({
|
||||
"error": f"invalid gate_level {gate_level!r}; valid: {sorted(VALID_GATE_LEVELS)}"
|
||||
}), break_loop=False)
|
||||
|
||||
# Discover all available checks from project config files
|
||||
all_checks = _discover_checks(project_root)
|
||||
if not all_checks:
|
||||
return Response(message=json.dumps({
|
||||
"error": f"no checks discovered in {project_root}",
|
||||
"project_root": project_root,
|
||||
"hint": "ensure package.json, Makefile, nx.json, or pyproject.toml exists",
|
||||
}), break_loop=False)
|
||||
|
||||
# Filter checks for this gate level
|
||||
checks_def = _filter_checks_by_gate(all_checks, gate_level)
|
||||
if not checks_def:
|
||||
return Response(message=json.dumps({"error": f"no checks defined for tech_stack={tech_stack} gate_level={gate_level}"}), break_loop=False)
|
||||
return Response(message=json.dumps({
|
||||
"error": f"no checks match gate_level={gate_level}",
|
||||
"project_root": project_root,
|
||||
"discovered_categories": sorted({c.get("category", "?") for c in all_checks}),
|
||||
}), break_loop=False)
|
||||
|
||||
# Detect stack descriptor for config row (informational only)
|
||||
backend_dir = _detect_backend_dir(project_root)
|
||||
frontend_dir = _detect_frontend_dir(project_root)
|
||||
stack_desc = _detect_stack_descriptor(project_root, backend_dir, frontend_dir)
|
||||
_ensure_config(project_id, stack_desc)
|
||||
|
||||
if dry_run:
|
||||
return Response(message=json.dumps({
|
||||
"project": name,
|
||||
"project_id": project_id,
|
||||
"tech_stack": tech_stack,
|
||||
"tech_stack": stack_desc,
|
||||
"gate_level": gate_level,
|
||||
"would_run": [c["name"] for c in checks_def],
|
||||
"would_run": [{
|
||||
"name": c["name"],
|
||||
"category": c.get("category", "?"),
|
||||
"level": c.get("level", "?"),
|
||||
} for c in checks_def],
|
||||
}, indent=2), break_loop=False)
|
||||
|
||||
# Execute checks
|
||||
start = time.time()
|
||||
results: List[Dict[str, Any]] = []
|
||||
for c in checks_def:
|
||||
@@ -457,7 +914,7 @@ class QualityGate(Tool):
|
||||
"run_id": run_id,
|
||||
"project": name,
|
||||
"project_id": project_id,
|
||||
"tech_stack": tech_stack,
|
||||
"tech_stack": stack_desc,
|
||||
"gate_level": gate_level,
|
||||
"decision": decision,
|
||||
"checks": results,
|
||||
|
||||
Reference in New Issue
Block a user