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
arbiter/__init__.py
ADDED
arbiter/__main__.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Arbiter CLI entry point.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m arbiter analyze /path/to/repo Full analysis with persistence
|
|
5
|
+
python -m arbiter score /path/to/repo Quick score (no persistence)
|
|
6
|
+
python -m arbiter agents Agent leaderboard
|
|
7
|
+
python -m arbiter trend [--days 30] Quality trend over time
|
|
8
|
+
python -m arbiter worst [--limit 20] Worst files by quality
|
|
9
|
+
python -m arbiter commits [--agent NAME] Recent commits with scores
|
|
10
|
+
python -m arbiter serve [--port 8080] Start API + dashboard
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from arbiter.agent_registry import AgentRegistry
|
|
21
|
+
from arbiter.analyzers.base import Analyzer, Finding
|
|
22
|
+
from arbiter.analyzers.ruff_analyzer import RuffAnalyzer
|
|
23
|
+
from arbiter.analyzers.complexity_analyzer import ComplexityAnalyzer
|
|
24
|
+
from arbiter.analyzers.security_analyzer import SecurityAnalyzer
|
|
25
|
+
from arbiter.analyzers.dead_code_analyzer import DeadCodeAnalyzer
|
|
26
|
+
from arbiter.analyzers.duplication_analyzer import DuplicationAnalyzer
|
|
27
|
+
from arbiter.diff_analyzer import score_commit, score_diff
|
|
28
|
+
from arbiter.git_historian import count_loc, walk_commits
|
|
29
|
+
from arbiter.scoring import score_findings
|
|
30
|
+
from arbiter.store import Store
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_analyzers() -> list[Analyzer]:
|
|
34
|
+
"""Get all available analyzers."""
|
|
35
|
+
all_analyzers = [
|
|
36
|
+
RuffAnalyzer(),
|
|
37
|
+
ComplexityAnalyzer(),
|
|
38
|
+
SecurityAnalyzer(),
|
|
39
|
+
DeadCodeAnalyzer(),
|
|
40
|
+
DuplicationAnalyzer(),
|
|
41
|
+
]
|
|
42
|
+
available = []
|
|
43
|
+
for a in all_analyzers:
|
|
44
|
+
if a.is_available():
|
|
45
|
+
available.append(a)
|
|
46
|
+
else:
|
|
47
|
+
print(f" [skip] {a.name} not installed", file=sys.stderr)
|
|
48
|
+
return available
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run_analysis(repo_path: Path, analyzers: list[Analyzer], exclude_paths: list[str] | None = None) -> list[Finding]:
|
|
52
|
+
"""Run all analyzers against a repo."""
|
|
53
|
+
all_findings: list[Finding] = []
|
|
54
|
+
for analyzer in analyzers:
|
|
55
|
+
try:
|
|
56
|
+
findings = analyzer.analyze_repo(repo_path, exclude_paths=exclude_paths)
|
|
57
|
+
all_findings.extend(findings)
|
|
58
|
+
print(f" [{analyzer.name}] {len(findings)} findings", file=sys.stderr)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f" [{analyzer.name}] ERROR: {e}", file=sys.stderr)
|
|
61
|
+
return all_findings
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _find_git_root(path: Path) -> Path | None:
|
|
65
|
+
"""Walk up from path to find the nearest .git directory."""
|
|
66
|
+
current = path
|
|
67
|
+
while current != current.parent:
|
|
68
|
+
if (current / ".git").exists():
|
|
69
|
+
return current
|
|
70
|
+
current = current.parent
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_analyze(args: argparse.Namespace) -> None:
|
|
75
|
+
"""Full analysis: run analyzers, score, persist to store."""
|
|
76
|
+
repo_path = Path(args.repo).resolve()
|
|
77
|
+
git_root = _find_git_root(repo_path)
|
|
78
|
+
if not git_root:
|
|
79
|
+
print(f"Warning: {repo_path} is not inside a git repository. Skipping commit analysis.", file=sys.stderr)
|
|
80
|
+
|
|
81
|
+
db_path = Path(args.db) if args.db else Path("arbiter_data.db")
|
|
82
|
+
store = Store(db_path)
|
|
83
|
+
registry = AgentRegistry()
|
|
84
|
+
|
|
85
|
+
exclude_paths = [p.strip() for p in args.exclude.split(",") if p.strip()] if args.exclude else None
|
|
86
|
+
if exclude_paths:
|
|
87
|
+
print(f"Analyzing {repo_path} (excluding: {', '.join(exclude_paths)})...", file=sys.stderr)
|
|
88
|
+
else:
|
|
89
|
+
print(f"Analyzing {repo_path}...", file=sys.stderr)
|
|
90
|
+
|
|
91
|
+
# Run analyzers
|
|
92
|
+
analyzers = _get_analyzers()
|
|
93
|
+
findings = _run_analysis(repo_path, analyzers, exclude_paths=exclude_paths)
|
|
94
|
+
loc = count_loc(repo_path)
|
|
95
|
+
|
|
96
|
+
repo_name = repo_path.name
|
|
97
|
+
|
|
98
|
+
# Score
|
|
99
|
+
score = score_findings(findings, loc)
|
|
100
|
+
store.record_snapshot(score, loc, repo_name=repo_name)
|
|
101
|
+
|
|
102
|
+
# Update file-level quality
|
|
103
|
+
file_findings: dict[str, list[Finding]] = {}
|
|
104
|
+
for f in findings:
|
|
105
|
+
file_findings.setdefault(f.file_path, []).append(f)
|
|
106
|
+
for fp, ff in file_findings.items():
|
|
107
|
+
worst = max((f.severity for f in ff), key=lambda s: {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}.get(s, 0))
|
|
108
|
+
store.update_file_quality(fp, len(ff), worst, "analysis", repo_name=repo_name)
|
|
109
|
+
|
|
110
|
+
# Walk recent commits and score per-agent (requires git root)
|
|
111
|
+
commits = []
|
|
112
|
+
if git_root:
|
|
113
|
+
commits = walk_commits(git_root, max_count=args.commits, registry=registry)
|
|
114
|
+
for commit in commits:
|
|
115
|
+
# Per-commit scoring: score only the files this commit touched
|
|
116
|
+
commit_score = score_commit(git_root, commit, analyzers, exclude_paths=exclude_paths)
|
|
117
|
+
store.record_commit(
|
|
118
|
+
commit.hash, commit.timestamp, commit.agent,
|
|
119
|
+
commit.files_changed, commit.loc_added, commit.loc_removed, commit_score,
|
|
120
|
+
repo_name=repo_name,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Print summary
|
|
124
|
+
print(f"\n{'='*60}")
|
|
125
|
+
print(f" Arbiter Score: {score.overall} ({score.grade})")
|
|
126
|
+
print(f"{'='*60}")
|
|
127
|
+
print(f" Lint: {score.lint_score:5.1f}")
|
|
128
|
+
print(f" Security: {score.security_score:5.1f}")
|
|
129
|
+
print(f" Complexity: {score.complexity_score:5.1f}")
|
|
130
|
+
print(f" Findings: {score.total_findings} ({', '.join(f'{k}:{v}' for k, v in sorted(score.findings_by_severity.items()))})")
|
|
131
|
+
print(f" LOC: {loc:,}")
|
|
132
|
+
print(f" Commits: {len(commits)} analyzed")
|
|
133
|
+
print(f" Tools: {', '.join(score.findings_by_tool.keys()) or 'none'}")
|
|
134
|
+
|
|
135
|
+
# Agent leaderboard
|
|
136
|
+
board = store.get_agent_leaderboard()
|
|
137
|
+
if board:
|
|
138
|
+
print("\n Agent Leaderboard:")
|
|
139
|
+
for agent in board:
|
|
140
|
+
print(f" {agent.agent_name:12s} avg={agent.avg_score:5.1f} commits={agent.commit_count} loc={agent.total_loc:,}")
|
|
141
|
+
|
|
142
|
+
print(f"\n Data stored in {db_path}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_score(args: argparse.Namespace) -> None:
|
|
146
|
+
"""Quick score without persistence."""
|
|
147
|
+
repo_path = Path(args.repo).resolve()
|
|
148
|
+
exclude_paths = [p.strip() for p in args.exclude.split(",") if p.strip()] if args.exclude else None
|
|
149
|
+
analyzers = _get_analyzers()
|
|
150
|
+
findings = _run_analysis(repo_path, analyzers, exclude_paths=exclude_paths)
|
|
151
|
+
loc = count_loc(repo_path)
|
|
152
|
+
score = score_findings(findings, loc)
|
|
153
|
+
|
|
154
|
+
if args.json:
|
|
155
|
+
print(json.dumps({
|
|
156
|
+
"overall": score.overall,
|
|
157
|
+
"grade": score.grade,
|
|
158
|
+
"lint": score.lint_score,
|
|
159
|
+
"security": score.security_score,
|
|
160
|
+
"complexity": score.complexity_score,
|
|
161
|
+
"findings": score.total_findings,
|
|
162
|
+
"loc": loc,
|
|
163
|
+
"by_severity": score.findings_by_severity,
|
|
164
|
+
"by_tool": score.findings_by_tool,
|
|
165
|
+
}, indent=2))
|
|
166
|
+
else:
|
|
167
|
+
print(f"Score: {score.overall} ({score.grade}) | Lint: {score.lint_score} | Security: {score.security_score} | Complexity: {score.complexity_score} | Findings: {score.total_findings} | LOC: {loc:,}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_agents(args: argparse.Namespace) -> None:
|
|
171
|
+
"""Print agent leaderboard."""
|
|
172
|
+
db_path = Path(args.db) if args.db else Path("arbiter_data.db")
|
|
173
|
+
store = Store(db_path)
|
|
174
|
+
board = store.get_agent_leaderboard()
|
|
175
|
+
if not board:
|
|
176
|
+
print("No data. Run 'arbiter analyze' first.")
|
|
177
|
+
return
|
|
178
|
+
print(f"{'Agent':15s} {'Avg Score':>10s} {'Commits':>8s} {'LOC':>10s}")
|
|
179
|
+
print("-" * 47)
|
|
180
|
+
for agent in board:
|
|
181
|
+
print(f"{agent.agent_name:15s} {agent.avg_score:10.1f} {agent.commit_count:8d} {agent.total_loc:10,}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_trend(args: argparse.Namespace) -> None:
|
|
185
|
+
"""Print quality trend."""
|
|
186
|
+
db_path = Path(args.db) if args.db else Path("arbiter_data.db")
|
|
187
|
+
store = Store(db_path)
|
|
188
|
+
trend = store.get_trend(days=args.days)
|
|
189
|
+
if not trend:
|
|
190
|
+
print("No trend data. Run 'arbiter analyze' first.")
|
|
191
|
+
return
|
|
192
|
+
for point in trend:
|
|
193
|
+
print(f"{point['timestamp']} score={point['overall_score']:5.1f} "
|
|
194
|
+
f"lint={point['lint_score']:5.1f} sec={point['security_score']:5.1f} "
|
|
195
|
+
f"cx={point['complexity_score']:5.1f} findings={point['total_findings']}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_worst(args: argparse.Namespace) -> None:
|
|
199
|
+
"""Print worst files by quality."""
|
|
200
|
+
db_path = Path(args.db) if args.db else Path("arbiter_data.db")
|
|
201
|
+
store = Store(db_path)
|
|
202
|
+
worst = store.get_worst_files(limit=args.limit)
|
|
203
|
+
if not worst:
|
|
204
|
+
print("No file data. Run 'arbiter analyze' first.")
|
|
205
|
+
return
|
|
206
|
+
print(f"{'File':60s} {'Findings':>8s} {'Severity':>10s} {'Agent':>10s}")
|
|
207
|
+
print("-" * 92)
|
|
208
|
+
for f in worst:
|
|
209
|
+
print(f"{f.file_path:60s} {f.finding_count:8d} {f.worst_severity:>10s} {f.last_agent:>10s}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_audit_fleet(args: argparse.Namespace) -> None:
|
|
213
|
+
"""Audit all repos in a directory."""
|
|
214
|
+
fleet_dir = Path(args.directory).resolve()
|
|
215
|
+
if not fleet_dir.is_dir():
|
|
216
|
+
print(f"Error: {fleet_dir} is not a directory", file=sys.stderr)
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
|
|
219
|
+
db_path = Path(args.db) if args.db else Path("arbiter_fleet.db")
|
|
220
|
+
store = Store(db_path)
|
|
221
|
+
registry = AgentRegistry()
|
|
222
|
+
analyzers = _get_analyzers()
|
|
223
|
+
|
|
224
|
+
repos = sorted([d for d in fleet_dir.iterdir() if d.is_dir() and (d / ".git").exists()])
|
|
225
|
+
print(f"Found {len(repos)} git repos in {fleet_dir}", file=sys.stderr)
|
|
226
|
+
|
|
227
|
+
for i, repo_path in enumerate(repos, 1):
|
|
228
|
+
repo_name = repo_path.name
|
|
229
|
+
print(f"\n[{i}/{len(repos)}] {repo_name}", file=sys.stderr)
|
|
230
|
+
|
|
231
|
+
findings = _run_analysis(repo_path, analyzers)
|
|
232
|
+
loc = count_loc(repo_path)
|
|
233
|
+
score = score_findings(findings, loc)
|
|
234
|
+
store.record_snapshot(score, loc, repo_name=repo_name)
|
|
235
|
+
|
|
236
|
+
# File-level quality
|
|
237
|
+
file_findings: dict[str, list[Finding]] = {}
|
|
238
|
+
for f in findings:
|
|
239
|
+
file_findings.setdefault(f.file_path, []).append(f)
|
|
240
|
+
for fp, ff in file_findings.items():
|
|
241
|
+
worst = max((f.severity for f in ff), key=lambda s: {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}.get(s, 0))
|
|
242
|
+
store.update_file_quality(fp, len(ff), worst, "analysis", repo_name=repo_name)
|
|
243
|
+
|
|
244
|
+
# Commits
|
|
245
|
+
commits = walk_commits(repo_path, max_count=args.commits, registry=registry)
|
|
246
|
+
for commit in commits:
|
|
247
|
+
store.record_commit(
|
|
248
|
+
commit.hash, commit.timestamp, commit.agent,
|
|
249
|
+
commit.files_changed, commit.loc_added, commit.loc_removed, score,
|
|
250
|
+
repo_name=repo_name,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
grade = score.grade
|
|
254
|
+
print(f" Score: {score.overall} ({grade}) | {score.total_findings} findings | {loc:,} LOC", file=sys.stderr)
|
|
255
|
+
|
|
256
|
+
# Print fleet summary
|
|
257
|
+
print(f"\n{'='*70}", file=sys.stderr)
|
|
258
|
+
report = store.get_fleet_report()
|
|
259
|
+
_print_fleet_report(report)
|
|
260
|
+
print(f"\nData stored in {db_path}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_fleet_report(args: argparse.Namespace) -> None:
|
|
264
|
+
"""Print fleet quality report from existing data."""
|
|
265
|
+
db_path = Path(args.db) if args.db else Path("arbiter_fleet.db")
|
|
266
|
+
store = Store(db_path)
|
|
267
|
+
report = store.get_fleet_report()
|
|
268
|
+
if not report:
|
|
269
|
+
print("No fleet data. Run 'arbiter audit-fleet <dir>' first.")
|
|
270
|
+
return
|
|
271
|
+
_print_fleet_report(report)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _print_fleet_report(report: list[dict]) -> None:
|
|
275
|
+
"""Format and print the fleet report."""
|
|
276
|
+
def _grade(score: float) -> str:
|
|
277
|
+
if score >= 90: return "A"
|
|
278
|
+
if score >= 80: return "B"
|
|
279
|
+
if score >= 70: return "C"
|
|
280
|
+
if score >= 60: return "D"
|
|
281
|
+
return "F"
|
|
282
|
+
|
|
283
|
+
print(f"\n{'Repo':30s} {'Score':>6s} {'Grade':>6s} {'Findings':>9s} {'LOC':>8s}")
|
|
284
|
+
print("-" * 63)
|
|
285
|
+
grades = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0}
|
|
286
|
+
for r in report:
|
|
287
|
+
score = r.get("overall_score", 0) or 0
|
|
288
|
+
g = _grade(score)
|
|
289
|
+
grades[g] += 1
|
|
290
|
+
loc = r.get("total_loc", 0) or 0
|
|
291
|
+
findings = r.get("total_findings", 0) or 0
|
|
292
|
+
name = (r.get("repo_name") or "?")[:30]
|
|
293
|
+
print(f"{name:30s} {score:6.1f} {g:>6s} {findings:9d} {loc:8,}")
|
|
294
|
+
|
|
295
|
+
print(f"\nFleet: {len(report)} repos | ", end="")
|
|
296
|
+
print(" | ".join(f"{g}:{c}" for g, c in sorted(grades.items()) if c > 0))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def cmd_triage(args: argparse.Namespace) -> None:
|
|
300
|
+
"""Auto-classify repos and output actionable recommendations."""
|
|
301
|
+
db_path = Path(args.db) if args.db else Path("arbiter_fleet.db")
|
|
302
|
+
store = Store(db_path)
|
|
303
|
+
report = store.get_fleet_report()
|
|
304
|
+
if not report:
|
|
305
|
+
print("No fleet data. Run 'arbiter audit-fleet <dir>' first.")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
green, yellow, red, archive = [], [], [], []
|
|
309
|
+
|
|
310
|
+
for r in report:
|
|
311
|
+
score = r.get("overall_score", 0) or 0
|
|
312
|
+
loc = r.get("total_loc", 0) or 0
|
|
313
|
+
findings = r.get("total_findings", 0) or 0
|
|
314
|
+
name = r.get("repo_name", "?")
|
|
315
|
+
|
|
316
|
+
entry = {"name": name, "score": score, "loc": loc, "findings": findings}
|
|
317
|
+
|
|
318
|
+
if loc == 0 and findings == 0:
|
|
319
|
+
archive.append(entry)
|
|
320
|
+
elif score >= 80:
|
|
321
|
+
green.append(entry)
|
|
322
|
+
elif score >= 60:
|
|
323
|
+
if findings > 100:
|
|
324
|
+
red.append(entry)
|
|
325
|
+
else:
|
|
326
|
+
yellow.append(entry)
|
|
327
|
+
else:
|
|
328
|
+
red.append(entry)
|
|
329
|
+
|
|
330
|
+
print("=" * 70)
|
|
331
|
+
print(" ARBITER FLEET TRIAGE")
|
|
332
|
+
print("=" * 70)
|
|
333
|
+
|
|
334
|
+
print(f"\n GREEN ({len(green)} repos) — no action needed")
|
|
335
|
+
for r in sorted(green, key=lambda x: -x["score"]):
|
|
336
|
+
print(f" {r['name']:30s} {r['score']:5.1f} {r['loc']:>8,} LOC")
|
|
337
|
+
|
|
338
|
+
print(f"\n YELLOW ({len(yellow)} repos) — minor cleanup")
|
|
339
|
+
for r in sorted(yellow, key=lambda x: x["score"]):
|
|
340
|
+
print(f" {r['name']:30s} {r['score']:5.1f} {r['findings']:>5d} findings {r['loc']:>8,} LOC")
|
|
341
|
+
print(f" ACTION: ruff check --fix {r['name']}/")
|
|
342
|
+
|
|
343
|
+
print(f"\n RED ({len(red)} repos) — needs remediation or archival decision")
|
|
344
|
+
for r in sorted(red, key=lambda x: x["score"]):
|
|
345
|
+
print(f" {r['name']:30s} {r['score']:5.1f} {r['findings']:>5d} findings {r['loc']:>8,} LOC")
|
|
346
|
+
if r["findings"] > 500:
|
|
347
|
+
print(f" ACTION: ruff check --fix + manual review (high finding count)")
|
|
348
|
+
else:
|
|
349
|
+
print(f" ACTION: ruff check --fix, then re-score")
|
|
350
|
+
|
|
351
|
+
print(f"\n ARCHIVE CANDIDATES ({len(archive)} repos) — 0 LOC, no Python code")
|
|
352
|
+
for r in sorted(archive, key=lambda x: x["name"]):
|
|
353
|
+
print(f" {r['name']}")
|
|
354
|
+
print(f" ACTION: review for archival → gh repo archive hummbl-dev/<name>")
|
|
355
|
+
|
|
356
|
+
print(f"\n{'='*70}")
|
|
357
|
+
print(f" Summary: {len(green)} green | {len(yellow)} yellow | {len(red)} red | {len(archive)} archive candidates")
|
|
358
|
+
print(f"{'='*70}")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def cmd_fix(args: argparse.Namespace) -> None:
|
|
362
|
+
"""Run ruff --fix on a repo and show before/after score."""
|
|
363
|
+
repo_path = Path(args.repo).resolve()
|
|
364
|
+
if not (repo_path / ".git").exists():
|
|
365
|
+
print(f"Error: {repo_path} is not a git repository", file=sys.stderr)
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
|
|
368
|
+
analyzers = _get_analyzers()
|
|
369
|
+
ruff_only = [a for a in analyzers if a.name == "ruff"]
|
|
370
|
+
if not ruff_only:
|
|
371
|
+
print("Error: ruff not available", file=sys.stderr)
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
|
|
374
|
+
# Before score
|
|
375
|
+
findings_before = _run_analysis(repo_path, ruff_only)
|
|
376
|
+
loc = count_loc(repo_path)
|
|
377
|
+
score_before = score_findings(findings_before, loc)
|
|
378
|
+
|
|
379
|
+
print(f"\nBEFORE: {score_before.overall} ({score_before.grade}) | {score_before.total_findings} findings")
|
|
380
|
+
|
|
381
|
+
if args.dry_run:
|
|
382
|
+
# Show what would be fixed
|
|
383
|
+
import subprocess
|
|
384
|
+
result = subprocess.run(
|
|
385
|
+
["ruff", "check", "--fix", "--diff", str(repo_path)],
|
|
386
|
+
capture_output=True, text=True, timeout=120,
|
|
387
|
+
)
|
|
388
|
+
if result.stdout:
|
|
389
|
+
lines = result.stdout.strip().split("\n")
|
|
390
|
+
print(f"\nWould fix {len([l for l in lines if l.startswith('---')])} files")
|
|
391
|
+
print("(use without --dry-run to apply)")
|
|
392
|
+
else:
|
|
393
|
+
print("\nNothing to fix automatically.")
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
# Apply fixes
|
|
397
|
+
import subprocess
|
|
398
|
+
result = subprocess.run(
|
|
399
|
+
["ruff", "check", "--fix", "--unsafe-fixes", str(repo_path)],
|
|
400
|
+
capture_output=True, text=True, timeout=120,
|
|
401
|
+
)
|
|
402
|
+
print(f"\nruff --fix output: {result.stdout.strip()}" if result.stdout.strip() else "")
|
|
403
|
+
|
|
404
|
+
# After score
|
|
405
|
+
findings_after = _run_analysis(repo_path, ruff_only)
|
|
406
|
+
score_after = score_findings(findings_after, loc)
|
|
407
|
+
|
|
408
|
+
delta = score_after.overall - score_before.overall
|
|
409
|
+
print(f"AFTER: {score_after.overall} ({score_after.grade}) | {score_after.total_findings} findings")
|
|
410
|
+
print(f"DELTA: {'+' if delta >= 0 else ''}{delta:.1f} points | "
|
|
411
|
+
f"{score_before.total_findings - score_after.total_findings} findings fixed")
|
|
412
|
+
|
|
413
|
+
if score_after.total_findings > 0:
|
|
414
|
+
print(f"\nRemaining findings ({score_after.total_findings}):")
|
|
415
|
+
for f in findings_after[:10]:
|
|
416
|
+
print(f" {f.file_path}:{f.line} [{f.rule_id}] {f.message[:60]}")
|
|
417
|
+
if len(findings_after) > 10:
|
|
418
|
+
print(f" ... and {len(findings_after) - 10} more")
|
|
419
|
+
|
|
420
|
+
if not args.no_commit and delta > 0:
|
|
421
|
+
print(f"\nTo commit: cd {repo_path} && git add -A && git commit -m 'fix: auto-remediate ruff findings (Arbiter)'")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def cmd_diff(args: argparse.Namespace) -> None:
|
|
425
|
+
"""Score only files changed since base branch."""
|
|
426
|
+
repo_path = Path(args.repo).resolve()
|
|
427
|
+
exclude_paths = [p.strip() for p in args.exclude.split(",") if p.strip()] if args.exclude else None
|
|
428
|
+
analyzers = _get_analyzers()
|
|
429
|
+
|
|
430
|
+
base = args.base
|
|
431
|
+
print(f"Analyzing diff against {base}...", file=sys.stderr)
|
|
432
|
+
|
|
433
|
+
diff_score, findings, changed_files = score_diff(
|
|
434
|
+
repo_path, analyzers, base_branch=base, exclude_paths=exclude_paths,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
python_changed = [f for f in changed_files if f.endswith(".py")]
|
|
438
|
+
|
|
439
|
+
if args.json:
|
|
440
|
+
print(json.dumps({
|
|
441
|
+
"overall": diff_score.overall,
|
|
442
|
+
"grade": diff_score.grade,
|
|
443
|
+
"lint": diff_score.lint_score,
|
|
444
|
+
"security": diff_score.security_score,
|
|
445
|
+
"complexity": diff_score.complexity_score,
|
|
446
|
+
"findings": diff_score.total_findings,
|
|
447
|
+
"files_changed": len(changed_files),
|
|
448
|
+
"python_files_changed": len(python_changed),
|
|
449
|
+
"by_severity": diff_score.findings_by_severity,
|
|
450
|
+
"by_tool": diff_score.findings_by_tool,
|
|
451
|
+
"changed_files": changed_files,
|
|
452
|
+
}, indent=2))
|
|
453
|
+
else:
|
|
454
|
+
print(f"\nDiff vs {base}: {len(changed_files)} files changed ({len(python_changed)} Python)")
|
|
455
|
+
print(f"Score: {diff_score.overall} ({diff_score.grade}) | "
|
|
456
|
+
f"Lint: {diff_score.lint_score} | Security: {diff_score.security_score} | "
|
|
457
|
+
f"Complexity: {diff_score.complexity_score} | "
|
|
458
|
+
f"Findings: {diff_score.total_findings}")
|
|
459
|
+
|
|
460
|
+
if findings:
|
|
461
|
+
print(f"\nTop findings ({min(len(findings), 10)} of {len(findings)}):")
|
|
462
|
+
for f in findings[:10]:
|
|
463
|
+
print(f" {f.file_path}:{f.line} [{f.severity}] {f.rule_id}: {f.message[:60]}")
|
|
464
|
+
if len(findings) > 10:
|
|
465
|
+
print(f" ... and {len(findings) - 10} more")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def cmd_commits(args: argparse.Namespace) -> None:
|
|
469
|
+
"""Print recent commits with scores."""
|
|
470
|
+
db_path = Path(args.db) if args.db else Path("arbiter_data.db")
|
|
471
|
+
store = Store(db_path)
|
|
472
|
+
commits = store.get_recent_commits(agent=args.agent, limit=args.limit)
|
|
473
|
+
if not commits:
|
|
474
|
+
print("No commit data. Run 'arbiter analyze' first.")
|
|
475
|
+
return
|
|
476
|
+
for c in commits:
|
|
477
|
+
print(f"{c['commit_hash'][:8]} {c['agent']:12s} score={c['overall_score']:5.1f} "
|
|
478
|
+
f"+{c['loc_added']}/-{c['loc_removed']} {c['timestamp'][:16]}")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def main() -> None:
|
|
482
|
+
parser = argparse.ArgumentParser(description="Arbiter — Agent-aware code quality system")
|
|
483
|
+
parser.add_argument("--db", help="Path to SQLite database (default: arbiter_data.db)")
|
|
484
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
485
|
+
|
|
486
|
+
# analyze
|
|
487
|
+
p_analyze = subparsers.add_parser("analyze", help="Full analysis with persistence")
|
|
488
|
+
p_analyze.add_argument("repo", help="Path to git repository")
|
|
489
|
+
p_analyze.add_argument("--commits", type=int, default=100, help="Max commits to analyze")
|
|
490
|
+
p_analyze.add_argument("--exclude", type=str, default="", help="Comma-separated paths to exclude (e.g. dashboard,agents/factorio_system)")
|
|
491
|
+
|
|
492
|
+
# score
|
|
493
|
+
p_score = subparsers.add_parser("score", help="Quick score (no persistence)")
|
|
494
|
+
p_score.add_argument("repo", help="Path to directory or git repository")
|
|
495
|
+
p_score.add_argument("--json", action="store_true", help="JSON output")
|
|
496
|
+
p_score.add_argument("--exclude", type=str, default="", help="Comma-separated paths to exclude")
|
|
497
|
+
|
|
498
|
+
# diff
|
|
499
|
+
p_diff = subparsers.add_parser("diff", help="Score only files changed since base branch")
|
|
500
|
+
p_diff.add_argument("repo", help="Path to git repository")
|
|
501
|
+
p_diff.add_argument("--base", default="main", help="Base branch (default: main)")
|
|
502
|
+
p_diff.add_argument("--json", action="store_true", help="JSON output")
|
|
503
|
+
p_diff.add_argument("--exclude", type=str, default="", help="Comma-separated paths to exclude")
|
|
504
|
+
|
|
505
|
+
# agents
|
|
506
|
+
subparsers.add_parser("agents", help="Agent leaderboard")
|
|
507
|
+
|
|
508
|
+
# trend
|
|
509
|
+
p_trend = subparsers.add_parser("trend", help="Quality trend")
|
|
510
|
+
p_trend.add_argument("--days", type=int, default=30, help="Days of history")
|
|
511
|
+
|
|
512
|
+
# worst
|
|
513
|
+
p_worst = subparsers.add_parser("worst", help="Worst files")
|
|
514
|
+
p_worst.add_argument("--limit", type=int, default=20, help="Number of files")
|
|
515
|
+
|
|
516
|
+
# commits
|
|
517
|
+
p_commits = subparsers.add_parser("commits", help="Recent commits with scores")
|
|
518
|
+
p_commits.add_argument("--agent", help="Filter by agent")
|
|
519
|
+
p_commits.add_argument("--limit", type=int, default=50, help="Number of commits")
|
|
520
|
+
|
|
521
|
+
# audit-fleet
|
|
522
|
+
p_fleet = subparsers.add_parser("audit-fleet", help="Audit all repos in a directory")
|
|
523
|
+
p_fleet.add_argument("directory", help="Directory containing git repos")
|
|
524
|
+
p_fleet.add_argument("--commits", type=int, default=20, help="Max commits per repo")
|
|
525
|
+
|
|
526
|
+
# triage
|
|
527
|
+
subparsers.add_parser("triage", help="Auto-classify repos with actionable recommendations")
|
|
528
|
+
|
|
529
|
+
# fix
|
|
530
|
+
p_fix = subparsers.add_parser("fix", help="Auto-fix ruff findings and show before/after")
|
|
531
|
+
p_fix.add_argument("repo", help="Path to git repository")
|
|
532
|
+
p_fix.add_argument("--dry-run", action="store_true", help="Show what would be fixed without applying")
|
|
533
|
+
p_fix.add_argument("--no-commit", action="store_true", help="Don't suggest commit command")
|
|
534
|
+
|
|
535
|
+
# fleet-report
|
|
536
|
+
subparsers.add_parser("fleet-report", help="Print fleet quality report")
|
|
537
|
+
|
|
538
|
+
# serve
|
|
539
|
+
p_serve = subparsers.add_parser("serve", help="Start API + dashboard")
|
|
540
|
+
p_serve.add_argument("--port", type=int, default=8080, help="Port")
|
|
541
|
+
p_serve.add_argument("--host", default="0.0.0.0", help="Host")
|
|
542
|
+
|
|
543
|
+
args = parser.parse_args()
|
|
544
|
+
|
|
545
|
+
commands = {
|
|
546
|
+
"analyze": cmd_analyze,
|
|
547
|
+
"score": cmd_score,
|
|
548
|
+
"diff": cmd_diff,
|
|
549
|
+
"agents": cmd_agents,
|
|
550
|
+
"trend": cmd_trend,
|
|
551
|
+
"worst": cmd_worst,
|
|
552
|
+
"commits": cmd_commits,
|
|
553
|
+
"audit-fleet": cmd_audit_fleet,
|
|
554
|
+
"fleet-report": cmd_fleet_report,
|
|
555
|
+
"triage": cmd_triage,
|
|
556
|
+
"fix": cmd_fix,
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
handler = commands.get(args.command)
|
|
560
|
+
if handler:
|
|
561
|
+
handler(args)
|
|
562
|
+
elif args.command == "serve":
|
|
563
|
+
from arbiter.api import run_server
|
|
564
|
+
run_server(host=args.host, port=args.port, db_path=args.db)
|
|
565
|
+
else:
|
|
566
|
+
parser.print_help()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
if __name__ == "__main__":
|
|
570
|
+
main()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Agent Registry — Maps commit authors to AI agent identities.
|
|
2
|
+
|
|
3
|
+
Provides agent attribution for commits based on author email,
|
|
4
|
+
Co-Authored-By trailers, and configurable patterns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class AgentProfile:
|
|
17
|
+
"""Profile for a known agent or human contributor."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
emails: list[str] = field(default_factory=list)
|
|
21
|
+
co_author_patterns: list[str] = field(default_factory=list)
|
|
22
|
+
trust_tier: str = "verified" # verified, probation, unknown
|
|
23
|
+
quality_threshold: float = 70.0 # minimum passing score
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Default registry covering common AI agent signatures
|
|
27
|
+
_DEFAULT_AGENTS = [
|
|
28
|
+
AgentProfile(
|
|
29
|
+
name="claude",
|
|
30
|
+
emails=["noreply@anthropic.com"],
|
|
31
|
+
co_author_patterns=[r"Claude\s+(Opus|Sonnet|Haiku|Code)"],
|
|
32
|
+
trust_tier="verified",
|
|
33
|
+
quality_threshold=70.0,
|
|
34
|
+
),
|
|
35
|
+
AgentProfile(
|
|
36
|
+
name="codex",
|
|
37
|
+
emails=["codex@openai.com", "noreply@github.com"],
|
|
38
|
+
co_author_patterns=[r"Codex"],
|
|
39
|
+
trust_tier="verified",
|
|
40
|
+
quality_threshold=70.0,
|
|
41
|
+
),
|
|
42
|
+
AgentProfile(
|
|
43
|
+
name="gemini",
|
|
44
|
+
emails=["gemini@google.com"],
|
|
45
|
+
co_author_patterns=[r"Gemini"],
|
|
46
|
+
trust_tier="probation",
|
|
47
|
+
quality_threshold=80.0,
|
|
48
|
+
),
|
|
49
|
+
AgentProfile(
|
|
50
|
+
name="copilot",
|
|
51
|
+
emails=[],
|
|
52
|
+
co_author_patterns=[r"GitHub\s+Copilot"],
|
|
53
|
+
trust_tier="verified",
|
|
54
|
+
quality_threshold=70.0,
|
|
55
|
+
),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AgentRegistry:
|
|
60
|
+
"""Registry of known agents and human contributors."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, agents: list[AgentProfile] | None = None):
|
|
63
|
+
self._agents = agents if agents is not None else list(_DEFAULT_AGENTS)
|
|
64
|
+
self._email_index: dict[str, str] = {}
|
|
65
|
+
self._rebuild_index()
|
|
66
|
+
|
|
67
|
+
def _rebuild_index(self) -> None:
|
|
68
|
+
self._email_index.clear()
|
|
69
|
+
for agent in self._agents:
|
|
70
|
+
for email in agent.emails:
|
|
71
|
+
self._email_index[email.lower()] = agent.name
|
|
72
|
+
|
|
73
|
+
def add_agent(self, agent: AgentProfile) -> None:
|
|
74
|
+
self._agents.append(agent)
|
|
75
|
+
self._rebuild_index()
|
|
76
|
+
|
|
77
|
+
def identify(self, author_email: str, commit_message: str = "") -> str:
|
|
78
|
+
"""Identify the agent from commit metadata.
|
|
79
|
+
|
|
80
|
+
Checks in order:
|
|
81
|
+
1. Co-Authored-By trailer in commit message
|
|
82
|
+
2. Author email match
|
|
83
|
+
3. Default: "human"
|
|
84
|
+
"""
|
|
85
|
+
# Check Co-Authored-By patterns
|
|
86
|
+
for agent in self._agents:
|
|
87
|
+
for pattern in agent.co_author_patterns:
|
|
88
|
+
if re.search(pattern, commit_message, re.IGNORECASE):
|
|
89
|
+
return agent.name
|
|
90
|
+
|
|
91
|
+
# Check email
|
|
92
|
+
email_lower = author_email.lower()
|
|
93
|
+
if email_lower in self._email_index:
|
|
94
|
+
return self._email_index[email_lower]
|
|
95
|
+
|
|
96
|
+
return "human"
|
|
97
|
+
|
|
98
|
+
def get_profile(self, agent_name: str) -> AgentProfile | None:
|
|
99
|
+
for agent in self._agents:
|
|
100
|
+
if agent.name == agent_name:
|
|
101
|
+
return agent
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def all_agents(self) -> list[AgentProfile]:
|
|
105
|
+
return list(self._agents)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_json(cls, path: Path) -> "AgentRegistry":
|
|
109
|
+
"""Load registry from a JSON config file."""
|
|
110
|
+
data = json.loads(path.read_text())
|
|
111
|
+
agents = []
|
|
112
|
+
for entry in data.get("agents", []):
|
|
113
|
+
agents.append(AgentProfile(
|
|
114
|
+
name=entry["name"],
|
|
115
|
+
emails=entry.get("emails", []),
|
|
116
|
+
co_author_patterns=entry.get("co_author_patterns", []),
|
|
117
|
+
trust_tier=entry.get("trust_tier", "unknown"),
|
|
118
|
+
quality_threshold=entry.get("quality_threshold", 70.0),
|
|
119
|
+
))
|
|
120
|
+
return cls(agents)
|