Fix 14 tool bugs: capability_check tool_name, quality_gate pipefail, 12 doc param fixes, 3 test-suite fixes

This commit is contained in:
Software Orchestrator
2026-06-17 23:33:57 +00:00
parent 53758f0755
commit 2343f0e717
24 changed files with 829 additions and 166 deletions
+594 -137
View File
@@ -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,