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/diff_analyzer.py
ADDED
|
@@ -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
|
arbiter/git_historian.py
ADDED
|
@@ -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))
|