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.
@@ -0,0 +1,161 @@
1
+ """Diff Analyzer — Per-commit and per-branch quality scoring.
2
+
3
+ Scores only the files that changed, not the entire repo.
4
+ This makes agent leaderboard scores meaningful.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from arbiter.analyzers.base import Analyzer, Finding
12
+ from arbiter.git_historian import (
13
+ CommitInfo,
14
+ count_loc_for_files,
15
+ get_changed_files,
16
+ get_diff_files,
17
+ )
18
+ from arbiter.scoring import RepoScore, score_findings
19
+
20
+
21
+ def score_commit(
22
+ repo_path: Path,
23
+ commit: CommitInfo,
24
+ analyzers: list[Analyzer],
25
+ *,
26
+ exclude_paths: list[str] | None = None,
27
+ ) -> RepoScore:
28
+ """Score only the files changed in a specific commit.
29
+
30
+ Returns a RepoScore scoped to the commit's changed files.
31
+ Non-Python commits get a perfect score (100).
32
+ """
33
+ changed = get_changed_files(repo_path, commit.hash)
34
+ return _score_file_set(repo_path, changed, analyzers, exclude_paths=exclude_paths)
35
+
36
+
37
+ def score_diff(
38
+ repo_path: Path,
39
+ analyzers: list[Analyzer],
40
+ *,
41
+ base_branch: str = "main",
42
+ exclude_paths: list[str] | None = None,
43
+ ) -> tuple[RepoScore, list[Finding], list[str]]:
44
+ """Score only the files changed between base branch and HEAD.
45
+
46
+ Returns (score, findings, changed_files).
47
+ """
48
+ changed = get_diff_files(repo_path, base_branch)
49
+ python_files = [f for f in changed if f.endswith(".py")]
50
+
51
+ if not python_files:
52
+ empty_score = RepoScore(
53
+ overall=100.0,
54
+ lint_score=100.0,
55
+ security_score=100.0,
56
+ complexity_score=100.0,
57
+ total_findings=0,
58
+ )
59
+ return empty_score, [], changed
60
+
61
+ abs_files = [repo_path / f for f in python_files]
62
+ existing = [f for f in abs_files if f.exists()]
63
+ if not existing:
64
+ empty_score = RepoScore(
65
+ overall=100.0,
66
+ lint_score=100.0,
67
+ security_score=100.0,
68
+ complexity_score=100.0,
69
+ total_findings=0,
70
+ )
71
+ return empty_score, [], changed
72
+
73
+ findings = _run_analyzers_on_files(analyzers, existing, exclude_paths=exclude_paths)
74
+ loc = count_loc_for_files(existing)
75
+ score = score_findings(findings, max(loc, 1))
76
+
77
+ return score, findings, changed
78
+
79
+
80
+ def _score_file_set(
81
+ repo_path: Path,
82
+ changed_files: list[str],
83
+ analyzers: list[Analyzer],
84
+ *,
85
+ exclude_paths: list[str] | None = None,
86
+ ) -> RepoScore:
87
+ """Score a set of changed files."""
88
+ python_files = [f for f in changed_files if f.endswith(".py")]
89
+
90
+ if not python_files:
91
+ return RepoScore(
92
+ overall=100.0,
93
+ lint_score=100.0,
94
+ security_score=100.0,
95
+ complexity_score=100.0,
96
+ total_findings=0,
97
+ )
98
+
99
+ abs_files = [repo_path / f for f in python_files]
100
+ existing = [f for f in abs_files if f.exists()]
101
+ if not existing:
102
+ return RepoScore(
103
+ overall=100.0,
104
+ lint_score=100.0,
105
+ security_score=100.0,
106
+ complexity_score=100.0,
107
+ total_findings=0,
108
+ )
109
+
110
+ findings = _run_analyzers_on_files(analyzers, existing, exclude_paths=exclude_paths)
111
+ loc = count_loc_for_files(existing)
112
+ return score_findings(findings, max(loc, 1))
113
+
114
+
115
+ def _run_analyzers_on_files(
116
+ analyzers: list[Analyzer],
117
+ files: list[Path],
118
+ *,
119
+ exclude_paths: list[str] | None = None,
120
+ ) -> list[Finding]:
121
+ """Run analyzers against specific files (not full repo).
122
+
123
+ Creates a temporary analysis by running each analyzer on individual files
124
+ and filtering findings to only the target files.
125
+ """
126
+ all_findings: list[Finding] = []
127
+ file_set = {str(f) for f in files}
128
+ # Also index by just the filename for relative path matching
129
+ rel_set = {str(f.name) for f in files}
130
+
131
+ for analyzer in analyzers:
132
+ if not analyzer.is_available():
133
+ continue
134
+ for file_path in files:
135
+ try:
136
+ # Run analyzer on the file's parent directory and filter
137
+ findings = analyzer.analyze_repo(
138
+ file_path.parent,
139
+ exclude_paths=exclude_paths,
140
+ )
141
+ for f in findings:
142
+ # Match findings to our target files
143
+ if (
144
+ f.file_path in file_set
145
+ or str(Path(f.file_path).resolve()) in file_set
146
+ or Path(f.file_path).name in rel_set
147
+ ):
148
+ all_findings.append(f)
149
+ except Exception:
150
+ continue
151
+
152
+ # Deduplicate (same file + line + rule can appear if parent dirs overlap)
153
+ seen = set()
154
+ unique: list[Finding] = []
155
+ for f in all_findings:
156
+ key = (f.file_path, f.line, f.rule_id)
157
+ if key not in seen:
158
+ seen.add(key)
159
+ unique.append(f)
160
+
161
+ return unique
@@ -0,0 +1,209 @@
1
+ """Git Historian — Walks git log and extracts commit metadata with agent attribution.
2
+
3
+ Uses subprocess to call git CLI. No git library dependencies.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from arbiter.agent_registry import AgentRegistry
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class CommitInfo:
17
+ """Metadata for a single git commit."""
18
+
19
+ hash: str
20
+ author_name: str
21
+ author_email: str
22
+ timestamp: str # ISO 8601
23
+ message: str
24
+ files_changed: int
25
+ loc_added: int
26
+ loc_removed: int
27
+ agent: str # attributed agent name
28
+
29
+
30
+ # Git log format: hash|author_name|author_email|timestamp|subject
31
+ _LOG_FORMAT = "%H|%an|%ae|%aI|%s"
32
+ _LOG_SEP = "|"
33
+
34
+
35
+ def walk_commits(
36
+ repo_path: str | Path,
37
+ *,
38
+ since: str | None = None,
39
+ until: str | None = None,
40
+ max_count: int = 500,
41
+ registry: AgentRegistry | None = None,
42
+ ) -> list[CommitInfo]:
43
+ """Walk git log and return commit info with agent attribution.
44
+
45
+ Args:
46
+ repo_path: Path to the git repository.
47
+ since: ISO date string for start of range.
48
+ until: ISO date string for end of range.
49
+ max_count: Maximum commits to return.
50
+ registry: Agent registry for attribution. Uses default if None.
51
+
52
+ Returns:
53
+ List of CommitInfo, newest first.
54
+ """
55
+ if registry is None:
56
+ registry = AgentRegistry()
57
+
58
+ repo = Path(repo_path)
59
+ cmd = [
60
+ "git", "-C", str(repo), "log",
61
+ f"--format={_LOG_FORMAT}",
62
+ f"--max-count={max_count}",
63
+ ]
64
+ if since:
65
+ cmd.append(f"--since={since}")
66
+ if until:
67
+ cmd.append(f"--until={until}")
68
+
69
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
70
+ if result.returncode != 0:
71
+ return []
72
+
73
+ commits: list[CommitInfo] = []
74
+ for line in result.stdout.strip().split("\n"):
75
+ if not line:
76
+ continue
77
+ parts = line.split(_LOG_SEP, 4)
78
+ if len(parts) < 5:
79
+ continue
80
+
81
+ commit_hash, author_name, author_email, timestamp, subject = parts
82
+
83
+ # Get full message for Co-Authored-By detection
84
+ full_msg = _get_full_message(repo, commit_hash)
85
+
86
+ # Get diffstat
87
+ loc_added, loc_removed, files_changed = _get_diffstat(repo, commit_hash)
88
+
89
+ # Attribute agent
90
+ agent = registry.identify(author_email, full_msg)
91
+
92
+ commits.append(CommitInfo(
93
+ hash=commit_hash,
94
+ author_name=author_name,
95
+ author_email=author_email,
96
+ timestamp=timestamp,
97
+ message=subject,
98
+ files_changed=files_changed,
99
+ loc_added=loc_added,
100
+ loc_removed=loc_removed,
101
+ agent=agent,
102
+ ))
103
+
104
+ return commits
105
+
106
+
107
+ def _get_full_message(repo: Path, commit_hash: str) -> str:
108
+ """Get the full commit message (for Co-Authored-By trailer parsing)."""
109
+ result = subprocess.run(
110
+ ["git", "-C", str(repo), "log", "-1", "--format=%B", commit_hash],
111
+ capture_output=True, text=True, timeout=10,
112
+ )
113
+ return result.stdout if result.returncode == 0 else ""
114
+
115
+
116
+ def _get_diffstat(repo: Path, commit_hash: str) -> tuple[int, int, int]:
117
+ """Get LOC added, removed, and files changed for a commit."""
118
+ result = subprocess.run(
119
+ ["git", "-C", str(repo), "diff", "--shortstat", f"{commit_hash}~1", commit_hash],
120
+ capture_output=True, text=True, timeout=10,
121
+ )
122
+ if result.returncode != 0 or not result.stdout.strip():
123
+ return 0, 0, 0
124
+
125
+ text = result.stdout.strip()
126
+ added = removed = files = 0
127
+
128
+ import re
129
+ files_m = re.search(r"(\d+) files? changed", text)
130
+ added_m = re.search(r"(\d+) insertions?", text)
131
+ removed_m = re.search(r"(\d+) deletions?", text)
132
+
133
+ if files_m:
134
+ files = int(files_m.group(1))
135
+ if added_m:
136
+ added = int(added_m.group(1))
137
+ if removed_m:
138
+ removed = int(removed_m.group(1))
139
+
140
+ return added, removed, files
141
+
142
+
143
+ def get_changed_files(repo_path: str | Path, commit_hash: str) -> list[str]:
144
+ """Get list of files changed in a specific commit (relative paths)."""
145
+ repo = Path(repo_path)
146
+ result = subprocess.run(
147
+ ["git", "-C", str(repo), "diff", "--name-only", f"{commit_hash}~1", commit_hash],
148
+ capture_output=True, text=True, timeout=10,
149
+ )
150
+ if result.returncode != 0 or not result.stdout.strip():
151
+ return []
152
+ return [f for f in result.stdout.strip().split("\n") if f]
153
+
154
+
155
+ def get_diff_files(repo_path: str | Path, base_branch: str = "main") -> list[str]:
156
+ """Get list of files changed between base branch and HEAD (relative paths)."""
157
+ repo = Path(repo_path)
158
+ # Use merge-base to find the common ancestor
159
+ merge_base = subprocess.run(
160
+ ["git", "-C", str(repo), "merge-base", base_branch, "HEAD"],
161
+ capture_output=True, text=True, timeout=10,
162
+ )
163
+ if merge_base.returncode != 0:
164
+ # Fallback: diff directly against branch
165
+ base_ref = base_branch
166
+ else:
167
+ base_ref = merge_base.stdout.strip()
168
+
169
+ result = subprocess.run(
170
+ ["git", "-C", str(repo), "diff", "--name-only", base_ref, "HEAD"],
171
+ capture_output=True, text=True, timeout=10,
172
+ )
173
+ if result.returncode != 0 or not result.stdout.strip():
174
+ return []
175
+ return [f for f in result.stdout.strip().split("\n") if f]
176
+
177
+
178
+ def get_python_files(repo_path: str | Path) -> list[Path]:
179
+ """List all tracked Python files in the repo."""
180
+ repo = Path(repo_path)
181
+ result = subprocess.run(
182
+ ["git", "-C", str(repo), "ls-files", "*.py"],
183
+ capture_output=True, text=True, timeout=10,
184
+ )
185
+ if result.returncode != 0:
186
+ return []
187
+ return [repo / f for f in result.stdout.strip().split("\n") if f]
188
+
189
+
190
+ def count_loc(repo_path: str | Path) -> int:
191
+ """Count total lines of Python code in the repo."""
192
+ total = 0
193
+ for py_file in get_python_files(repo_path):
194
+ try:
195
+ total += sum(1 for _ in open(py_file, encoding="utf-8", errors="ignore"))
196
+ except OSError:
197
+ continue
198
+ return total
199
+
200
+
201
+ def count_loc_for_files(file_paths: list[Path]) -> int:
202
+ """Count total lines of code for a specific set of files."""
203
+ total = 0
204
+ for py_file in file_paths:
205
+ try:
206
+ total += sum(1 for _ in open(py_file, encoding="utf-8", errors="ignore"))
207
+ except OSError:
208
+ continue
209
+ return total
arbiter/scoring.py ADDED
@@ -0,0 +1,127 @@
1
+ """Scoring Engine — Deterministic, transparent, decomposable quality scoring.
2
+
3
+ Every score breaks down into: lint, security, complexity, duplication, coverage.
4
+ Same code always produces the same score.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from arbiter.analyzers.base import Finding
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class RepoScore:
16
+ """Quality score for a repository or subset of files."""
17
+
18
+ overall: float # 0-100 weighted composite
19
+ lint_score: float # 0-100
20
+ security_score: float # 0-100
21
+ complexity_score: float # 0-100
22
+ total_findings: int
23
+ findings_by_severity: dict[str, int] = field(default_factory=dict)
24
+ findings_by_tool: dict[str, int] = field(default_factory=dict)
25
+
26
+ @property
27
+ def grade(self) -> str:
28
+ if self.overall >= 90:
29
+ return "A"
30
+ if self.overall >= 80:
31
+ return "B"
32
+ if self.overall >= 70:
33
+ return "C"
34
+ if self.overall >= 60:
35
+ return "D"
36
+ return "F"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class AgentScore:
41
+ """Quality metrics attributed to a specific agent."""
42
+
43
+ agent_name: str
44
+ commit_count: int
45
+ avg_score: float
46
+ total_loc: int
47
+ trend: str = "stable" # improving, stable, declining
48
+
49
+
50
+ # Default weights (must sum to 1.0)
51
+ _DEFAULT_WEIGHTS = {
52
+ "lint": 0.35,
53
+ "security": 0.30,
54
+ "complexity": 0.35,
55
+ }
56
+
57
+
58
+ def score_findings(
59
+ findings: list[Finding],
60
+ loc: int,
61
+ *,
62
+ weights: dict[str, float] | None = None,
63
+ ) -> RepoScore:
64
+ """Score a set of findings against a codebase.
65
+
66
+ The scoring is deterministic: same findings + same LOC = same score.
67
+
68
+ Scoring formula per dimension:
69
+ score = 100 - (penalty_points / max(loc, 1)) * normalization_factor
70
+
71
+ Penalty points by severity:
72
+ CRITICAL: 50, HIGH: 20, MEDIUM: 5, LOW: 1
73
+ """
74
+ w = weights or _DEFAULT_WEIGHTS
75
+ if loc < 1:
76
+ loc = 1
77
+
78
+ severity_points = {"CRITICAL": 50, "HIGH": 20, "MEDIUM": 5, "LOW": 1}
79
+
80
+ # Categorize findings
81
+ lint_findings = [f for f in findings if f.tool in ("ruff", "vulture")]
82
+ security_findings = [f for f in findings if f.tool in ("bandit", "semgrep")]
83
+ complexity_findings = [f for f in findings if f.tool in ("radon", "complexity")]
84
+
85
+ # Score each dimension
86
+ lint_score = _dimension_score(lint_findings, loc, severity_points, 1000)
87
+ security_score = _dimension_score(security_findings, loc, severity_points, 500)
88
+ complexity_score = _dimension_score(complexity_findings, loc, severity_points, 800)
89
+
90
+ # Weighted overall
91
+ overall = (
92
+ lint_score * w.get("lint", 0.35)
93
+ + security_score * w.get("security", 0.30)
94
+ + complexity_score * w.get("complexity", 0.35)
95
+ )
96
+
97
+ # Count by severity and tool
98
+ by_severity: dict[str, int] = {}
99
+ by_tool: dict[str, int] = {}
100
+ for f in findings:
101
+ by_severity[f.severity] = by_severity.get(f.severity, 0) + 1
102
+ by_tool[f.tool] = by_tool.get(f.tool, 0) + 1
103
+
104
+ return RepoScore(
105
+ overall=round(overall, 1),
106
+ lint_score=round(lint_score, 1),
107
+ security_score=round(security_score, 1),
108
+ complexity_score=round(complexity_score, 1),
109
+ total_findings=len(findings),
110
+ findings_by_severity=by_severity,
111
+ findings_by_tool=by_tool,
112
+ )
113
+
114
+
115
+ def _dimension_score(
116
+ findings: list[Finding],
117
+ loc: int,
118
+ severity_points: dict[str, int],
119
+ normalization: int,
120
+ ) -> float:
121
+ """Score a single dimension. Returns 0-100."""
122
+ if not findings:
123
+ return 100.0
124
+
125
+ total_penalty = sum(severity_points.get(f.severity, 1) for f in findings)
126
+ score = 100.0 - (total_penalty / max(loc, 1)) * normalization
127
+ return max(0.0, min(100.0, score))