Files
a0_software_orchestrator/tools/quality_gate.py
T

927 lines
34 KiB
Python
Raw Normal View History

"""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)