ghostbuster-cli 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,298 @@
1
+ """Orphan File Scanner — detects files/folders that should be in .gitignore.
2
+
3
+ Identifies large files, common build artifacts, dependency directories,
4
+ and other items that are tracked by git but shouldn't be.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ from pathlib import Path
11
+
12
+ from ghostbuster.core.models import Ghost, GhostCategory, Severity
13
+
14
+ # Directories that should almost always be in .gitignore
15
+ IGNORABLE_DIRS: dict[str, str] = {
16
+ "node_modules": "Node.js dependencies directory",
17
+ "venv": "Python virtual environment",
18
+ ".venv": "Python virtual environment",
19
+ "env": "Python virtual environment",
20
+ ".env": "Python virtual environment",
21
+ "__pycache__": "Python bytecode cache",
22
+ ".pytest_cache": "Pytest cache",
23
+ ".mypy_cache": "Mypy cache",
24
+ ".ruff_cache": "Ruff cache",
25
+ ".tox": "Tox environments",
26
+ ".nox": "Nox environments",
27
+ "dist": "Build distribution directory",
28
+ "build": "Build output directory",
29
+ ".eggs": "Python eggs directory",
30
+ "*.egg-info": "Python egg-info directory",
31
+ "htmlcov": "Coverage HTML report",
32
+ ".coverage": "Coverage data file",
33
+ ".hypothesis": "Hypothesis testing cache",
34
+ "bower_components": "Bower dependencies",
35
+ ".next": "Next.js build output",
36
+ ".nuxt": "Nuxt.js build output",
37
+ ".cache": "Generic cache directory",
38
+ ".parcel-cache": "Parcel bundler cache",
39
+ ".turbo": "Turborepo cache",
40
+ }
41
+
42
+ # File patterns that are commonly forgotten
43
+ IGNORABLE_PATTERNS: dict[str, str] = {
44
+ ".pyc": "Python bytecode",
45
+ ".pyo": "Python optimized bytecode",
46
+ ".DS_Store": "macOS metadata",
47
+ "Thumbs.db": "Windows thumbnail cache",
48
+ ".env": "Environment variables file (may contain secrets)",
49
+ ".env.local": "Local environment variables",
50
+ }
51
+
52
+ # Size threshold for flagging large files (10 MB)
53
+ LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
54
+
55
+ # File extensions that are commonly large and shouldn't be in repos
56
+ LARGE_FILE_EXTENSIONS: set[str] = {
57
+ ".h5",
58
+ ".hdf5", # ML model files
59
+ ".pkl",
60
+ ".pickle", # Pickled data
61
+ ".pt",
62
+ ".pth", # PyTorch models
63
+ ".onnx", # ONNX models
64
+ ".bin", # Generic binary
65
+ ".db",
66
+ ".sqlite", # Databases
67
+ ".sqlite3",
68
+ ".csv", # Data files (when large)
69
+ ".parquet",
70
+ ".zip",
71
+ ".tar",
72
+ ".gz", # Archives
73
+ ".tar.gz",
74
+ ".tgz",
75
+ ".rar",
76
+ ".7z",
77
+ ".mp4",
78
+ ".avi", # Video files
79
+ ".mov",
80
+ ".mkv",
81
+ ".iso", # Disk images
82
+ }
83
+
84
+
85
+ class OrphanFileScanner:
86
+ """Detects files and directories that should be in .gitignore."""
87
+
88
+ name = "orphan-file"
89
+
90
+ def scan(self, path: Path) -> list[Ghost]:
91
+ """Scan a project directory for orphan files."""
92
+ ghosts: list[Ghost] = []
93
+ gitignore_patterns = self._load_gitignore(path)
94
+
95
+ # Check for ignorable directories
96
+ ghosts.extend(self._check_ignorable_dirs(path, gitignore_patterns))
97
+
98
+ # Check for large files
99
+ ghosts.extend(self._check_large_files(path))
100
+
101
+ # Check for common forgotten files
102
+ ghosts.extend(self._check_forgotten_files(path, gitignore_patterns))
103
+
104
+ return ghosts
105
+
106
+ def _load_gitignore(self, path: Path) -> set[str]:
107
+ """Load patterns from .gitignore file."""
108
+ gitignore = path / ".gitignore"
109
+ if not gitignore.exists():
110
+ return set()
111
+
112
+ patterns: set[str] = set()
113
+ try:
114
+ for line in gitignore.read_text(encoding="utf-8").splitlines():
115
+ line = line.strip()
116
+ if line and not line.startswith("#"):
117
+ # Normalize: remove trailing slashes for directory matching
118
+ patterns.add(line.rstrip("/"))
119
+ return patterns
120
+ except OSError:
121
+ return set()
122
+
123
+ def _is_in_gitignore(self, name: str, patterns: set[str]) -> bool:
124
+ """Check if a name matches any gitignore pattern (simplified check)."""
125
+ # Simple check: exact match or glob pattern
126
+ if name in patterns:
127
+ return True
128
+ if name + "/" in patterns:
129
+ return True
130
+ if "/" + name in patterns:
131
+ return True
132
+ # Check wildcard patterns like *.pyc
133
+ for pattern in patterns:
134
+ if pattern.startswith("*") and name.endswith(pattern[1:]):
135
+ return True
136
+ if pattern.endswith("*") and name.startswith(pattern[:-1]):
137
+ return True
138
+ if pattern.endswith("/") and name == pattern[:-1]:
139
+ return True
140
+ return False
141
+
142
+ def _check_ignorable_dirs(self, path: Path, gitignore_patterns: set[str]) -> list[Ghost]:
143
+ """Check for directories that should be in .gitignore."""
144
+ ghosts: list[Ghost] = []
145
+
146
+ for item in path.iterdir():
147
+ if not item.is_dir():
148
+ continue
149
+
150
+ dir_name = item.name
151
+
152
+ # Check known ignorable directories
153
+ if dir_name in IGNORABLE_DIRS and not self._is_in_gitignore(
154
+ dir_name, gitignore_patterns
155
+ ):
156
+ # Calculate directory size for impact assessment
157
+ size = self._get_dir_size(item)
158
+ size_str = self._format_size(size)
159
+ ghosts.append(
160
+ Ghost(
161
+ category=GhostCategory.ORPHAN_FILE,
162
+ name=dir_name,
163
+ message=(
164
+ f"Directory '{dir_name}/' ({size_str}) is "
165
+ f"{IGNORABLE_DIRS[dir_name]} and should be in .gitignore"
166
+ ),
167
+ file_path=item,
168
+ severity=Severity.HIGH if size > LARGE_FILE_THRESHOLD else Severity.MEDIUM,
169
+ fixable=True,
170
+ suggestion=f"Add '{dir_name}/' to .gitignore",
171
+ )
172
+ )
173
+
174
+ # Check for egg-info directories
175
+ if dir_name.endswith(".egg-info") and not self._is_in_gitignore(
176
+ "*.egg-info", gitignore_patterns
177
+ ):
178
+ ghosts.append(
179
+ Ghost(
180
+ category=GhostCategory.ORPHAN_FILE,
181
+ name=dir_name,
182
+ message=f"Directory '{dir_name}/' is a build artifact and should be in .gitignore",
183
+ file_path=item,
184
+ severity=Severity.MEDIUM,
185
+ fixable=True,
186
+ suggestion="Add '*.egg-info/' to .gitignore",
187
+ )
188
+ )
189
+
190
+ return ghosts
191
+
192
+ def _check_large_files(self, path: Path) -> list[Ghost]:
193
+ """Check for files that exceed the size threshold."""
194
+ ghosts: list[Ghost] = []
195
+
196
+ for item in path.rglob("*"):
197
+ if not item.is_file():
198
+ continue
199
+
200
+ # Skip .git directory
201
+ try:
202
+ rel = item.relative_to(path)
203
+ except ValueError:
204
+ continue
205
+
206
+ parts = rel.parts
207
+ if any(p in {".git", "node_modules", "venv", ".venv", "__pycache__"} for p in parts):
208
+ continue
209
+
210
+ try:
211
+ size = item.stat().st_size
212
+ except OSError:
213
+ continue
214
+
215
+ # Flag files with suspicious extensions regardless of size
216
+ suffix = item.suffix.lower()
217
+ if suffix in LARGE_FILE_EXTENSIONS and size > 1024 * 1024: # > 1MB
218
+ size_str = self._format_size(size)
219
+ ghosts.append(
220
+ Ghost(
221
+ category=GhostCategory.ORPHAN_FILE,
222
+ name=item.name,
223
+ message=f"Large file '{rel}' ({size_str}) should probably not be in the repository",
224
+ file_path=item,
225
+ severity=Severity.HIGH if size > LARGE_FILE_THRESHOLD else Severity.MEDIUM,
226
+ fixable=True,
227
+ suggestion=f"Add '{item.name}' to .gitignore and use Git LFS or external storage",
228
+ )
229
+ )
230
+ elif size > LARGE_FILE_THRESHOLD:
231
+ size_str = self._format_size(size)
232
+ ghosts.append(
233
+ Ghost(
234
+ category=GhostCategory.ORPHAN_FILE,
235
+ name=item.name,
236
+ message=f"File '{rel}' ({size_str}) exceeds {self._format_size(LARGE_FILE_THRESHOLD)} threshold",
237
+ file_path=item,
238
+ severity=Severity.HIGH,
239
+ fixable=False,
240
+ suggestion=f"Consider adding '{item.name}' to .gitignore or using Git LFS",
241
+ )
242
+ )
243
+
244
+ return ghosts
245
+
246
+ def _check_forgotten_files(self, path: Path, gitignore_patterns: set[str]) -> list[Ghost]:
247
+ """Check for common files that should be gitignored."""
248
+ ghosts: list[Ghost] = []
249
+
250
+ for item in path.iterdir():
251
+ if not item.is_file():
252
+ continue
253
+
254
+ name = item.name
255
+
256
+ # Check specific filenames
257
+ if name in IGNORABLE_PATTERNS and not self._is_in_gitignore(name, gitignore_patterns):
258
+ ghosts.append(
259
+ Ghost(
260
+ category=GhostCategory.ORPHAN_FILE,
261
+ name=name,
262
+ message=f"File '{name}' ({IGNORABLE_PATTERNS[name]}) should be in .gitignore",
263
+ file_path=item,
264
+ severity=Severity.LOW
265
+ if name in {".DS_Store", "Thumbs.db"}
266
+ else Severity.MEDIUM,
267
+ fixable=True,
268
+ suggestion=f"Add '{name}' to .gitignore",
269
+ )
270
+ )
271
+
272
+ return ghosts
273
+
274
+ def _get_dir_size(self, path: Path, max_depth: int = 3) -> int:
275
+ """Calculate approximate directory size (limited depth for speed)."""
276
+ total = 0
277
+ depth = 0
278
+ try:
279
+ for item in path.rglob("*"):
280
+ if item.is_file():
281
+ with contextlib.suppress(OSError):
282
+ total += item.stat().st_size
283
+ # Simple depth tracking
284
+ depth += 1
285
+ if depth > 10000: # Safety limit
286
+ break
287
+ except OSError:
288
+ pass
289
+ return total
290
+
291
+ @staticmethod
292
+ def _format_size(size: int) -> str:
293
+ """Format a file size in human-readable format."""
294
+ for unit in ("B", "KB", "MB", "GB"):
295
+ if size < 1024:
296
+ return f"{size:.1f} {unit}"
297
+ size //= 1024
298
+ return f"{size:.1f} TB"
@@ -0,0 +1,196 @@
1
+ """Phantom Env Scanner — detects env vars referenced but never set.
2
+
3
+ Scans Python files for os.environ / os.getenv usage and checks if the
4
+ referenced environment variables are defined in .env files or the system.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from ghostbuster.core.models import Ghost, GhostCategory, Severity
15
+
16
+
17
+ class PhantomEnvScanner:
18
+ """Detects environment variables referenced in code but not set anywhere."""
19
+
20
+ name = "phantom-env"
21
+
22
+ def scan(self, path: Path) -> list[Ghost]:
23
+ """Scan a project directory for phantom environment variables."""
24
+ # Collect all env vars referenced in code
25
+ referenced = self._find_referenced_env_vars(path)
26
+ if not referenced:
27
+ return []
28
+
29
+ # Collect all env vars that are defined
30
+ defined = self._find_defined_env_vars(path)
31
+
32
+ # Find phantoms: referenced but not defined
33
+ ghosts: list[Ghost] = []
34
+ for env_var, locations in referenced.items():
35
+ if env_var not in defined:
36
+ # Use the first location for the ghost
37
+ first_file, first_line = locations[0]
38
+
39
+ locations_str = ", ".join(f"{f.name}:{l}" for f, l in locations[:3])
40
+ if len(locations) > 3:
41
+ locations_str += f" (+{len(locations) - 3} more)"
42
+
43
+ ghosts.append(
44
+ Ghost(
45
+ category=GhostCategory.PHANTOM_ENV,
46
+ name=env_var,
47
+ message=(
48
+ f"Environment variable '{env_var}' is referenced "
49
+ f"at {locations_str} but never defined"
50
+ ),
51
+ file_path=first_file,
52
+ line_number=first_line,
53
+ severity=Severity.HIGH,
54
+ fixable=True,
55
+ suggestion=f"Add '{env_var}=' to your .env file or set it in your environment",
56
+ )
57
+ )
58
+
59
+ return ghosts
60
+
61
+ def _find_referenced_env_vars(self, path: Path) -> dict[str, list[tuple[Path, int]]]:
62
+ """Find all env var references in Python files via AST + regex.
63
+
64
+ Returns mapping of env_var_name -> list of (file_path, line_number).
65
+ """
66
+ env_vars: dict[str, list[tuple[Path, int]]] = {}
67
+
68
+ for py_file in path.rglob("*.py"):
69
+ parts = py_file.relative_to(path).parts
70
+ if any(
71
+ p
72
+ in {
73
+ "venv",
74
+ ".venv",
75
+ "node_modules",
76
+ ".git",
77
+ "__pycache__",
78
+ ".tox",
79
+ ".nox",
80
+ "build",
81
+ "dist",
82
+ }
83
+ for p in parts
84
+ ):
85
+ continue
86
+
87
+ try:
88
+ source = py_file.read_text(encoding="utf-8", errors="ignore")
89
+ tree = ast.parse(source, filename=str(py_file))
90
+ except (SyntaxError, OSError):
91
+ continue
92
+
93
+ # AST-based detection
94
+ for node in ast.walk(tree):
95
+ env_name = self._extract_env_var_from_node(node)
96
+ if env_name:
97
+ env_vars.setdefault(env_name, []).append((py_file, getattr(node, "lineno", 0)))
98
+
99
+ return env_vars
100
+
101
+ def _extract_env_var_from_node(self, node: ast.AST) -> str | None:
102
+ """Extract an env var name from an AST node if it's an env access pattern.
103
+
104
+ Detects:
105
+ - os.environ["KEY"]
106
+ - os.environ.get("KEY")
107
+ - os.getenv("KEY")
108
+ """
109
+ # Pattern: os.environ["KEY"] or os.environ.get("KEY")
110
+ if (
111
+ isinstance(node, ast.Subscript)
112
+ and self._is_os_environ(node.value)
113
+ and isinstance(node.slice, ast.Constant)
114
+ and isinstance(node.slice.value, str)
115
+ ):
116
+ return node.slice.value
117
+
118
+ # Pattern: os.environ.get("KEY", ...) or os.getenv("KEY", ...)
119
+ if isinstance(node, ast.Call):
120
+ func = node.func
121
+
122
+ # os.getenv("KEY")
123
+ if (
124
+ isinstance(func, ast.Attribute)
125
+ and func.attr == "getenv"
126
+ and isinstance(func.value, ast.Name)
127
+ and func.value.id == "os"
128
+ ):
129
+ return self._get_first_str_arg(node)
130
+
131
+ # os.environ.get("KEY")
132
+ if (
133
+ isinstance(func, ast.Attribute)
134
+ and func.attr == "get"
135
+ and self._is_os_environ(func.value)
136
+ ):
137
+ return self._get_first_str_arg(node)
138
+
139
+ return None
140
+
141
+ def _is_os_environ(self, node: ast.AST) -> bool:
142
+ """Check if a node represents 'os.environ'."""
143
+ return (
144
+ isinstance(node, ast.Attribute)
145
+ and node.attr == "environ"
146
+ and isinstance(node.value, ast.Name)
147
+ and node.value.id == "os"
148
+ )
149
+
150
+ def _get_first_str_arg(self, node: ast.Call) -> str | None:
151
+ """Get the first string argument from a call node."""
152
+ if (
153
+ node.args
154
+ and isinstance(node.args[0], ast.Constant)
155
+ and isinstance(node.args[0].value, str)
156
+ ):
157
+ return node.args[0].value
158
+ return None
159
+
160
+ def _find_defined_env_vars(self, path: Path) -> set[str]:
161
+ """Find env vars that are actually defined.
162
+
163
+ Checks:
164
+ - Current process environment
165
+ - .env file
166
+ - .env.example file
167
+ - .env.sample file
168
+ """
169
+ defined: set[str] = set()
170
+
171
+ # System environment
172
+ defined.update(os.environ.keys())
173
+
174
+ # .env files
175
+ for env_filename in (".env", ".env.example", ".env.sample", ".env.template", ".env.local"):
176
+ env_file = path / env_filename
177
+ if env_file.exists():
178
+ defined.update(self._parse_env_file(env_file))
179
+
180
+ return defined
181
+
182
+ def _parse_env_file(self, filepath: Path) -> set[str]:
183
+ """Parse a .env file and return all defined variable names."""
184
+ env_vars: set[str] = set()
185
+ try:
186
+ for line in filepath.read_text(encoding="utf-8").splitlines():
187
+ line = line.strip()
188
+ if not line or line.startswith("#"):
189
+ continue
190
+ # Match KEY=value or export KEY=value
191
+ match = re.match(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=", line)
192
+ if match:
193
+ env_vars.add(match.group(1))
194
+ except OSError:
195
+ pass
196
+ return env_vars
@@ -0,0 +1,112 @@
1
+ """Scanner protocol and orchestrator for running all ghost detectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Protocol, runtime_checkable
8
+
9
+ from ghostbuster.core.models import Ghost, GhostCategory, ScanResult, Severity
10
+ from ghostbuster.core.scoring import calculate_score
11
+
12
+
13
+ @runtime_checkable
14
+ class Scanner(Protocol):
15
+ """Protocol that all ghost scanners must implement.
16
+
17
+ Each scanner is responsible for detecting one category of ghosts.
18
+ Scanners must be pure logic — no CLI/UI side effects.
19
+ """
20
+
21
+ name: str
22
+
23
+ def scan(self, path: Path) -> list[Ghost]:
24
+ """Scan the given path and return a list of found ghosts."""
25
+ ...
26
+
27
+
28
+ class ScanOrchestrator:
29
+ """Runs all registered scanners and aggregates results.
30
+
31
+ Usage::
32
+
33
+ orchestrator = ScanOrchestrator()
34
+ orchestrator.register(DeadImportScanner())
35
+ orchestrator.register(OrphanFileScanner())
36
+ result = orchestrator.run(Path("."))
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._scanners: list[Scanner] = []
41
+
42
+ def register(self, scanner: Scanner) -> None:
43
+ """Register a scanner to be run during scan."""
44
+ self._scanners.append(scanner)
45
+
46
+ @property
47
+ def scanner_names(self) -> list[str]:
48
+ """Return names of all registered scanners."""
49
+ return [s.name for s in self._scanners]
50
+
51
+ def run(
52
+ self,
53
+ path: Path,
54
+ categories: list[str] | None = None,
55
+ ) -> ScanResult:
56
+ """Run all registered scanners (or filtered subset) on the given path.
57
+
58
+ Args:
59
+ path: Root directory to scan.
60
+ categories: Optional list of category names to filter scanners.
61
+ If None, all scanners are run.
62
+
63
+ Returns:
64
+ Aggregated ScanResult with all ghosts found and a score.
65
+ """
66
+ start = time.perf_counter()
67
+ all_ghosts: list[Ghost] = []
68
+
69
+ scanners_to_run = self._scanners
70
+ if categories:
71
+ scanners_to_run = [s for s in self._scanners if s.name in categories]
72
+
73
+ for scanner in scanners_to_run:
74
+ try:
75
+ ghosts = scanner.scan(path)
76
+ all_ghosts.extend(ghosts)
77
+ except Exception as exc:
78
+ # Don't let one scanner crash the entire run.
79
+ # In debug mode, the CLI layer will re-raise.
80
+ all_ghosts.append(
81
+ Ghost(
82
+ category=GhostCategory.DEAD_IMPORT,
83
+ name=f"scanner-error-{scanner.name}",
84
+ message=f"Scanner '{scanner.name}' failed: {exc}",
85
+ severity=Severity.LOW,
86
+ )
87
+ )
88
+
89
+ elapsed_ms = (time.perf_counter() - start) * 1000
90
+ score = calculate_score(all_ghosts)
91
+
92
+ return ScanResult(
93
+ ghosts=all_ghosts,
94
+ score=score,
95
+ scanned_path=path,
96
+ duration_ms=elapsed_ms,
97
+ )
98
+
99
+
100
+ def create_default_orchestrator() -> ScanOrchestrator:
101
+ """Create an orchestrator with all built-in scanners registered."""
102
+ from ghostbuster.core.dead_imports import DeadImportScanner
103
+ from ghostbuster.core.orphan_files import OrphanFileScanner
104
+ from ghostbuster.core.phantom_env import PhantomEnvScanner
105
+ from ghostbuster.core.zombie_code import ZombieCodeScanner
106
+
107
+ orchestrator = ScanOrchestrator()
108
+ orchestrator.register(DeadImportScanner())
109
+ orchestrator.register(OrphanFileScanner())
110
+ orchestrator.register(ZombieCodeScanner())
111
+ orchestrator.register(PhantomEnvScanner())
112
+ return orchestrator
@@ -0,0 +1,83 @@
1
+ """Ghost Score calculation — the signature viraliy metric.
2
+
3
+ Computes a 0-100 "hauntedness" score based on ghost findings.
4
+ Designed to be provocative enough to screenshot and share.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ghostbuster.core.models import Ghost, GhostCategory, GhostScore, Severity
10
+
11
+ # Weight multipliers per category (reflects real-world severity impact)
12
+ CATEGORY_WEIGHTS: dict[GhostCategory, int] = {
13
+ GhostCategory.DEAD_IMPORT: 5, # Common, moderate waste
14
+ GhostCategory.ORPHAN_FILE: 8, # Disk space, clone time
15
+ GhostCategory.ZOMBIE_CODE: 3, # Confusing but usually harmless
16
+ GhostCategory.PHANTOM_ENV: 10, # Can cause runtime crashes
17
+ }
18
+
19
+ # Bonus weight for high-severity ghosts
20
+ SEVERITY_MULTIPLIER: dict[Severity, float] = {
21
+ Severity.LOW: 0.5,
22
+ Severity.MEDIUM: 1.0,
23
+ Severity.HIGH: 1.5,
24
+ }
25
+
26
+ # Score thresholds and their labels
27
+ SCORE_LABELS: list[tuple[int, str]] = [
28
+ (0, "No ghosts detected. Your codebase is clean!"),
29
+ (10, "Almost clean - just a few minor issues."),
30
+ (20, "Mildly affected. Nothing a quick cleanup cannot fix."),
31
+ (35, "Multiple issues detected. Time for cleanup."),
32
+ (50, "Noticeable technical debt detected."),
33
+ (70, "Seriously affected. Your codebase needs significant cleanup."),
34
+ (85, "Extremely cluttered with unused code and files."),
35
+ (100, "Severe technical debt detected across multiple categories."),
36
+ ]
37
+
38
+
39
+ def calculate_score(ghosts: list[Ghost]) -> GhostScore:
40
+ """Calculate the Ghost Score from a list of ghost findings.
41
+
42
+ The score is a weighted sum of ghosts, capped at 100.
43
+ Each ghost contributes its category weight × severity multiplier.
44
+
45
+ Args:
46
+ ghosts: List of Ghost findings from all scanners.
47
+
48
+ Returns:
49
+ GhostScore with value (0-100), label, and per-category breakdown.
50
+ """
51
+ if not ghosts:
52
+ return GhostScore(
53
+ value=0,
54
+ label=SCORE_LABELS[0][1],
55
+ breakdown={cat: 0 for cat in GhostCategory},
56
+ )
57
+
58
+ # Calculate raw score with weighted contributions
59
+ raw_score = 0.0
60
+ breakdown: dict[GhostCategory, int] = {cat: 0 for cat in GhostCategory}
61
+
62
+ for ghost in ghosts:
63
+ weight = CATEGORY_WEIGHTS.get(ghost.category, 3)
64
+ multiplier = SEVERITY_MULTIPLIER.get(ghost.severity, 1.0)
65
+ contribution = weight * multiplier
66
+ raw_score += contribution
67
+ breakdown[ghost.category] = breakdown.get(ghost.category, 0) + 1
68
+
69
+ # Cap at 100
70
+ final_score = min(int(raw_score), 100)
71
+
72
+ # Find the appropriate label
73
+ label = SCORE_LABELS[-1][1] # Default to worst
74
+ for threshold, score_label in SCORE_LABELS:
75
+ if final_score <= threshold:
76
+ label = score_label
77
+ break
78
+
79
+ return GhostScore(
80
+ value=final_score,
81
+ label=label,
82
+ breakdown=breakdown,
83
+ )