arbiter-dev 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arbiter/__init__.py +3 -0
- arbiter/__main__.py +570 -0
- arbiter/agent_registry.py +120 -0
- arbiter/analyzers/__init__.py +1 -0
- arbiter/analyzers/base.py +44 -0
- arbiter/analyzers/complexity_analyzer.py +66 -0
- arbiter/analyzers/dead_code_analyzer.py +52 -0
- arbiter/analyzers/duplication_analyzer.py +96 -0
- arbiter/analyzers/ruff_analyzer.py +78 -0
- arbiter/analyzers/security_analyzer.py +64 -0
- arbiter/api.py +173 -0
- arbiter/bus_bridge.py +82 -0
- arbiter/diff_analyzer.py +161 -0
- arbiter/git_historian.py +209 -0
- arbiter/scoring.py +127 -0
- arbiter/store.py +249 -0
- arbiter_dev-0.2.0.dist-info/METADATA +216 -0
- arbiter_dev-0.2.0.dist-info/RECORD +22 -0
- arbiter_dev-0.2.0.dist-info/WHEEL +5 -0
- arbiter_dev-0.2.0.dist-info/entry_points.txt +2 -0
- arbiter_dev-0.2.0.dist-info/licenses/LICENSE +21 -0
- arbiter_dev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Arbiter analyzers — pluggable code quality analysis tools."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Base analyzer interface and shared data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Finding:
|
|
12
|
+
"""A single quality finding from any analyzer."""
|
|
13
|
+
|
|
14
|
+
file_path: str
|
|
15
|
+
line: int
|
|
16
|
+
severity: str # CRITICAL, HIGH, MEDIUM, LOW
|
|
17
|
+
rule_id: str
|
|
18
|
+
message: str
|
|
19
|
+
tool: str # which analyzer produced this
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Default paths to exclude from analysis
|
|
23
|
+
DEFAULT_EXCLUDE_PATHS = [
|
|
24
|
+
"node_modules", "__pycache__", ".venv", "venv", "dist", "build",
|
|
25
|
+
".git", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
26
|
+
"vendor", "third_party", "generated",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Analyzer(ABC):
|
|
31
|
+
"""Abstract base for code quality analyzers."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def name(self) -> str:
|
|
36
|
+
"""Unique analyzer name."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
|
|
40
|
+
"""Analyze an entire repository. Returns list of findings."""
|
|
41
|
+
|
|
42
|
+
def is_available(self) -> bool:
|
|
43
|
+
"""Check if the underlying tool is installed."""
|
|
44
|
+
return True
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Complexity Analyzer — Cyclomatic complexity via radon CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from arbiter.analyzers.base import Analyzer, Finding
|
|
10
|
+
|
|
11
|
+
# Map radon CC grades to severity
|
|
12
|
+
_GRADE_SEVERITY = {
|
|
13
|
+
"A": "LOW", # 1-5 (simple)
|
|
14
|
+
"B": "LOW", # 6-10 (well structured)
|
|
15
|
+
"C": "MEDIUM", # 11-20 (slightly complex)
|
|
16
|
+
"D": "HIGH", # 21-30 (more complex)
|
|
17
|
+
"E": "CRITICAL", # 31-40 (complex)
|
|
18
|
+
"F": "CRITICAL", # 41+ (very complex)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ComplexityAnalyzer(Analyzer):
|
|
23
|
+
"""Runs radon cc and parses JSON output."""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
return "complexity"
|
|
28
|
+
|
|
29
|
+
def is_available(self) -> bool:
|
|
30
|
+
try:
|
|
31
|
+
subprocess.run(["radon", "--version"], capture_output=True, timeout=5)
|
|
32
|
+
return True
|
|
33
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["radon", "cc", "--json", "--min", "C", str(repo_path)],
|
|
39
|
+
capture_output=True, text=True, timeout=120,
|
|
40
|
+
)
|
|
41
|
+
if not result.stdout.strip():
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(result.stdout)
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
findings: list[Finding] = []
|
|
50
|
+
for file_path, blocks in data.items():
|
|
51
|
+
if not isinstance(blocks, list):
|
|
52
|
+
continue
|
|
53
|
+
for block in blocks:
|
|
54
|
+
grade = block.get("rank", "A")
|
|
55
|
+
cc = block.get("complexity", 0)
|
|
56
|
+
name = block.get("name", "?")
|
|
57
|
+
lineno = block.get("lineno", 0)
|
|
58
|
+
findings.append(Finding(
|
|
59
|
+
file_path=file_path,
|
|
60
|
+
line=lineno,
|
|
61
|
+
severity=_GRADE_SEVERITY.get(grade, "MEDIUM"),
|
|
62
|
+
rule_id=f"CC-{grade}",
|
|
63
|
+
message=f"{name}: cyclomatic complexity {cc} (grade {grade})",
|
|
64
|
+
tool="radon",
|
|
65
|
+
))
|
|
66
|
+
return findings
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Dead Code Analyzer — Unused code detection via Vulture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from arbiter.analyzers.base import Analyzer, Finding
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeadCodeAnalyzer(Analyzer):
|
|
13
|
+
"""Runs vulture and parses text output."""
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
return "vulture"
|
|
18
|
+
|
|
19
|
+
def is_available(self) -> bool:
|
|
20
|
+
try:
|
|
21
|
+
subprocess.run(["vulture", "--version"], capture_output=True, timeout=5)
|
|
22
|
+
return True
|
|
23
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["vulture", str(repo_path), "--min-confidence", "80"],
|
|
29
|
+
capture_output=True, text=True, timeout=120,
|
|
30
|
+
)
|
|
31
|
+
if not result.stdout.strip():
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
findings: list[Finding] = []
|
|
35
|
+
# vulture output: filepath:line: message (confidence%)
|
|
36
|
+
pattern = re.compile(r"^(.+?):(\d+):\s+(.+?)\s+\((\d+)% confidence\)$")
|
|
37
|
+
for line in result.stdout.strip().split("\n"):
|
|
38
|
+
m = pattern.match(line.strip())
|
|
39
|
+
if not m:
|
|
40
|
+
continue
|
|
41
|
+
filepath, lineno, message, confidence = m.groups()
|
|
42
|
+
conf = int(confidence)
|
|
43
|
+
severity = "HIGH" if conf >= 90 else "MEDIUM"
|
|
44
|
+
findings.append(Finding(
|
|
45
|
+
file_path=filepath,
|
|
46
|
+
line=int(lineno),
|
|
47
|
+
severity=severity,
|
|
48
|
+
rule_id=f"DEAD-{conf}",
|
|
49
|
+
message=message,
|
|
50
|
+
tool="vulture",
|
|
51
|
+
))
|
|
52
|
+
return findings
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Duplication Analyzer — AST-based near-duplicate function detection.
|
|
2
|
+
|
|
3
|
+
Uses Python's ast module to normalize function bodies and detect
|
|
4
|
+
functions with identical or near-identical structure.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import hashlib
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from arbiter.analyzers.base import Analyzer, Finding
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DuplicationAnalyzer(Analyzer):
|
|
18
|
+
"""Detects duplicate functions via AST hashing."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return "duplication"
|
|
23
|
+
|
|
24
|
+
def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
|
|
25
|
+
# Collect function AST hashes across all Python files
|
|
26
|
+
hash_to_locations: dict[str, list[tuple[str, int, str]]] = defaultdict(list)
|
|
27
|
+
|
|
28
|
+
for py_file in repo_path.rglob("*.py"):
|
|
29
|
+
if _should_skip(py_file, exclude_paths):
|
|
30
|
+
continue
|
|
31
|
+
try:
|
|
32
|
+
source = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
33
|
+
tree = ast.parse(source)
|
|
34
|
+
except (SyntaxError, ValueError):
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
for node in ast.walk(tree):
|
|
38
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
39
|
+
body_hash = _hash_function_body(node)
|
|
40
|
+
if body_hash:
|
|
41
|
+
rel_path = str(py_file.relative_to(repo_path))
|
|
42
|
+
hash_to_locations[body_hash].append(
|
|
43
|
+
(rel_path, node.lineno, node.name)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Report groups with 2+ identical functions
|
|
47
|
+
findings: list[Finding] = []
|
|
48
|
+
for body_hash, locations in hash_to_locations.items():
|
|
49
|
+
if len(locations) < 2:
|
|
50
|
+
continue
|
|
51
|
+
severity = "HIGH" if len(locations) >= 3 else "MEDIUM"
|
|
52
|
+
names = ", ".join(f"{loc[2]}@{loc[0]}:{loc[1]}" for loc in locations)
|
|
53
|
+
for file_path, lineno, func_name in locations:
|
|
54
|
+
findings.append(Finding(
|
|
55
|
+
file_path=file_path,
|
|
56
|
+
line=lineno,
|
|
57
|
+
severity=severity,
|
|
58
|
+
rule_id=f"DUP-{len(locations)}",
|
|
59
|
+
message=f"Duplicate function body ({len(locations)} copies): {names}",
|
|
60
|
+
tool="duplication",
|
|
61
|
+
))
|
|
62
|
+
return findings
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _hash_function_body(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
|
|
66
|
+
"""Hash the normalized AST of a function body.
|
|
67
|
+
|
|
68
|
+
Normalizes by:
|
|
69
|
+
- Stripping all variable names (replaced with positional placeholders)
|
|
70
|
+
- Stripping docstrings
|
|
71
|
+
- Only hashing functions with 3+ statements (skip trivial functions)
|
|
72
|
+
"""
|
|
73
|
+
body = node.body
|
|
74
|
+
|
|
75
|
+
# Skip docstring if present
|
|
76
|
+
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
|
|
77
|
+
body = body[1:]
|
|
78
|
+
|
|
79
|
+
if len(body) < 5:
|
|
80
|
+
return None # Skip small functions (< 5 statements)
|
|
81
|
+
|
|
82
|
+
# Dump AST and hash it
|
|
83
|
+
try:
|
|
84
|
+
dump = ast.dump(ast.Module(body=body, type_ignores=[]))
|
|
85
|
+
return hashlib.sha256(dump.encode()).hexdigest()[:16]
|
|
86
|
+
except Exception:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _should_skip(path: Path, extra_excludes: list[str] | None = None) -> bool:
|
|
91
|
+
"""Skip test files, venvs, and other non-source directories."""
|
|
92
|
+
parts = path.parts
|
|
93
|
+
skip_dirs = {"__pycache__", ".venv", "venv", ".git", "node_modules", ".tox"}
|
|
94
|
+
if extra_excludes:
|
|
95
|
+
skip_dirs.update(extra_excludes)
|
|
96
|
+
return any(p in skip_dirs for p in parts) or path.name.startswith("test_")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Ruff Analyzer — Lint analysis via ruff CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from arbiter.analyzers.base import Analyzer, Finding
|
|
10
|
+
|
|
11
|
+
# Map ruff rule prefixes to severity levels
|
|
12
|
+
_SEVERITY_MAP = {
|
|
13
|
+
"F": "MEDIUM", # Pyflakes (unused imports, undefined names)
|
|
14
|
+
"E": "MEDIUM", # pycodestyle errors
|
|
15
|
+
"W": "LOW", # pycodestyle warnings
|
|
16
|
+
"B": "HIGH", # flake8-bugbear (likely bugs)
|
|
17
|
+
"S": "HIGH", # flake8-bandit (security)
|
|
18
|
+
"C": "MEDIUM", # conventions
|
|
19
|
+
"I": "LOW", # isort
|
|
20
|
+
"N": "LOW", # naming
|
|
21
|
+
"D": "LOW", # docstrings
|
|
22
|
+
"SIM": "MEDIUM", # simplify
|
|
23
|
+
"UP": "LOW", # pyupgrade
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _severity_for_rule(rule_id: str) -> str:
|
|
28
|
+
"""Map a ruff rule ID to a severity level."""
|
|
29
|
+
for prefix, severity in _SEVERITY_MAP.items():
|
|
30
|
+
if rule_id.startswith(prefix):
|
|
31
|
+
return severity
|
|
32
|
+
return "MEDIUM"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RuffAnalyzer(Analyzer):
|
|
36
|
+
"""Runs ruff check and parses JSON output."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
return "ruff"
|
|
41
|
+
|
|
42
|
+
def is_available(self) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
subprocess.run(["ruff", "--version"], capture_output=True, timeout=5)
|
|
45
|
+
return True
|
|
46
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
|
|
50
|
+
cmd = ["ruff", "check", "--output-format", "json"]
|
|
51
|
+
if exclude_paths:
|
|
52
|
+
for ep in exclude_paths:
|
|
53
|
+
cmd.extend(["--exclude", ep])
|
|
54
|
+
cmd.append(str(repo_path))
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
cmd, capture_output=True, text=True, timeout=120,
|
|
57
|
+
)
|
|
58
|
+
# ruff returns exit code 1 when findings exist — that's expected
|
|
59
|
+
if not result.stdout.strip():
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(result.stdout)
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
findings: list[Finding] = []
|
|
68
|
+
for item in data:
|
|
69
|
+
code = item.get("code", "")
|
|
70
|
+
findings.append(Finding(
|
|
71
|
+
file_path=item.get("filename", ""),
|
|
72
|
+
line=item.get("location", {}).get("row", 0),
|
|
73
|
+
severity=_severity_for_rule(code),
|
|
74
|
+
rule_id=code,
|
|
75
|
+
message=item.get("message", ""),
|
|
76
|
+
tool="ruff",
|
|
77
|
+
))
|
|
78
|
+
return findings
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Security Analyzer — Security findings via Bandit CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from arbiter.analyzers.base import Analyzer, Finding
|
|
10
|
+
|
|
11
|
+
# Map Bandit severity to Arbiter severity (one level up)
|
|
12
|
+
_SEVERITY_MAP = {
|
|
13
|
+
"HIGH": "CRITICAL",
|
|
14
|
+
"MEDIUM": "HIGH",
|
|
15
|
+
"LOW": "MEDIUM",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SecurityAnalyzer(Analyzer):
|
|
20
|
+
"""Runs bandit and parses JSON output."""
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def name(self) -> str:
|
|
24
|
+
return "bandit"
|
|
25
|
+
|
|
26
|
+
def is_available(self) -> bool:
|
|
27
|
+
try:
|
|
28
|
+
subprocess.run(["bandit", "--version"], capture_output=True, timeout=5)
|
|
29
|
+
return True
|
|
30
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def analyze_repo(self, repo_path: Path, exclude_paths: list[str] | None = None) -> list[Finding]:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
[
|
|
36
|
+
"bandit", "-r", str(repo_path),
|
|
37
|
+
"-f", "json",
|
|
38
|
+
"--exclude", "*/tests/*,*/__pycache__/*",
|
|
39
|
+
"-ll", # LOW and above
|
|
40
|
+
],
|
|
41
|
+
capture_output=True, text=True, timeout=600,
|
|
42
|
+
)
|
|
43
|
+
# bandit returns exit code 1 when findings exist
|
|
44
|
+
output = result.stdout.strip()
|
|
45
|
+
if not output:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(output)
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
findings: list[Finding] = []
|
|
54
|
+
for item in data.get("results", []):
|
|
55
|
+
sev = item.get("issue_severity", "LOW")
|
|
56
|
+
findings.append(Finding(
|
|
57
|
+
file_path=item.get("filename", ""),
|
|
58
|
+
line=item.get("line_number", 0),
|
|
59
|
+
severity=_SEVERITY_MAP.get(sev, "MEDIUM"),
|
|
60
|
+
rule_id=item.get("test_id", ""),
|
|
61
|
+
message=item.get("issue_text", ""),
|
|
62
|
+
tool="bandit",
|
|
63
|
+
))
|
|
64
|
+
return findings
|
arbiter/api.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Arbiter API — stdlib HTTP server with JSON endpoints.
|
|
2
|
+
|
|
3
|
+
No FastAPI, no Flask — just http.server + json.
|
|
4
|
+
|
|
5
|
+
Endpoints:
|
|
6
|
+
GET /api/score Current repo score
|
|
7
|
+
GET /api/agents Agent leaderboard
|
|
8
|
+
GET /api/trend Quality trend over time
|
|
9
|
+
GET /api/worst Worst files
|
|
10
|
+
GET /api/commits Recent commits with scores
|
|
11
|
+
GET /api/health System health
|
|
12
|
+
GET / Dashboard (HTML)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import parse_qs, urlparse
|
|
23
|
+
|
|
24
|
+
from arbiter.store import Store
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_DASHBOARD_DIR = Path(__file__).resolve().parent.parent.parent / "dashboard"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ArbiterHandler(BaseHTTPRequestHandler):
|
|
32
|
+
"""HTTP request handler for Arbiter API."""
|
|
33
|
+
|
|
34
|
+
store: Store # Set by run_server
|
|
35
|
+
|
|
36
|
+
def do_GET(self) -> None:
|
|
37
|
+
parsed = urlparse(self.path)
|
|
38
|
+
path = parsed.path.rstrip("/")
|
|
39
|
+
params = parse_qs(parsed.query)
|
|
40
|
+
|
|
41
|
+
routes = {
|
|
42
|
+
"/api/score": self._handle_score,
|
|
43
|
+
"/api/agents": self._handle_agents,
|
|
44
|
+
"/api/trend": self._handle_trend,
|
|
45
|
+
"/api/worst": self._handle_worst,
|
|
46
|
+
"/api/commits": self._handle_commits,
|
|
47
|
+
"/api/health": self._handle_health,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
handler = routes.get(path)
|
|
51
|
+
if handler:
|
|
52
|
+
handler(params)
|
|
53
|
+
elif path.startswith("/api/agents/") and path.count("/") == 4 and path.endswith("/trend"):
|
|
54
|
+
# /api/agents/{name}/trend
|
|
55
|
+
agent_name = path.split("/")[3]
|
|
56
|
+
self._handle_agent_trend(params, agent_name)
|
|
57
|
+
elif path.startswith("/api/commits/") and path.count("/") == 3:
|
|
58
|
+
# /api/commits/{hash}
|
|
59
|
+
commit_hash = path.split("/")[3]
|
|
60
|
+
self._handle_commit_detail(params, commit_hash)
|
|
61
|
+
elif path == "/api/fleet":
|
|
62
|
+
self._handle_fleet(params)
|
|
63
|
+
elif path == "" or path == "/index.html":
|
|
64
|
+
self._serve_dashboard()
|
|
65
|
+
else:
|
|
66
|
+
self._send_404()
|
|
67
|
+
|
|
68
|
+
def _handle_score(self, params: dict) -> None:
|
|
69
|
+
trend = self.store.get_trend(days=1)
|
|
70
|
+
if trend:
|
|
71
|
+
latest = trend[-1]
|
|
72
|
+
self._send_json(latest)
|
|
73
|
+
else:
|
|
74
|
+
self._send_json({"error": "No data. Run 'arbiter analyze' first."}, status=404)
|
|
75
|
+
|
|
76
|
+
def _handle_agents(self, params: dict) -> None:
|
|
77
|
+
board = self.store.get_agent_leaderboard()
|
|
78
|
+
self._send_json([
|
|
79
|
+
{
|
|
80
|
+
"agent": a.agent_name,
|
|
81
|
+
"avg_score": a.avg_score,
|
|
82
|
+
"commit_count": a.commit_count,
|
|
83
|
+
"total_loc": a.total_loc,
|
|
84
|
+
"trend": a.trend,
|
|
85
|
+
}
|
|
86
|
+
for a in board
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
def _handle_trend(self, params: dict) -> None:
|
|
90
|
+
days = int(params.get("days", ["30"])[0])
|
|
91
|
+
trend = self.store.get_trend(days=days)
|
|
92
|
+
self._send_json(trend)
|
|
93
|
+
|
|
94
|
+
def _handle_worst(self, params: dict) -> None:
|
|
95
|
+
limit = int(params.get("limit", ["20"])[0])
|
|
96
|
+
worst = self.store.get_worst_files(limit=limit)
|
|
97
|
+
self._send_json([
|
|
98
|
+
{
|
|
99
|
+
"file_path": f.file_path,
|
|
100
|
+
"finding_count": f.finding_count,
|
|
101
|
+
"worst_severity": f.worst_severity,
|
|
102
|
+
"last_agent": f.last_agent,
|
|
103
|
+
}
|
|
104
|
+
for f in worst
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
def _handle_commits(self, params: dict) -> None:
|
|
108
|
+
agent = params.get("agent", [None])[0]
|
|
109
|
+
limit = int(params.get("limit", ["50"])[0])
|
|
110
|
+
commits = self.store.get_recent_commits(agent=agent, limit=limit)
|
|
111
|
+
self._send_json(commits)
|
|
112
|
+
|
|
113
|
+
def _handle_agent_trend(self, params: dict, agent_name: str) -> None:
|
|
114
|
+
days = int(params.get("days", ["30"])[0])
|
|
115
|
+
trend = self.store.get_agent_trend(agent_name, days=days)
|
|
116
|
+
self._send_json(trend)
|
|
117
|
+
|
|
118
|
+
def _handle_fleet(self, params: dict) -> None:
|
|
119
|
+
report = self.store.get_fleet_report()
|
|
120
|
+
self._send_json(report)
|
|
121
|
+
|
|
122
|
+
def _handle_commit_detail(self, params: dict, commit_hash: str) -> None:
|
|
123
|
+
commits = self.store.get_recent_commits(limit=500)
|
|
124
|
+
match = [c for c in commits if c.get("commit_hash", "").startswith(commit_hash)]
|
|
125
|
+
if match:
|
|
126
|
+
self._send_json(match[0])
|
|
127
|
+
else:
|
|
128
|
+
self._send_json({"error": "Commit not found"}, status=404)
|
|
129
|
+
|
|
130
|
+
def _handle_health(self, params: dict) -> None:
|
|
131
|
+
self._send_json({"status": "ok", "version": "0.2.0"})
|
|
132
|
+
|
|
133
|
+
def _serve_dashboard(self) -> None:
|
|
134
|
+
index = _DASHBOARD_DIR / "index.html"
|
|
135
|
+
if not index.exists():
|
|
136
|
+
self._send_response(200, "text/html", b"<h1>Arbiter</h1><p>Dashboard not found. Place index.html in dashboard/</p>")
|
|
137
|
+
return
|
|
138
|
+
content = index.read_bytes()
|
|
139
|
+
self._send_response(200, "text/html", content)
|
|
140
|
+
|
|
141
|
+
def _send_json(self, data: Any, status: int = 200) -> None:
|
|
142
|
+
body = json.dumps(data, indent=2, default=str).encode("utf-8")
|
|
143
|
+
self._send_response(status, "application/json", body)
|
|
144
|
+
|
|
145
|
+
def _send_response(self, status: int, content_type: str, body: bytes) -> None:
|
|
146
|
+
self.send_response(status)
|
|
147
|
+
self.send_header("Content-Type", content_type)
|
|
148
|
+
self.send_header("Content-Length", str(len(body)))
|
|
149
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
150
|
+
self.end_headers()
|
|
151
|
+
self.wfile.write(body)
|
|
152
|
+
|
|
153
|
+
def _send_404(self) -> None:
|
|
154
|
+
self._send_json({"error": "Not found"}, status=404)
|
|
155
|
+
|
|
156
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
157
|
+
logger.info(format, *args)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_server(host: str = "0.0.0.0", port: int = 8080, db_path: str | None = None) -> None:
|
|
161
|
+
"""Start the Arbiter HTTP server."""
|
|
162
|
+
store = Store(db_path or "arbiter_data.db")
|
|
163
|
+
ArbiterHandler.store = store
|
|
164
|
+
|
|
165
|
+
server = HTTPServer((host, port), ArbiterHandler)
|
|
166
|
+
print(f"Arbiter dashboard: http://{host}:{port}/")
|
|
167
|
+
print(f"Arbiter API: http://{host}:{port}/api/")
|
|
168
|
+
print(f"Database: {db_path or 'arbiter_data.db'}")
|
|
169
|
+
try:
|
|
170
|
+
server.serve_forever()
|
|
171
|
+
except KeyboardInterrupt:
|
|
172
|
+
print("\nShutting down.")
|
|
173
|
+
server.shutdown()
|
arbiter/bus_bridge.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Bus Bridge — Posts quality milestones to founder-mode coordination bus.
|
|
2
|
+
|
|
3
|
+
Only used when Arbiter runs in the founder-mode context.
|
|
4
|
+
Uses subprocess to call bus_writer (no import dependency on founder-mode).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Default bus path for founder-mode
|
|
15
|
+
_DEFAULT_BUS_PATH = Path.home() / "workspace" / "founder-mode" / "_state" / "coordination" / "messages.tsv"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def post_quality_milestone(
|
|
19
|
+
score: float,
|
|
20
|
+
grade: str,
|
|
21
|
+
findings: int,
|
|
22
|
+
repo_name: str = "",
|
|
23
|
+
*,
|
|
24
|
+
bus_path: str | Path | None = None,
|
|
25
|
+
founder_mode_root: str | Path | None = None,
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Post a quality scan milestone to the coordination bus.
|
|
28
|
+
|
|
29
|
+
Returns True if the message was posted successfully.
|
|
30
|
+
"""
|
|
31
|
+
label = f"{repo_name}: " if repo_name else ""
|
|
32
|
+
message = f"Arbiter scan complete: {label}{score:.1f} ({grade}), {findings} findings"
|
|
33
|
+
return _post_bus_message("MILESTONE", message, bus_path=bus_path, founder_mode_root=founder_mode_root)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def post_quality_alert(
|
|
37
|
+
score: float,
|
|
38
|
+
threshold: float,
|
|
39
|
+
repo_name: str = "",
|
|
40
|
+
*,
|
|
41
|
+
bus_path: str | Path | None = None,
|
|
42
|
+
founder_mode_root: str | Path | None = None,
|
|
43
|
+
) -> bool:
|
|
44
|
+
"""Post a quality alert when score drops below threshold."""
|
|
45
|
+
label = f"{repo_name}: " if repo_name else ""
|
|
46
|
+
message = f"Quality alert: {label}score {score:.1f} below threshold {threshold:.1f}"
|
|
47
|
+
return _post_bus_message("STATUS", message, bus_path=bus_path, founder_mode_root=founder_mode_root)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _post_bus_message(
|
|
51
|
+
msg_type: str,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
bus_path: str | Path | None = None,
|
|
55
|
+
founder_mode_root: str | Path | None = None,
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""Post a message to the coordination bus via bus_writer."""
|
|
58
|
+
fm_root = Path(founder_mode_root) if founder_mode_root else Path.home() / "workspace" / "founder-mode"
|
|
59
|
+
|
|
60
|
+
cmd = [
|
|
61
|
+
sys.executable, "-m", "founder_mode.bus.bus_writer",
|
|
62
|
+
"arbiter", "all", msg_type, message,
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
env_additions = {}
|
|
66
|
+
if bus_path:
|
|
67
|
+
env_additions["BUS_PATH"] = str(bus_path)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
import os
|
|
71
|
+
env = {**os.environ, **env_additions}
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
cmd,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=10,
|
|
77
|
+
cwd=str(fm_root),
|
|
78
|
+
env=env,
|
|
79
|
+
)
|
|
80
|
+
return result.returncode == 0
|
|
81
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
82
|
+
return False
|