pycodegate 0.1.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,167 @@
1
+ """Security rules: eval, exec, pickle, yaml, secrets, weak hashes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+
8
+ from pycodegate.rules.base import BaseRules
9
+ from pycodegate.types import Category, Diagnostic, Severity
10
+
11
+ # Patterns that suggest a hardcoded secret
12
+ _SECRET_VAR_PATTERNS = re.compile(
13
+ r"(api_key|apikey|secret|password|passwd|token|auth_token|private_key|"
14
+ r"access_key|secret_key|credentials)",
15
+ re.IGNORECASE,
16
+ )
17
+
18
+ # Patterns that look like actual secret values (not empty/placeholder)
19
+ _SECRET_VALUE_MIN_LENGTH = 7
20
+
21
+
22
+ class SecurityRules(BaseRules):
23
+ """Security-related checks."""
24
+
25
+ def check(self, source: str, filename: str) -> list[Diagnostic]:
26
+ tree = self._parse(source)
27
+ if tree is None:
28
+ return []
29
+
30
+ diags: list[Diagnostic] = []
31
+ diags.extend(self._check_eval_exec(tree, filename))
32
+ diags.extend(self._check_pickle(tree, filename))
33
+ diags.extend(self._check_yaml(tree, filename))
34
+ diags.extend(self._check_hardcoded_secrets(tree, filename))
35
+ diags.extend(self._check_weak_hash(tree, filename))
36
+ return diags
37
+
38
+ def _check_eval_exec(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
39
+ diags: list[Diagnostic] = []
40
+ for node in ast.walk(tree):
41
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
42
+ if node.func.id == "eval":
43
+ diags.append(
44
+ Diagnostic(
45
+ file_path=filename,
46
+ rule="no-eval",
47
+ severity=Severity.ERROR,
48
+ category=Category.SECURITY,
49
+ message="Avoid eval() — it executes arbitrary code",
50
+ help="Use ast.literal_eval() for safe parsing of literals",
51
+ line=node.lineno,
52
+ column=node.col_offset,
53
+ cost=3.0,
54
+ )
55
+ )
56
+ elif node.func.id == "exec":
57
+ diags.append(
58
+ Diagnostic(
59
+ file_path=filename,
60
+ rule="no-exec",
61
+ severity=Severity.ERROR,
62
+ category=Category.SECURITY,
63
+ message="Avoid exec() — it executes arbitrary code",
64
+ help="Refactor to avoid dynamic code execution",
65
+ line=node.lineno,
66
+ column=node.col_offset,
67
+ cost=3.0,
68
+ )
69
+ )
70
+ return diags
71
+
72
+ def _check_pickle(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
73
+ diags: list[Diagnostic] = []
74
+ for node in ast.walk(tree):
75
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
76
+ if node.func.attr in ("load", "loads") and isinstance(node.func.value, ast.Name):
77
+ if node.func.value.id == "pickle":
78
+ diags.append(
79
+ Diagnostic(
80
+ file_path=filename,
81
+ rule="no-pickle-load",
82
+ severity=Severity.ERROR,
83
+ category=Category.SECURITY,
84
+ message="pickle.load() can execute arbitrary code on untrusted data",
85
+ help="Use JSON or a safe serialization format for untrusted data",
86
+ line=node.lineno,
87
+ column=node.col_offset,
88
+ cost=3.0,
89
+ )
90
+ )
91
+ return diags
92
+
93
+ def _check_yaml(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
94
+ diags: list[Diagnostic] = []
95
+ for node in ast.walk(tree):
96
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
97
+ if (
98
+ node.func.attr == "load"
99
+ and isinstance(node.func.value, ast.Name)
100
+ and node.func.value.id == "yaml"
101
+ ):
102
+ has_loader = any(kw.arg == "Loader" for kw in node.keywords)
103
+ if not has_loader:
104
+ diags.append(
105
+ Diagnostic(
106
+ file_path=filename,
107
+ rule="no-unsafe-yaml-load",
108
+ severity=Severity.ERROR,
109
+ category=Category.SECURITY,
110
+ message="yaml.load() without Loader is unsafe — can execute arbitrary code",
111
+ help="Use yaml.safe_load() or pass Loader=yaml.SafeLoader",
112
+ line=node.lineno,
113
+ column=node.col_offset,
114
+ cost=3.0,
115
+ )
116
+ )
117
+ return diags
118
+
119
+ def _check_hardcoded_secrets(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
120
+ diags: list[Diagnostic] = []
121
+ for node in ast.walk(tree):
122
+ if not isinstance(node, ast.Assign):
123
+ continue
124
+ if not isinstance(node.value, ast.Constant) or not isinstance(node.value.value, str):
125
+ continue
126
+ if len(node.value.value) < _SECRET_VALUE_MIN_LENGTH:
127
+ continue
128
+ for target in node.targets:
129
+ if isinstance(target, ast.Name) and _SECRET_VAR_PATTERNS.search(target.id):
130
+ diags.append(
131
+ Diagnostic(
132
+ file_path=filename,
133
+ rule="no-hardcoded-secret",
134
+ severity=Severity.ERROR,
135
+ category=Category.SECURITY,
136
+ message=f"Hardcoded secret in '{target.id}' — use environment variables",
137
+ help="Use os.environ or a .env file via python-dotenv",
138
+ line=node.lineno,
139
+ column=node.col_offset,
140
+ cost=3.0,
141
+ )
142
+ )
143
+ return diags
144
+
145
+ def _check_weak_hash(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
146
+ diags: list[Diagnostic] = []
147
+ for node in ast.walk(tree):
148
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
149
+ if (
150
+ node.func.attr in ("md5", "sha1")
151
+ and isinstance(node.func.value, ast.Name)
152
+ and node.func.value.id == "hashlib"
153
+ ):
154
+ diags.append(
155
+ Diagnostic(
156
+ file_path=filename,
157
+ rule="no-weak-hash",
158
+ severity=Severity.WARNING,
159
+ category=Category.SECURITY,
160
+ message=f"hashlib.{node.func.attr}() is cryptographically weak",
161
+ help="Use hashlib.sha256() or hashlib.sha3_256() instead",
162
+ line=node.lineno,
163
+ column=node.col_offset,
164
+ cost=1.0,
165
+ )
166
+ )
167
+ return diags
@@ -0,0 +1,242 @@
1
+ """Structure rules: project-level health checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ from pycodegate.types import Category, Diagnostic, Severity
9
+
10
+
11
+ class StructureRules:
12
+ """Project-level structure health checks."""
13
+
14
+ def check_project(self, project_path: str, source_files: list[str]) -> list[Diagnostic]:
15
+ """Run all structure checks on the project."""
16
+ path = Path(project_path)
17
+ diags: list[Diagnostic] = []
18
+
19
+ diags.extend(self._check_large_files(source_files))
20
+ diags.extend(self._check_tests(path, source_files))
21
+ diags.extend(self._check_readme(path))
22
+ diags.extend(self._check_license(path))
23
+ diags.extend(self._check_gitignore(path))
24
+ diags.extend(self._check_linter_config(path))
25
+ diags.extend(self._check_type_checker(path))
26
+ diags.extend(self._check_type_coverage(source_files))
27
+
28
+ return diags
29
+
30
+ def _check_large_files(self, files: list[str]) -> list[Diagnostic]:
31
+ diags = []
32
+ for f in files:
33
+ try:
34
+ line_count = len(Path(f).read_text().splitlines())
35
+ if line_count > 1000:
36
+ diags.append(
37
+ Diagnostic(
38
+ file_path=f,
39
+ rule="structure/large-file",
40
+ severity=Severity.WARNING,
41
+ category=Category.STRUCTURE,
42
+ message=f"File is {line_count} lines (max 1000)",
43
+ help="Split into smaller, focused modules",
44
+ line=1,
45
+ cost=1.0,
46
+ )
47
+ )
48
+ except (OSError, UnicodeDecodeError):
49
+ pass
50
+ return diags
51
+
52
+ def _check_tests(self, path: Path, source_files: list[str]) -> list[Diagnostic]:
53
+ test_files = [
54
+ f
55
+ for f in source_files
56
+ if Path(f).stem.startswith("test_") or Path(f).stem.endswith("_test") or "/tests/" in f
57
+ ]
58
+ src_files = [f for f in source_files if f not in test_files]
59
+
60
+ if not test_files:
61
+ return [
62
+ Diagnostic(
63
+ file_path=str(path),
64
+ rule="structure/no-tests",
65
+ severity=Severity.WARNING,
66
+ category=Category.STRUCTURE,
67
+ message="No test files found",
68
+ help="Add tests to improve code reliability",
69
+ line=0,
70
+ cost=3.0,
71
+ )
72
+ ]
73
+
74
+ # Test ratio check
75
+ test_lines = sum(
76
+ len(Path(f).read_text().splitlines()) for f in test_files if Path(f).exists()
77
+ )
78
+ src_lines = sum(
79
+ len(Path(f).read_text().splitlines()) for f in src_files if Path(f).exists()
80
+ )
81
+
82
+ if src_lines > 0:
83
+ ratio = test_lines / src_lines
84
+ if ratio < 0.1:
85
+ return [
86
+ Diagnostic(
87
+ file_path=str(path),
88
+ rule="structure/low-test-ratio",
89
+ severity=Severity.WARNING,
90
+ category=Category.STRUCTURE,
91
+ message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
92
+ help="Add more tests to improve coverage",
93
+ line=0,
94
+ cost=2.0,
95
+ )
96
+ ]
97
+ elif ratio < 0.3:
98
+ return [
99
+ Diagnostic(
100
+ file_path=str(path),
101
+ rule="structure/low-test-ratio",
102
+ severity=Severity.WARNING,
103
+ category=Category.STRUCTURE,
104
+ message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
105
+ help="Add more tests to improve coverage",
106
+ line=0,
107
+ cost=1.0,
108
+ )
109
+ ]
110
+ return []
111
+
112
+ def _check_readme(self, path: Path) -> list[Diagnostic]:
113
+ for name in ["README.md", "README.rst", "README", "readme.md"]:
114
+ if (path / name).exists():
115
+ return []
116
+ return [
117
+ Diagnostic(
118
+ file_path=str(path),
119
+ rule="structure/no-readme",
120
+ severity=Severity.WARNING,
121
+ category=Category.STRUCTURE,
122
+ message="No README file found",
123
+ help="Add a README.md describing your project",
124
+ line=0,
125
+ cost=1.0,
126
+ )
127
+ ]
128
+
129
+ def _check_license(self, path: Path) -> list[Diagnostic]:
130
+ for name in ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "COPYING"]:
131
+ if (path / name).exists():
132
+ return []
133
+ return [
134
+ Diagnostic(
135
+ file_path=str(path),
136
+ rule="structure/no-license",
137
+ severity=Severity.WARNING,
138
+ category=Category.STRUCTURE,
139
+ message="No LICENSE file found",
140
+ help="Add a LICENSE file to clarify usage terms",
141
+ line=0,
142
+ cost=0.5,
143
+ )
144
+ ]
145
+
146
+ def _check_gitignore(self, path: Path) -> list[Diagnostic]:
147
+ if (path / ".gitignore").exists():
148
+ return []
149
+ return [
150
+ Diagnostic(
151
+ file_path=str(path),
152
+ rule="structure/no-gitignore",
153
+ severity=Severity.WARNING,
154
+ category=Category.STRUCTURE,
155
+ message="No .gitignore file found",
156
+ help="Add a .gitignore to exclude build artifacts and secrets",
157
+ line=0,
158
+ cost=0.5,
159
+ )
160
+ ]
161
+
162
+ def _check_linter_config(self, path: Path) -> list[Diagnostic]:
163
+ # Check for ruff, flake8, pylint configs
164
+ if (path / "ruff.toml").exists() or (path / ".flake8").exists():
165
+ return []
166
+ pyproject = path / "pyproject.toml"
167
+ if pyproject.exists():
168
+ content = pyproject.read_text()
169
+ if "[tool.ruff]" in content or "[tool.flake8]" in content or "[tool.pylint]" in content:
170
+ return []
171
+ return [
172
+ Diagnostic(
173
+ file_path=str(path),
174
+ rule="structure/no-linter-config",
175
+ severity=Severity.WARNING,
176
+ category=Category.STRUCTURE,
177
+ message="No linter configuration found",
178
+ help="Add ruff or flake8 config to enforce code style",
179
+ line=0,
180
+ cost=0.5,
181
+ )
182
+ ]
183
+
184
+ def _check_type_checker(self, path: Path) -> list[Diagnostic]:
185
+ if (
186
+ (path / "mypy.ini").exists()
187
+ or (path / "pyrightconfig.json").exists()
188
+ or (path / ".mypy.ini").exists()
189
+ ):
190
+ return []
191
+ pyproject = path / "pyproject.toml"
192
+ if pyproject.exists():
193
+ content = pyproject.read_text()
194
+ if "[tool.mypy]" in content or "[tool.pyright]" in content:
195
+ return []
196
+ return [
197
+ Diagnostic(
198
+ file_path=str(path),
199
+ rule="structure/no-type-checker",
200
+ severity=Severity.WARNING,
201
+ category=Category.STRUCTURE,
202
+ message="No type checker configuration found",
203
+ help="Add mypy or pyright config for type safety",
204
+ line=0,
205
+ cost=0.5,
206
+ )
207
+ ]
208
+
209
+ def _check_type_coverage(self, source_files: list[str]) -> list[Diagnostic]:
210
+ """Check what fraction of functions have type annotations."""
211
+ total_funcs = 0
212
+ annotated_funcs = 0
213
+ for f in source_files:
214
+ # Skip test files
215
+ if Path(f).stem.startswith("test_") or Path(f).stem.endswith("_test") or "/tests/" in f:
216
+ continue
217
+ try:
218
+ tree = ast.parse(Path(f).read_text())
219
+ except (SyntaxError, OSError, UnicodeDecodeError):
220
+ continue
221
+ for node in ast.walk(tree):
222
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
223
+ total_funcs += 1
224
+ if node.returns or any(a.annotation for a in node.args.args if a.arg != "self"):
225
+ annotated_funcs += 1
226
+
227
+ if total_funcs > 0:
228
+ coverage = annotated_funcs / total_funcs
229
+ if coverage < 0.5:
230
+ return [
231
+ Diagnostic(
232
+ file_path="project",
233
+ rule="structure/low-type-coverage",
234
+ severity=Severity.WARNING,
235
+ category=Category.STRUCTURE,
236
+ message=f"Only {coverage:.0%} of functions have type annotations (< 50%)",
237
+ help="Add return type and parameter annotations to functions",
238
+ line=0,
239
+ cost=1.0,
240
+ )
241
+ ]
242
+ return []
pycodegate/scan.py ADDED
@@ -0,0 +1,201 @@
1
+ """Scan orchestration: runs lint + dead code in parallel, produces ScanResult."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from pycodegate.discover import discover_project
12
+ from pycodegate.profile import PROFILES, detect_profile
13
+ from pycodegate.rules import get_all_rule_sets, get_framework_rules
14
+ from pycodegate.rules.dead_code import DeadCodeRules
15
+ from pycodegate.rules.dependencies import DependencyRules
16
+ from pycodegate.rules.imports import ImportsRules
17
+ from pycodegate.rules.structure import StructureRules
18
+ from pycodegate.score import calculate_score
19
+ from pycodegate.types import Diagnostic, ScanResult
20
+ from pycodegate.utils.diff import get_changed_files
21
+ from pycodegate.utils.file_discovery import find_python_files
22
+
23
+ if TYPE_CHECKING:
24
+ from pycodegate.config import Config
25
+
26
+
27
+ def scan_project(
28
+ project_path: str,
29
+ config: Config,
30
+ diff_base: str | None = None,
31
+ ) -> ScanResult:
32
+ """Run full scan and return results."""
33
+ start = time.monotonic()
34
+ project = discover_project(project_path)
35
+
36
+ # Resolve profile: config/CLI override takes precedence over auto-detection
37
+ if config.profile and config.profile in PROFILES:
38
+ profile = PROFILES[config.profile]
39
+ else:
40
+ profile = detect_profile(project_path)
41
+
42
+ files = _resolve_files(project_path, diff_base)
43
+ all_diags = _run_checks(files, project.framework, project_path, config)
44
+ all_diags = _apply_filters(all_diags, config, project_path, profile.suppressed_rules)
45
+ max_deduction_overrides = _build_max_deduction_overrides(
46
+ profile.max_deduction_overrides, config.max_deduction
47
+ )
48
+ score = calculate_score(all_diags, max_deduction_overrides=max_deduction_overrides)
49
+ elapsed = int((time.monotonic() - start) * 1000)
50
+
51
+ return ScanResult(
52
+ score=score,
53
+ diagnostics=all_diags,
54
+ project=project,
55
+ elapsed_ms=elapsed,
56
+ profile=profile.name,
57
+ )
58
+
59
+
60
+ def _resolve_files(project_path: str, diff_base: str | None) -> list[Path]:
61
+ """Determine which files to scan."""
62
+ if diff_base:
63
+ files = get_changed_files(project_path, diff_base)
64
+ if files is not None:
65
+ return files
66
+ return find_python_files(project_path)
67
+
68
+
69
+ def _run_checks(
70
+ files: list[Path],
71
+ framework: str | None,
72
+ project_path: str,
73
+ config: Config,
74
+ ) -> list[Diagnostic]:
75
+ """Run lint + dead code + imports + structure + dependency checks in parallel."""
76
+ str_files = [str(f) for f in files]
77
+ with ThreadPoolExecutor(max_workers=5) as executor:
78
+ lint_future = executor.submit(_run_lint, files, framework, config) if config.lint else None
79
+ dead_code_future = (
80
+ executor.submit(_run_dead_code, project_path, config) if config.dead_code else None
81
+ )
82
+ imports_future = (
83
+ executor.submit(_run_imports, project_path, str_files) if str_files else None
84
+ )
85
+ structure_future = (
86
+ executor.submit(_run_structure, project_path, str_files)
87
+ if config.lint and str_files
88
+ else None
89
+ )
90
+ dependencies_future = executor.submit(_run_dependencies, project_path)
91
+
92
+ lint_diags = lint_future.result() if lint_future else []
93
+ dead_code_diags = dead_code_future.result() if dead_code_future else []
94
+ imports_diags = imports_future.result() if imports_future else []
95
+ structure_diags = structure_future.result() if structure_future else []
96
+ dependencies_diags = dependencies_future.result()
97
+
98
+ return lint_diags + dead_code_diags + imports_diags + structure_diags + dependencies_diags
99
+
100
+
101
+ def _apply_filters(
102
+ diags: list[Diagnostic],
103
+ config: Config,
104
+ project_path: str = ".",
105
+ suppressed_rules: frozenset[str] | None = None,
106
+ ) -> list[Diagnostic]:
107
+ """Apply ignore_rules, profile suppressed_rules, ignore_files, and per_file_suppress filters."""
108
+ combined_ignore = set(config.ignore_rules)
109
+ if suppressed_rules:
110
+ combined_ignore |= suppressed_rules
111
+ if combined_ignore:
112
+ diags = [d for d in diags if d.rule not in combined_ignore]
113
+ root = Path(project_path).resolve()
114
+ if config.ignore_files:
115
+ diags = [d for d in diags if not _matches_ignore(d.file_path, config.ignore_files, root)]
116
+ if config.per_file_suppress:
117
+ diags = [
118
+ d for d in diags if not _matches_per_file_suppress(d, config.per_file_suppress, root)
119
+ ]
120
+ return diags
121
+
122
+
123
+ def _matches_per_file_suppress(
124
+ d: Diagnostic, per_file_suppress: dict[str, list[str]], root: Path
125
+ ) -> bool:
126
+ """Check if a diagnostic is suppressed by per_file_suppress config."""
127
+ p = Path(d.file_path)
128
+ try:
129
+ rel = str(p.resolve().relative_to(root))
130
+ except ValueError:
131
+ rel = d.file_path
132
+ return any(
133
+ d.rule in rules and (fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(d.file_path, pat))
134
+ for pat, rules in per_file_suppress.items()
135
+ )
136
+
137
+
138
+ def _build_max_deduction_overrides(
139
+ profile_overrides: dict[str, int], config_overrides: dict[str, int]
140
+ ) -> dict[str, int] | None:
141
+ """Merge profile and config max_deduction overrides, config takes precedence.
142
+
143
+ Profile overrides use title-case keys (e.g. "Security").
144
+ Config overrides use lowercase keys (e.g. "security") which are title-cased here.
145
+ """
146
+ merged: dict[str, int] = dict(profile_overrides)
147
+ for key, val in config_overrides.items():
148
+ merged[key.title()] = val
149
+ return merged or None
150
+
151
+
152
+ def _matches_ignore(file_path: str, patterns: list[str], root: Path) -> bool:
153
+ """Check if a file path matches any ignore pattern (absolute or relative)."""
154
+ p = Path(file_path)
155
+ # Try relative path from project root
156
+ try:
157
+ rel = str(p.resolve().relative_to(root))
158
+ except ValueError:
159
+ rel = file_path
160
+ return any(fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(file_path, pat) for pat in patterns)
161
+
162
+
163
+ def _run_lint(
164
+ files: list[Path],
165
+ framework: str | None,
166
+ config: Config,
167
+ ) -> list[Diagnostic]:
168
+ """Run all rule sets against all files."""
169
+ rule_sets = get_all_rule_sets() + get_framework_rules(framework)
170
+ diags: list[Diagnostic] = []
171
+
172
+ for file_path in files:
173
+ try:
174
+ source = file_path.read_text(encoding="utf-8", errors="ignore")
175
+ except (OSError, UnicodeDecodeError):
176
+ continue
177
+
178
+ for rules in rule_sets:
179
+ diags.extend(rules.check(source, str(file_path)))
180
+
181
+ return diags
182
+
183
+
184
+ def _run_dead_code(project_path: str, config: Config) -> list[Diagnostic]:
185
+ """Run dead code detection."""
186
+ return DeadCodeRules().check_project(project_path)
187
+
188
+
189
+ def _run_imports(project_path: str, source_files: list[str]) -> list[Diagnostic]:
190
+ """Run circular import detection."""
191
+ return ImportsRules().check_project(project_path, source_files)
192
+
193
+
194
+ def _run_structure(project_path: str, source_files: list[str]) -> list[Diagnostic]:
195
+ """Run project-level structure checks."""
196
+ return StructureRules().check_project(project_path, source_files)
197
+
198
+
199
+ def _run_dependencies(project_path: str) -> list[Diagnostic]:
200
+ """Run dependency vulnerability checks."""
201
+ return DependencyRules().check_project(project_path)
pycodegate/score.py ADDED
@@ -0,0 +1,69 @@
1
+ """Score calculation from diagnostics using weighted category-budget scoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ from pycodegate.constants import (
8
+ CATEGORY_WEIGHTS,
9
+ FRAMEWORK_CATEGORY_MAP,
10
+ LABEL_CRITICAL,
11
+ LABEL_EXCELLENT,
12
+ LABEL_GREAT,
13
+ LABEL_NEEDS_WORK,
14
+ )
15
+ from pycodegate.types import Diagnostic, Score
16
+
17
+
18
+ def calculate_score(
19
+ diagnostics: list[Diagnostic], max_deduction_overrides: dict | None = None
20
+ ) -> Score:
21
+ """Calculate a 0-100 health score from diagnostics using category budgets.
22
+
23
+ Each category has a weight that determines its maximum deduction budget.
24
+ Within a category, the top 3 findings are counted at full cost; additional
25
+ findings apply diminishing returns (10% each) to reward fixing top issues.
26
+ """
27
+ # 1. Compute max_deduction per category from weights
28
+ total_weight = sum(CATEGORY_WEIGHTS.values())
29
+ max_deductions = {cat: round(w / total_weight * 100) for cat, w in CATEGORY_WEIGHTS.items()}
30
+
31
+ # Fix rounding: adjust highest-weight category so sum == 100
32
+ diff = 100 - sum(max_deductions.values())
33
+ if diff != 0:
34
+ highest = max(CATEGORY_WEIGHTS, key=CATEGORY_WEIGHTS.get)
35
+ max_deductions[highest] += diff
36
+
37
+ # Apply overrides
38
+ if max_deduction_overrides:
39
+ for cat, val in max_deduction_overrides.items():
40
+ if cat in max_deductions:
41
+ max_deductions[cat] = val
42
+
43
+ # 2. Group diagnostics by resolved category
44
+ by_category: dict = defaultdict(list)
45
+ for d in diagnostics:
46
+ resolved = FRAMEWORK_CATEGORY_MAP.get(d.category, d.category)
47
+ by_category[resolved].append(d)
48
+
49
+ # 3. Calculate deduction per category with diminishing returns
50
+ total_deduction = 0.0
51
+ for cat, diags in by_category.items():
52
+ costs = sorted([d.cost for d in diags], reverse=True)
53
+ # Top 3 at full cost, rest at 10%
54
+ cat_total = sum(c if i < 3 else c * 0.1 for i, c in enumerate(costs))
55
+ cap = max_deductions.get(cat, 10)
56
+ total_deduction += min(cat_total, cap)
57
+
58
+ value = max(0, round(100 - total_deduction))
59
+
60
+ if value >= 90:
61
+ label = LABEL_EXCELLENT
62
+ elif value >= 75:
63
+ label = LABEL_GREAT
64
+ elif value >= 50:
65
+ label = LABEL_NEEDS_WORK
66
+ else:
67
+ label = LABEL_CRITICAL
68
+
69
+ return Score(value=value, label=label)