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.
pycodegate/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Python Doctor - Diagnose your Python project's health."""
2
+
3
+ __version__ = "0.1.0"
pycodegate/_compat.py ADDED
@@ -0,0 +1,15 @@
1
+ """Python version compatibility shims."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else:
10
+ try:
11
+ import tomllib
12
+ except ModuleNotFoundError:
13
+ import tomli as tomllib # type: ignore[no-redef]
14
+
15
+ __all__ = ["tomllib"]
pycodegate/api.py ADDED
@@ -0,0 +1,26 @@
1
+ """Programmatic API for python-doctor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pycodegate.config import load_config
8
+ from pycodegate.scan import scan_project
9
+
10
+ if TYPE_CHECKING:
11
+ from pycodegate.types import ScanResult
12
+
13
+
14
+ def diagnose(
15
+ project_path: str = ".",
16
+ *,
17
+ lint: bool = True,
18
+ dead_code: bool = True,
19
+ diff_base: str | None = None,
20
+ ) -> ScanResult:
21
+ """Run python-doctor analysis programmatically."""
22
+ config = load_config(project_path)
23
+ config.lint = lint
24
+ config.dead_code = dead_code
25
+
26
+ return scan_project(project_path, config, diff_base=diff_base)
pycodegate/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """CLI entry point for py-doctor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from pycodegate import __version__
10
+ from pycodegate.config import load_config
11
+ from pycodegate.output import output_json, print_scan_result
12
+ from pycodegate.scan import scan_project
13
+ from pycodegate.types import Severity
14
+ from pycodegate.utils.badge import generate_badge, generate_ci_workflow
15
+ from pycodegate.utils.fixer import run_ruff_fix
16
+ from pycodegate.utils.precommit import install_precommit_hook
17
+
18
+
19
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
20
+ @click.version_option(__version__, "-v", "--version", prog_name="Py Gate")
21
+ @click.argument("directory", default=".", type=click.Path(exists=True))
22
+ @click.option("--lint/--no-lint", default=True, help="Enable/disable lint checks.")
23
+ @click.option(
24
+ "--dead-code/--no-dead-code", default=True, help="Enable/disable dead code detection."
25
+ )
26
+ @click.option("--verbose", is_flag=True, help="Show file details per rule.")
27
+ @click.option("--score", "score_only", is_flag=True, help="Output only the numeric score.")
28
+ @click.option("--json", "json_output", is_flag=True, help="Output results as JSON.")
29
+ @click.option(
30
+ "--diff",
31
+ "diff_base",
32
+ default=None,
33
+ type=str,
34
+ help="Scan only files changed vs base branch.",
35
+ )
36
+ @click.option(
37
+ "--fail-on",
38
+ type=click.Choice(["error", "warning", "none"]),
39
+ default="none",
40
+ help="Exit with code 1 on this severity level.",
41
+ )
42
+ @click.option("--fix", "fix", is_flag=True, default=False, help="Auto-fix issues via ruff.")
43
+ @click.option(
44
+ "--profile",
45
+ "profile_override",
46
+ type=click.Choice(["cli", "web", "library", "script"]),
47
+ default=None,
48
+ help="Override auto-detected project profile.",
49
+ )
50
+ @click.option(
51
+ "--badge", "badge", is_flag=True, default=False, help="Output shields.io badge markdown."
52
+ )
53
+ @click.option(
54
+ "--ci", "ci_workflow", is_flag=True, default=False, help="Output GitHub Actions workflow YAML."
55
+ )
56
+ @click.option(
57
+ "--pre-commit", "precommit", is_flag=True, default=False, help="Install git pre-commit hook."
58
+ )
59
+ @click.option(
60
+ "--min-score",
61
+ "min_score",
62
+ type=int,
63
+ default=None,
64
+ help="Minimum score threshold (exit 1 if below).",
65
+ )
66
+ def main(
67
+ directory: str,
68
+ lint: bool,
69
+ dead_code: bool,
70
+ verbose: bool,
71
+ score_only: bool,
72
+ json_output: bool,
73
+ diff_base: str | None,
74
+ fail_on: str,
75
+ fix: bool,
76
+ profile_override: str | None,
77
+ badge: bool,
78
+ ci_workflow: bool,
79
+ precommit: bool,
80
+ min_score: int | None,
81
+ ) -> None:
82
+ """Py Gate — Diagnose your Python project's health."""
83
+ if ci_workflow:
84
+ click.echo(generate_ci_workflow(), nl=False)
85
+ return
86
+
87
+ if precommit:
88
+ effective_min = min_score if min_score is not None else 50
89
+ msg = install_precommit_hook(directory, min_score=effective_min)
90
+ click.echo(msg)
91
+ return
92
+
93
+ if fix:
94
+ fixes = run_ruff_fix(directory)
95
+ if fixes == -1:
96
+ click.echo("ruff not found, skipping auto-fix")
97
+ else:
98
+ click.echo(f"Fixed {fixes} issues via ruff")
99
+
100
+ config = load_config(directory)
101
+ config.lint = lint
102
+ config.dead_code = dead_code
103
+ config.verbose = verbose
104
+ config.fail_on = fail_on
105
+ if profile_override is not None:
106
+ config.profile = profile_override
107
+
108
+ result = scan_project(directory, config, diff_base=diff_base)
109
+
110
+ if badge:
111
+ click.echo(generate_badge(result.score.value, result.score.label))
112
+ return
113
+
114
+ if json_output:
115
+ output_json(result)
116
+ elif score_only:
117
+ click.echo(str(result.score.value))
118
+ else:
119
+ print_scan_result(result, verbose=verbose)
120
+
121
+ # Exit code based on --fail-on
122
+ has_errors = any(d.severity == Severity.ERROR for d in result.diagnostics)
123
+ if (fail_on == "error" and has_errors) or (fail_on == "warning" and result.diagnostics):
124
+ sys.exit(1)
125
+
126
+ if min_score is not None and result.score.value < min_score:
127
+ click.echo(
128
+ f"py-gate: score {result.score.value} is below minimum {min_score}",
129
+ err=True,
130
+ )
131
+ sys.exit(1)
pycodegate/config.py ADDED
@@ -0,0 +1,81 @@
1
+ """Configuration loading for py-gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from pycodegate._compat import tomllib
9
+
10
+
11
+ @dataclass
12
+ class Config:
13
+ lint: bool = True
14
+ dead_code: bool = True
15
+ verbose: bool = False
16
+ fail_on: str = "none"
17
+ ignore_rules: list[str] = field(default_factory=list)
18
+ ignore_files: list[str] = field(default_factory=list)
19
+ profile: str | None = None
20
+ per_file_suppress: dict[str, list[str]] = field(default_factory=dict)
21
+ max_deduction: dict[str, int] = field(default_factory=dict)
22
+
23
+
24
+ def load_config(project_path: str) -> Config:
25
+ """Load config from py-gate.toml or pyproject.toml [tool.py-gate]."""
26
+ root = Path(project_path)
27
+
28
+ # py-gate.toml takes precedence
29
+ doctor_toml = root / "py-gate.toml"
30
+ if doctor_toml.exists():
31
+ return _parse_doctor_toml(doctor_toml)
32
+
33
+ # Fall back to pyproject.toml
34
+ pyproject = root / "pyproject.toml"
35
+ if pyproject.exists():
36
+ return _parse_pyproject_toml(pyproject)
37
+
38
+ return Config()
39
+
40
+
41
+ def _parse_doctor_toml(path: Path) -> Config:
42
+ with open(path, "rb") as f:
43
+ data = tomllib.load(f)
44
+
45
+ options = data.get("options", {})
46
+ ignore = data.get("ignore", {})
47
+
48
+ return Config(
49
+ lint=options.get("lint", True),
50
+ dead_code=options.get("dead_code", True),
51
+ verbose=options.get("verbose", False),
52
+ fail_on=options.get("fail_on", "none"),
53
+ ignore_rules=ignore.get("rules", []),
54
+ ignore_files=ignore.get("files", []),
55
+ profile=options.get("profile", None),
56
+ per_file_suppress=data.get("per-file-suppress", {}),
57
+ max_deduction=data.get("max-deduction", {}),
58
+ )
59
+
60
+
61
+ def _parse_pyproject_toml(path: Path) -> Config:
62
+ with open(path, "rb") as f:
63
+ data = tomllib.load(f)
64
+
65
+ section = data.get("tool", {}).get("py-gate", {})
66
+ if not section:
67
+ return Config()
68
+
69
+ ignore = section.get("ignore", {})
70
+
71
+ return Config(
72
+ lint=section.get("lint", True),
73
+ dead_code=section.get("dead_code", True),
74
+ verbose=section.get("verbose", False),
75
+ fail_on=section.get("fail_on", "none"),
76
+ ignore_rules=ignore.get("rules", []),
77
+ ignore_files=ignore.get("files", []),
78
+ profile=section.get("profile", None),
79
+ per_file_suppress=section.get("per-file-suppress", {}),
80
+ max_deduction=section.get("max-deduction", {}),
81
+ )
@@ -0,0 +1,32 @@
1
+ """Constants and thresholds for python-doctor."""
2
+
3
+ from pycodegate.types import Category
4
+
5
+ # Score thresholds
6
+ SCORE_GREAT = 75
7
+ SCORE_NEEDS_WORK = 50
8
+
9
+ # Labels
10
+ LABEL_EXCELLENT = "Excellent"
11
+ LABEL_GREAT = "Great"
12
+ LABEL_NEEDS_WORK = "Needs work"
13
+ LABEL_CRITICAL = "Critical"
14
+
15
+ # Category weights for budget-based scoring
16
+ CATEGORY_WEIGHTS: dict = {
17
+ Category.SECURITY: 5,
18
+ Category.CORRECTNESS: 4,
19
+ Category.COMPLEXITY: 3,
20
+ Category.ARCHITECTURE: 3,
21
+ Category.PERFORMANCE: 2,
22
+ Category.STRUCTURE: 2,
23
+ Category.IMPORTS: 1,
24
+ Category.DEAD_CODE: 1,
25
+ }
26
+
27
+ # Framework categories map to their parent category for scoring
28
+ FRAMEWORK_CATEGORY_MAP: dict = {
29
+ Category.DJANGO: Category.SECURITY,
30
+ Category.FASTAPI: Category.CORRECTNESS,
31
+ Category.FLASK: Category.SECURITY,
32
+ }
pycodegate/discover.py ADDED
@@ -0,0 +1,114 @@
1
+ """Project discovery: detect framework, Python version, package manager, etc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from pycodegate._compat import tomllib
9
+ from pycodegate.types import ProjectInfo
10
+ from pycodegate.utils.file_discovery import find_python_files
11
+
12
+
13
+ def discover_project(project_path: str) -> ProjectInfo:
14
+ """Auto-detect project characteristics."""
15
+ root = Path(project_path)
16
+ deps = _collect_all_deps(root)
17
+
18
+ return ProjectInfo(
19
+ path=project_path,
20
+ python_version=_detect_python_version(root),
21
+ framework=_detect_framework(deps),
22
+ package_manager=_detect_package_manager(root),
23
+ test_framework=_detect_test_framework(deps),
24
+ has_type_hints=_detect_type_hints(root),
25
+ source_file_count=len(find_python_files(project_path)),
26
+ )
27
+
28
+
29
+ def _collect_all_deps(root: Path) -> set[str]:
30
+ """Collect dependency names from all sources."""
31
+ deps: set[str] = set()
32
+
33
+ # pyproject.toml
34
+ pyproject = root / "pyproject.toml"
35
+ if pyproject.exists():
36
+ with open(pyproject, "rb") as f:
37
+ data = tomllib.load(f)
38
+ project = data.get("project", {})
39
+ for dep in project.get("dependencies", []):
40
+ deps.add(_parse_dep_name(dep))
41
+ for group_deps in project.get("optional-dependencies", {}).values():
42
+ for dep in group_deps:
43
+ deps.add(_parse_dep_name(dep))
44
+ # Poetry format
45
+ poetry = data.get("tool", {}).get("poetry", {})
46
+ for dep in poetry.get("dependencies", {}):
47
+ deps.add(dep.lower())
48
+ for group in poetry.get("group", {}).values():
49
+ for dep in group.get("dependencies", {}):
50
+ deps.add(dep.lower())
51
+
52
+ # requirements.txt
53
+ for req_file in ["requirements.txt", "requirements-dev.txt", "requirements_dev.txt"]:
54
+ req_path = root / req_file
55
+ if req_path.exists():
56
+ for line in req_path.read_text().splitlines():
57
+ line = line.strip()
58
+ if line and not line.startswith(("#", "-")):
59
+ deps.add(_parse_dep_name(line))
60
+
61
+ return deps
62
+
63
+
64
+ def _parse_dep_name(dep_str: str) -> str:
65
+ """Extract package name from a dependency specifier like 'django>=4.2'."""
66
+ return re.split(r"[><=!~\[;@\s]", dep_str)[0].strip().lower()
67
+
68
+
69
+ def _detect_framework(deps: set[str]) -> str | None:
70
+ if "django" in deps or "django-rest-framework" in deps or "djangorestframework" in deps:
71
+ return "django"
72
+ if "fastapi" in deps:
73
+ return "fastapi"
74
+ if "flask" in deps:
75
+ return "flask"
76
+ return None
77
+
78
+
79
+ def _detect_package_manager(root: Path) -> str | None:
80
+ if (root / "uv.lock").exists():
81
+ return "uv"
82
+ if (root / "poetry.lock").exists():
83
+ return "poetry"
84
+ if (root / "Pipfile.lock").exists():
85
+ return "pipenv"
86
+ if (root / "requirements.txt").exists():
87
+ return "pip"
88
+ if (root / "pyproject.toml").exists():
89
+ return "pip"
90
+ return None
91
+
92
+
93
+ def _detect_test_framework(deps: set[str]) -> str | None:
94
+ if "pytest" in deps:
95
+ return "pytest"
96
+ if "unittest" in deps:
97
+ return "unittest"
98
+ return None
99
+
100
+
101
+ def _detect_python_version(root: Path) -> str | None:
102
+ pyproject = root / "pyproject.toml"
103
+ if pyproject.exists():
104
+ with open(pyproject, "rb") as f:
105
+ data = tomllib.load(f)
106
+ requires = data.get("project", {}).get("requires-python", "")
107
+ match = re.search(r"(\d+\.\d+)", requires)
108
+ if match:
109
+ return match.group(1)
110
+ return None
111
+
112
+
113
+ def _detect_type_hints(root: Path) -> bool:
114
+ return (root / "py.typed").exists() or (root / "mypy.ini").exists()
pycodegate/output.py ADDED
@@ -0,0 +1,270 @@
1
+ """Rich terminal output: score bar, doctor face, framed summary, category-grouped diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from collections import defaultdict
8
+
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.text import Text
12
+
13
+ from pycodegate import __version__
14
+ from pycodegate.constants import CATEGORY_WEIGHTS, FRAMEWORK_CATEGORY_MAP
15
+ from pycodegate.types import Category, Diagnostic, ScanResult, Severity
16
+
17
+ BAR_WIDTH = 50
18
+
19
+ # Doctor ASCII faces
20
+ _HAPPY = r"""
21
+ ┌─────┐
22
+ │ ◕ ◕ │
23
+ │ ◡ │
24
+ └─────┘
25
+ """
26
+
27
+ _NEUTRAL = r"""
28
+ ┌─────┐
29
+ │ ◑ ◑ │
30
+ │ ━ │
31
+ └─────┘
32
+ """
33
+
34
+ _SAD = r"""
35
+ ┌─────┐
36
+ │ ◔ ◔ │
37
+ │ ◠ │
38
+ └─────┘
39
+ """
40
+
41
+ # Display metadata for each category
42
+ CATEGORY_DISPLAY: dict[Category, tuple[str, str]] = {
43
+ Category.SECURITY: ("🔒", "Security"),
44
+ Category.CORRECTNESS: ("✓", "Correctness"),
45
+ Category.COMPLEXITY: ("⚡", "Complexity"),
46
+ Category.ARCHITECTURE: ("🏗", "Architecture"),
47
+ Category.PERFORMANCE: ("🐎", "Performance"),
48
+ Category.STRUCTURE: ("📁", "Structure"),
49
+ Category.IMPORTS: ("📦", "Imports"),
50
+ Category.DEAD_CODE: ("💀", "Dead Code"),
51
+ Category.DEPENDENCIES: ("🛡", "Dependencies"),
52
+ }
53
+
54
+
55
+ def format_score_bar(score: int) -> str:
56
+ """Return a text-based score bar."""
57
+ filled = round(score / 100 * BAR_WIDTH)
58
+ empty = BAR_WIDTH - filled
59
+ bar = "█" * filled + "░" * empty
60
+ return f" {bar} {score}"
61
+
62
+
63
+ def format_doctor_face(score: int) -> str:
64
+ """Return doctor ASCII art based on score."""
65
+ if score >= 75:
66
+ return _HAPPY
67
+ elif score >= 50:
68
+ return _NEUTRAL
69
+ else:
70
+ return _SAD
71
+
72
+
73
+ def _score_color(score: int) -> str:
74
+ if score >= 75:
75
+ return "green"
76
+ elif score >= 50:
77
+ return "yellow"
78
+ return "red"
79
+
80
+
81
+ def _compute_category_sub_scores(
82
+ diagnostics: list[Diagnostic],
83
+ ) -> dict[Category, tuple[int, int]]:
84
+ """Return {resolved_category: (earned, max)} for each category in CATEGORY_WEIGHTS."""
85
+ total_weight = sum(CATEGORY_WEIGHTS.values())
86
+ max_deductions = {cat: round(w / total_weight * 100) for cat, w in CATEGORY_WEIGHTS.items()}
87
+ # Fix rounding to ensure sum == 100
88
+ diff = 100 - sum(max_deductions.values())
89
+ if diff != 0:
90
+ highest = max(CATEGORY_WEIGHTS, key=lambda k: CATEGORY_WEIGHTS[k])
91
+ max_deductions[highest] += diff
92
+
93
+ # Group diagnostics by resolved category
94
+ by_category: dict[Category, list[Diagnostic]] = defaultdict(list)
95
+ for d in diagnostics:
96
+ resolved = FRAMEWORK_CATEGORY_MAP.get(d.category, d.category)
97
+ by_category[resolved].append(d)
98
+
99
+ # Compute actual deduction per category (same logic as score.py)
100
+ actual_deductions: dict[Category, float] = {}
101
+ for cat, diags in by_category.items():
102
+ costs = sorted([d.cost for d in diags], reverse=True)
103
+ cat_total = sum(c if i < 3 else c * 0.1 for i, c in enumerate(costs))
104
+ cap = max_deductions.get(cat, 10)
105
+ actual_deductions[cat] = min(cat_total, cap)
106
+
107
+ # Build (earned, max) per category
108
+ result: dict[Category, tuple[int, int]] = {}
109
+ for cat, max_ded in max_deductions.items():
110
+ deducted = round(actual_deductions.get(cat, 0.0))
111
+ earned = max_ded - deducted
112
+ result[cat] = (earned, max_ded)
113
+
114
+ return result
115
+
116
+
117
+ def format_summary(result: ScanResult) -> str:
118
+ """Format the full scan summary as a string (for testing)."""
119
+ lines: list[str] = []
120
+ lines.append(format_doctor_face(result.score.value))
121
+ lines.append(f" Score: {result.score.value}/100 — {result.score.label}")
122
+ lines.append(format_score_bar(result.score.value))
123
+ lines.append("")
124
+
125
+ errors = sum(1 for d in result.diagnostics if d.severity == Severity.ERROR)
126
+ warnings = sum(1 for d in result.diagnostics if d.severity == Severity.WARNING)
127
+ files = len({d.file_path for d in result.diagnostics})
128
+
129
+ lines.append(f" {errors} errors, {warnings} warnings across {files} files")
130
+ lines.append(f" Completed in {result.elapsed_ms}ms")
131
+ return "\n".join(lines)
132
+
133
+
134
+ def print_scan_result(result: ScanResult, verbose: bool = False) -> None:
135
+ """Print the full scan result to terminal using Rich."""
136
+ console = Console()
137
+
138
+ # Project info header
139
+ p = result.project
140
+ console.print()
141
+ console.print(" [bold]Py Gate[/bold] — v0.1.0")
142
+ console.print()
143
+ console.print(f" [dim]Path:[/dim] {p.path}")
144
+ if p.python_version:
145
+ console.print(f" [dim]Python:[/dim] {p.python_version}")
146
+ if p.package_manager:
147
+ console.print(f" [dim]Package manager:[/dim] {p.package_manager}")
148
+ if p.test_framework:
149
+ console.print(f" [dim]Test framework:[/dim] {p.test_framework}")
150
+ if result.profile:
151
+ console.print(f" [dim]Profile:[/dim] {result.profile}")
152
+ console.print(f" [dim]Source files:[/dim] {p.source_file_count}")
153
+ console.print()
154
+
155
+ # Category-grouped diagnostics with sub-scores
156
+ _print_category_groups(console, result.diagnostics, verbose)
157
+
158
+ # Summary panel
159
+ color = _score_color(result.score.value)
160
+ face = format_doctor_face(result.score.value).strip()
161
+
162
+ errors = sum(1 for d in result.diagnostics if d.severity == Severity.ERROR)
163
+ warnings = sum(1 for d in result.diagnostics if d.severity == Severity.WARNING)
164
+ files = len({d.file_path for d in result.diagnostics})
165
+
166
+ summary = Text()
167
+ summary.append(f"\n{face}\n\n", style="bold")
168
+ summary.append(" Score: ", style="dim")
169
+ summary.append(f"{result.score.value}/100", style=f"bold {color}")
170
+ summary.append(f" — {result.score.label}\n", style=color)
171
+ summary.append(f"{format_score_bar(result.score.value)}\n\n")
172
+ summary.append(f" {errors} errors, {warnings} warnings across {files} files\n", style="dim")
173
+ summary.append(f" Completed in {result.elapsed_ms}ms\n", style="dim")
174
+
175
+ console.print(Panel(summary, title="[bold]Results[/bold]", border_style=color))
176
+ console.print()
177
+
178
+
179
+ def output_json(result: ScanResult) -> None:
180
+ """Output the scan result as structured JSON to stdout."""
181
+ p = result.project
182
+ errors = sum(1 for d in result.diagnostics if d.severity == Severity.ERROR)
183
+ warnings = sum(1 for d in result.diagnostics if d.severity == Severity.WARNING)
184
+
185
+ payload = {
186
+ "version": __version__,
187
+ "path": p.path,
188
+ "score": result.score.value,
189
+ "label": result.score.label,
190
+ "errors": errors,
191
+ "warnings": warnings,
192
+ "elapsed_ms": result.elapsed_ms,
193
+ "project": {
194
+ "framework": p.framework,
195
+ "python_version": p.python_version,
196
+ "package_manager": p.package_manager,
197
+ "test_framework": p.test_framework,
198
+ "source_file_count": p.source_file_count,
199
+ },
200
+ "diagnostics": [
201
+ {
202
+ "rule": d.rule,
203
+ "severity": d.severity.value,
204
+ "category": d.category.value,
205
+ "message": d.message,
206
+ "help": d.help,
207
+ "file_path": d.file_path,
208
+ "line": d.line,
209
+ }
210
+ for d in result.diagnostics
211
+ ],
212
+ }
213
+ sys.stdout.write(json.dumps(payload, indent=2) + "\n")
214
+
215
+
216
+ def _print_category_groups(
217
+ console: Console,
218
+ diagnostics: list[Diagnostic],
219
+ verbose: bool,
220
+ ) -> None:
221
+ """Print diagnostics grouped by category with sub-scores."""
222
+ sub_scores = _compute_category_sub_scores(diagnostics)
223
+
224
+ # Build rule-grouped diagnostics per resolved category
225
+ by_category: dict[Category, list[Diagnostic]] = defaultdict(list)
226
+ for d in diagnostics:
227
+ resolved = FRAMEWORK_CATEGORY_MAP.get(d.category, d.category)
228
+ by_category[resolved].append(d)
229
+
230
+ # Iterate in canonical order (CATEGORY_WEIGHTS order)
231
+ for cat in CATEGORY_WEIGHTS:
232
+ if cat not in CATEGORY_DISPLAY:
233
+ continue
234
+ emoji, name = CATEGORY_DISPLAY[cat]
235
+ earned, maximum = sub_scores.get(cat, (CATEGORY_WEIGHTS[cat], CATEGORY_WEIGHTS[cat]))
236
+ # Recompute max from sub_scores (already normalised to 100-point scale)
237
+ cat_diags = by_category.get(cat, [])
238
+
239
+ if earned == maximum:
240
+ # Perfect score for this category
241
+ console.print(
242
+ f" [bold]{emoji} {name}[/bold] "
243
+ f"[green]({earned}/{maximum})[/green] [green]✓[/green]"
244
+ )
245
+ console.print(" [green]✓ All clear.[/green]")
246
+ else:
247
+ console.print(f" [bold]{emoji} {name}[/bold] [yellow]({earned}/{maximum})[/yellow]")
248
+ # Group by rule within this category
249
+ by_rule: dict[str, list[Diagnostic]] = defaultdict(list)
250
+ for d in cat_diags:
251
+ by_rule[d.rule].append(d)
252
+
253
+ # Sort: errors first, then by rule name
254
+ sorted_rules = sorted(
255
+ by_rule.keys(),
256
+ key=lambda r: (0 if by_rule[r][0].severity == Severity.ERROR else 1, r),
257
+ )
258
+ for rule in sorted_rules:
259
+ rule_diags = by_rule[rule]
260
+ count = len(rule_diags)
261
+ sev = rule_diags[0].severity
262
+ icon = "[red]✗[/red]" if sev == Severity.ERROR else "[yellow]⚠[/yellow]"
263
+ console.print(f" {icon} {rule} × {count}")
264
+ if verbose:
265
+ for d in rule_diags[:10]:
266
+ console.print(f" [dim]{d.file_path}:{d.line}[/dim]")
267
+ if count > 10:
268
+ console.print(f" [dim]... and {count - 10} more[/dim]")
269
+
270
+ console.print()