927 lines
34 KiB
Python
927 lines
34 KiB
Python
"""Tool: generic phase gate validation (DB-backed).
|
|
|
|
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'
|
|
Default: 'run'
|
|
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'
|
|
dry_run (bool): if True, list checks that would run without executing
|
|
Default: False
|
|
|
|
Returns:
|
|
JSON string with decision, checks, blockers, next_action.
|
|
Persists to orch_quality_runs (one row per run).
|
|
For action=show_last: returns last run's full record.
|
|
For action=list_runs: returns summary of recent runs.
|
|
For action=show_config: returns orch_quality_config row.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import shlex
|
|
import time
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from helpers.tool import Tool, Response
|
|
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
|
DB_PATH,
|
|
get_project_id,
|
|
get_kv,
|
|
set_kv,
|
|
)
|
|
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
|
|
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:
|
|
return os.path.basename(project_root.rstrip("/"))
|
|
return os.path.basename(os.getcwd())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Path discovery — detect backend/frontend dirs generically
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_BACKEND_CANDIDATES = ("backend", "apps/api", "apps/backend", "server", "api")
|
|
_FRONTEND_CANDIDATES = ("frontend", "apps/web", "apps/frontend", "client", "web")
|
|
|
|
|
|
def _detect_backend_dir(project_root: str) -> Optional[str]:
|
|
"""Return the backend source directory relative to project_root, or None.
|
|
|
|
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 _detect_frontend_dir(project_root: str) -> Optional[str]:
|
|
"""Return the frontend source directory relative to project_root, or None.
|
|
|
|
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]] = []
|
|
|
|
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.
|
|
|
|
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"]
|
|
cmd = check["cmd"]
|
|
expect = check.get("expect", 0)
|
|
start = time.time()
|
|
try:
|
|
proc = subprocess.run(
|
|
["bash", "-lc", f"set -o pipefail; {cmd}"],
|
|
shell=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
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 + "\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.get("level", "?"),
|
|
"category": check.get("category", "?"),
|
|
"result": result,
|
|
"duration_ms": duration_ms,
|
|
"detail": detail,
|
|
"exit_code": proc.returncode,
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
return {
|
|
"name": name,
|
|
"level": check.get("level", "?"),
|
|
"category": check.get("category", "?"),
|
|
"result": "fail",
|
|
"duration_ms": timeout * 1000,
|
|
"detail": f"timeout after {timeout}s",
|
|
"exit_code": -1,
|
|
}
|
|
except FileNotFoundError as e:
|
|
return {
|
|
"name": name,
|
|
"level": check.get("level", "?"),
|
|
"category": check.get("category", "?"),
|
|
"result": "skip",
|
|
"duration_ms": 0,
|
|
"detail": f"command not found: {e}",
|
|
"exit_code": -1,
|
|
}
|
|
except Exception as e: # pragma: no cover
|
|
return {
|
|
"name": name,
|
|
"level": check.get("level", "?"),
|
|
"category": check.get("category", "?"),
|
|
"result": "fail",
|
|
"duration_ms": 0,
|
|
"detail": f"exception: {e}",
|
|
"exit_code": -1,
|
|
}
|
|
|
|
|
|
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,
|
|
decision: str,
|
|
checks: List[Dict[str, Any]],
|
|
blockers: List[str],
|
|
next_action: str,
|
|
duration_ms: int,
|
|
triggered_by: str,
|
|
) -> int:
|
|
"""Insert a run into orch_quality_runs, return the new id."""
|
|
import sqlite3
|
|
conn = sqlite3.connect(DB_PATH)
|
|
try:
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO orch_quality_runs (project_id, gate_level, decision, checks_json, blockers_json, next_action, duration_ms, triggered_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
project_id,
|
|
gate_level,
|
|
decision,
|
|
json.dumps(checks),
|
|
json.dumps(blockers),
|
|
next_action,
|
|
duration_ms,
|
|
triggered_by,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _fetch_last_run(project_id: int) -> Optional[Dict[str, Any]]:
|
|
import sqlite3
|
|
conn = sqlite3.connect(DB_PATH)
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT id, project_id, gate_level, decision, checks_json, blockers_json, next_action, duration_ms, triggered_by, created_at FROM orch_quality_runs WHERE project_id = ? ORDER BY id DESC LIMIT 1",
|
|
(project_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
return {
|
|
"id": row[0],
|
|
"project_id": row[1],
|
|
"gate_level": row[2],
|
|
"decision": row[3],
|
|
"checks": json.loads(row[4]) if row[4] else [],
|
|
"blockers": json.loads(row[5]) if row[5] else [],
|
|
"next_action": row[6],
|
|
"duration_ms": row[7],
|
|
"triggered_by": row[8],
|
|
"created_at": row[9],
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _list_runs(project_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
|
import sqlite3
|
|
conn = sqlite3.connect(DB_PATH)
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT id, gate_level, decision, duration_ms, triggered_by, created_at FROM orch_quality_runs WHERE project_id = ? ORDER BY id DESC LIMIT ?",
|
|
(project_id, limit),
|
|
).fetchall()
|
|
return [
|
|
{
|
|
"id": r[0],
|
|
"gate_level": r[1],
|
|
"decision": r[2],
|
|
"duration_ms": r[3],
|
|
"triggered_by": r[4],
|
|
"created_at": r[5],
|
|
}
|
|
for r in rows
|
|
]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _show_config(project_id: int) -> Optional[Dict[str, Any]]:
|
|
import sqlite3
|
|
conn = sqlite3.connect(DB_PATH)
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT project_id, tech_stack, coverage_min_pct, strict_types, require_security_scan, require_openapi_check, custom_checks_json, updated_at FROM orch_quality_config WHERE project_id = ?",
|
|
(project_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
return {
|
|
"project_id": row[0],
|
|
"tech_stack": row[1],
|
|
"coverage_min_pct": row[2],
|
|
"strict_types": bool(row[3]),
|
|
"require_security_scan": bool(row[4]),
|
|
"require_openapi_check": bool(row[5]),
|
|
"custom_checks_json": row[6],
|
|
"updated_at": row[7],
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _ensure_config(project_id: int, tech_stack: str) -> None:
|
|
"""Create or update orch_quality_config for a project."""
|
|
import sqlite3
|
|
conn = sqlite3.connect(DB_PATH)
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO orch_quality_config (project_id, tech_stack, coverage_min_pct, strict_types, require_security_scan, require_openapi_check, updated_at)
|
|
VALUES (?, ?, 70, 1, 1, 0, datetime('now'))
|
|
ON CONFLICT(project_id) DO UPDATE SET
|
|
tech_stack = excluded.tech_stack,
|
|
updated_at = datetime('now')
|
|
""",
|
|
(project_id, tech_stack),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool class
|
|
# ---------------------------------------------------------------------------
|
|
|
|
VALID_GATE_LEVELS = {
|
|
"implementation",
|
|
"quality-review",
|
|
"deployment",
|
|
"release",
|
|
"ops-gate",
|
|
"security-gate",
|
|
}
|
|
|
|
|
|
class QualityGate(Tool):
|
|
"""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 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
|
|
|
|
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(
|
|
self,
|
|
project_name: str = "",
|
|
project_root: str = "",
|
|
action: str = "run",
|
|
gate_level: str = "quality-review",
|
|
triggered_by: str = "manual",
|
|
dry_run: bool = False,
|
|
**kwargs,
|
|
) -> Response:
|
|
name = _resolve_name(project_name, project_root)
|
|
if not name:
|
|
return Response(message=json.dumps({"error": "no project_name or project_root given"}), break_loop=False)
|
|
|
|
# resolve project_id (no auto-create; LookupError on unknown name)
|
|
try:
|
|
project_id = get_project_id(name)
|
|
if project_id is None:
|
|
raise LookupError(f"project '{name}' is not registered")
|
|
except (ValueError, LookupError) as e:
|
|
return Response(message=json.dumps({"error": str(e)}), break_loop=False)
|
|
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 dev-projects dir
|
|
if not project_root:
|
|
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)
|
|
|
|
if action == "list_runs":
|
|
runs = _list_runs(project_id)
|
|
return Response(message=json.dumps(runs, indent=2), break_loop=False)
|
|
|
|
if action == "show_config":
|
|
cfg = _show_config(project_id)
|
|
return Response(message=json.dumps(cfg or {"info": "no config yet"}, indent=2), break_loop=False)
|
|
|
|
# --- action == 'run' (default) ---
|
|
|
|
# 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 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": stack_desc,
|
|
"gate_level": gate_level,
|
|
"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:
|
|
results.append(_run_check(c, project_root))
|
|
duration_ms = int((time.time() - start) * 1000)
|
|
|
|
failed = [r for r in results if r["result"] == "fail"]
|
|
skipped = [r for r in results if r["result"] == "skip"]
|
|
decision = "fail" if failed else ("warn" if skipped else "pass")
|
|
blockers = [f"{r['name']}: {r['detail']}" for r in failed]
|
|
next_action = _suggest_next_action(failed)
|
|
|
|
run_id = _persist_run(
|
|
project_id=project_id,
|
|
gate_level=gate_level,
|
|
decision=decision,
|
|
checks=results,
|
|
blockers=blockers,
|
|
next_action=next_action,
|
|
duration_ms=duration_ms,
|
|
triggered_by=triggered_by,
|
|
)
|
|
|
|
summary = {
|
|
"run_id": run_id,
|
|
"project": name,
|
|
"project_id": project_id,
|
|
"tech_stack": stack_desc,
|
|
"gate_level": gate_level,
|
|
"decision": decision,
|
|
"checks": results,
|
|
"blockers": blockers,
|
|
"next_action": next_action,
|
|
"duration_ms": duration_ms,
|
|
"triggered_by": triggered_by,
|
|
}
|
|
return Response(message=json.dumps(summary, indent=2), break_loop=False)
|