devdoctor-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.
devdoctor/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """DevDoctor: Deterministic developer machine and project diagnostic doctor."""
2
+
3
+ from devdoctor.api import DevDoctor
4
+ from devdoctor.checks.base import Check, check, get_registered_checks, register_check
5
+ from devdoctor.fixer import FixAction, FixPlan, FixResult, Fixer
6
+ from devdoctor.models import (
7
+ Confidence,
8
+ Diagnostic,
9
+ DiagnosticReport,
10
+ ProjectInfo,
11
+ ScanContext,
12
+ Severity,
13
+ )
14
+ from devdoctor.project import detect_project
15
+ from devdoctor.reporter import Reporter
16
+ from devdoctor.scanner import Scanner
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "DevDoctor",
22
+ "Diagnostic",
23
+ "DiagnosticReport",
24
+ "FixAction",
25
+ "FixPlan",
26
+ "FixResult",
27
+ "Fixer",
28
+ "ProjectInfo",
29
+ "ScanContext",
30
+ "Severity",
31
+ "Confidence",
32
+ "Check",
33
+ "check",
34
+ "register_check",
35
+ "get_registered_checks",
36
+ "detect_project",
37
+ "Reporter",
38
+ "Scanner",
39
+ "__version__",
40
+ ]
devdoctor/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow running DevDoctor directly via python -m devdoctor."""
2
+
3
+ import sys
4
+ from devdoctor.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
devdoctor/api.py ADDED
@@ -0,0 +1,96 @@
1
+ """Public Python API for DevDoctor programmatic integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import List, Optional, Set, Union
7
+
8
+ from devdoctor.checks.base import Check, check, get_registered_checks, register_check
9
+ from devdoctor.fixer import FixResult, Fixer
10
+ from devdoctor.models import (
11
+ Confidence,
12
+ Diagnostic,
13
+ DiagnosticReport,
14
+ FixAction,
15
+ ProjectInfo,
16
+ ScanContext,
17
+ Severity,
18
+ )
19
+ from devdoctor.project import detect_project
20
+ from devdoctor.reporter import Reporter
21
+ from devdoctor.scanner import Scanner
22
+
23
+
24
+ class DevDoctor:
25
+ """High-level Python API for running DevDoctor scans, fixes, and inspections programmatically."""
26
+
27
+ def __init__(
28
+ self,
29
+ project_path: Union[str, Path] = ".",
30
+ custom_checks_dir: Optional[Union[str, Path]] = None,
31
+ ):
32
+ self.project_path = Path(project_path).resolve()
33
+ self.custom_checks_dir = Path(custom_checks_dir).resolve() if custom_checks_dir else None
34
+ self.scanner = Scanner(self.project_path, self.custom_checks_dir)
35
+ self.reporter = Reporter()
36
+ self.fixer = Fixer()
37
+
38
+ def detect_project(self) -> ProjectInfo:
39
+ """Analyze project files and detect framework/technologies."""
40
+ return detect_project(self.project_path)
41
+
42
+ def scan(
43
+ self,
44
+ categories: Optional[List[str]] = None,
45
+ check_names: Optional[List[str]] = None,
46
+ include_frameworks: bool = True,
47
+ ) -> DiagnosticReport:
48
+ """Run diagnostic scan and return structured report."""
49
+ cat_set = set(categories) if categories else None
50
+ chk_set = set(check_names) if check_names else None
51
+ return self.scanner.scan(
52
+ categories=cat_set,
53
+ check_names=chk_set,
54
+ include_frameworks=include_frameworks,
55
+ )
56
+
57
+ def fix(
58
+ self,
59
+ safe_only: bool = False,
60
+ dry_run: bool = False,
61
+ auto_yes: bool = True,
62
+ interactive: bool = False,
63
+ ) -> FixResult:
64
+ """Generate and execute fixes for current project diagnostics."""
65
+ report = self.scan()
66
+ context = self.scanner.create_context()
67
+ plan = self.fixer.build_plan(report)
68
+ return self.fixer.execute_plan(
69
+ plan=plan,
70
+ context=context,
71
+ safe_only=safe_only,
72
+ dry_run=dry_run,
73
+ auto_yes=auto_yes,
74
+ interactive=interactive,
75
+ )
76
+
77
+ def free_port(self, port: int, auto_yes: bool = True) -> bool:
78
+ """Terminate process occupying the specified port."""
79
+ return self.fixer.free_port_interactive(port, auto_yes=auto_yes)
80
+
81
+
82
+ __all__ = [
83
+ "DevDoctor",
84
+ "Diagnostic",
85
+ "DiagnosticReport",
86
+ "FixAction",
87
+ "ProjectInfo",
88
+ "ScanContext",
89
+ "Severity",
90
+ "Confidence",
91
+ "Check",
92
+ "check",
93
+ "register_check",
94
+ "get_registered_checks",
95
+ "FixResult",
96
+ ]
@@ -0,0 +1,38 @@
1
+ """DevDoctor built-in diagnostic checks."""
2
+
3
+ from devdoctor.checks.base import Check, check, get_registered_checks, register_check
4
+ from devdoctor.checks.dependencies import DependencyConflictsCheck
5
+ from devdoctor.checks.django import DjangoCheck
6
+ from devdoctor.checks.docker import DockerCheck
7
+ from devdoctor.checks.environment import EnvironmentCheck
8
+ from devdoctor.checks.fastapi import FastAPICheck
9
+ from devdoctor.checks.filesystem import FilesystemCheck
10
+ from devdoctor.checks.git import GitCheck
11
+ from devdoctor.checks.imports import BrokenImportsCheck
12
+ from devdoctor.checks.pip import PipAvailabilityCheck
13
+ from devdoctor.checks.ports import PortConflictsCheck
14
+ from devdoctor.checks.postgres import PostgresCheck
15
+ from devdoctor.checks.python import PythonVersionCheck
16
+ from devdoctor.checks.redis import RedisCheck
17
+ from devdoctor.checks.virtualenv import VirtualenvCheck
18
+
19
+ __all__ = [
20
+ "Check",
21
+ "check",
22
+ "register_check",
23
+ "get_registered_checks",
24
+ "PythonVersionCheck",
25
+ "PipAvailabilityCheck",
26
+ "VirtualenvCheck",
27
+ "GitCheck",
28
+ "EnvironmentCheck",
29
+ "PortConflictsCheck",
30
+ "FilesystemCheck",
31
+ "BrokenImportsCheck",
32
+ "DependencyConflictsCheck",
33
+ "DjangoCheck",
34
+ "FastAPICheck",
35
+ "DockerCheck",
36
+ "PostgresCheck",
37
+ "RedisCheck",
38
+ ]
@@ -0,0 +1,69 @@
1
+ """Base Check interface and check decorator for DevDoctor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, List, Optional, Type, Union
6
+
7
+ from devdoctor.models import Diagnostic, ScanContext
8
+
9
+
10
+ class Check:
11
+ """Base class for all DevDoctor diagnostic checks."""
12
+ name: str = "base"
13
+ title: str = "Base Check"
14
+ description: str = ""
15
+ category: str = "system" # system, environment, git, network, dependencies, framework, filesystem
16
+ enabled: bool = True
17
+
18
+ def run(self, context: ScanContext) -> Union[Diagnostic, List[Diagnostic], None]:
19
+ """Execute the check and return one or more Diagnostics."""
20
+ raise NotImplementedError("Subclasses must implement run(context)")
21
+
22
+
23
+ _GLOBAL_CHECK_REGISTRY: Dict[str, Type[Check]] = {}
24
+
25
+
26
+ def register_check(check_cls: Type[Check]) -> Type[Check]:
27
+ """Register a Check class in the global registry."""
28
+ if hasattr(check_cls, "name") and check_cls.name:
29
+ _GLOBAL_CHECK_REGISTRY[check_cls.name] = check_cls
30
+ return check_cls
31
+
32
+
33
+ def get_registered_checks() -> List[Check]:
34
+ """Return instantiated instances of all registered checks."""
35
+ return [cls() for cls in _GLOBAL_CHECK_REGISTRY.values() if cls.enabled]
36
+
37
+
38
+ def check(
39
+ name: str,
40
+ title: Optional[str] = None,
41
+ category: str = "custom",
42
+ description: str = "",
43
+ ) -> Callable[[Callable[[ScanContext], Any]], Type[Check]]:
44
+ """Decorator to convert a simple function into a registered DevDoctor Check.
45
+
46
+ Example:
47
+ @check("my_api", title="My API Check")
48
+ def check_my_api(context: ScanContext) -> Diagnostic | None:
49
+ ...
50
+ """
51
+ def decorator(fn: Callable[[ScanContext], Any]) -> Type[Check]:
52
+ check_name = name
53
+ check_title = title or name.replace("_", " ").title()
54
+ check_category = category
55
+ check_desc = description or (fn.__doc__ or "")
56
+
57
+ class FunctionalCheck(Check):
58
+ def run(self, context: ScanContext) -> Any:
59
+ return fn(context)
60
+
61
+ FunctionalCheck.name = check_name
62
+ FunctionalCheck.title = check_title
63
+ FunctionalCheck.category = check_category
64
+ FunctionalCheck.description = check_desc
65
+
66
+ register_check(FunctionalCheck)
67
+ return FunctionalCheck
68
+
69
+ return decorator
@@ -0,0 +1,125 @@
1
+ """Check Python dependency conflicts and package requirements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from typing import List, Optional
10
+
11
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
12
+ from packaging.version import InvalidVersion, Version
13
+
14
+ from devdoctor.checks.base import Check, register_check
15
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
16
+
17
+
18
+ @register_check
19
+ class DependencyConflictsCheck(Check):
20
+ name = "dependency_conflicts"
21
+ title = "Dependency Conflicts"
22
+ category = "dependencies"
23
+ description = "Checks for package version conflicts, broken requirements, and pip check incompatibilities."
24
+
25
+ def run(self, context: ScanContext) -> List[Diagnostic]:
26
+ diagnostics: List[Diagnostic] = []
27
+
28
+ # 1. Run pip check for active environment consistency
29
+ try:
30
+ res = subprocess.run(
31
+ [sys.executable, "-m", "pip", "check"],
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=10,
35
+ )
36
+ if res.returncode != 0 and res.stdout.strip():
37
+ for line in res.stdout.strip().splitlines():
38
+ line = line.strip()
39
+ if not line:
40
+ continue
41
+ # Format: "pkgA 1.0.0 has requirement pkgB<3.0, but you have pkgB 3.1.0."
42
+ match = re.match(r"^(\S+)\s+(\S+)\s+has requirement\s+([^,]+),\s+but you have\s+(\S+)\s+(\S+)\.?", line)
43
+ if match:
44
+ parent_pkg = match.group(1)
45
+ req_spec = match.group(3).strip()
46
+ child_pkg = match.group(4)
47
+ installed_ver = match.group(5).rstrip(".")
48
+
49
+ # Suggested action
50
+ cmd = f'pip install "{req_spec}"'
51
+ action = f"Install a compatible version of {child_pkg} matching {req_spec}."
52
+
53
+ evidence = (
54
+ f"{parent_pkg} requires:\n {req_spec}\n\n "
55
+ f"Installed:\n {child_pkg} {installed_ver}\n\n "
56
+ f"Suggested action:\n {action}\n\n "
57
+ f"Run:\n {cmd}"
58
+ )
59
+
60
+ diagnostics.append(Diagnostic(
61
+ title="Dependency conflict",
62
+ severity=Severity.ERROR,
63
+ message=f"{parent_pkg} requires {req_spec}, but {child_pkg} {installed_ver} is installed.",
64
+ why=f"Incompatible version of {child_pkg} installed in the environment.",
65
+ evidence=evidence,
66
+ fix=f"Run:\n {cmd}",
67
+ command=cmd,
68
+ confidence=Confidence.HIGH,
69
+ fix_action=FixAction(
70
+ description=f"Align {child_pkg} with {req_spec}",
71
+ command=cmd,
72
+ safe=False,
73
+ ),
74
+ check_id=f"{self.name}_{parent_pkg}_{child_pkg}",
75
+ metadata={"parent": parent_pkg, "child": child_pkg, "spec": req_spec, "installed": installed_ver},
76
+ ))
77
+ else:
78
+ diagnostics.append(Diagnostic(
79
+ title="Dependency conflict",
80
+ severity=Severity.ERROR,
81
+ message=line,
82
+ why="pip check detected an unsatisfied package requirement.",
83
+ evidence=line,
84
+ fix="Resolve dependencies using pip install.",
85
+ confidence=Confidence.HIGH,
86
+ check_id=f"{self.name}_pip_check",
87
+ ))
88
+ except Exception:
89
+ pass
90
+
91
+ # 2. Check project declared requirements against installed packages
92
+ if context.project_info and context.project_info.dependencies:
93
+ for pkg, spec_str in context.project_info.dependencies.items():
94
+ if spec_str == "*":
95
+ continue
96
+ try:
97
+ installed_ver_str = importlib.metadata.version(pkg)
98
+ installed_ver = Version(installed_ver_str)
99
+ spec = SpecifierSet(spec_str)
100
+ if installed_ver not in spec:
101
+ cmd = f'pip install "{pkg}{spec_str}"'
102
+ evidence = f"{pkg} declared as: {spec_str}\nInstalled: {installed_ver_str}"
103
+ diagnostics.append(Diagnostic(
104
+ title=f"Mismatched package version: {pkg}",
105
+ severity=Severity.ERROR,
106
+ message=f"{pkg} requires {spec_str}, but installed version is {installed_ver_str}.",
107
+ why=f"The project configuration specifies '{pkg}{spec_str}', but the active environment has {installed_ver_str}.",
108
+ evidence=evidence,
109
+ fix=f"Reinstall compatible version:\n {cmd}",
110
+ command=cmd,
111
+ confidence=Confidence.HIGH,
112
+ fix_action=FixAction(
113
+ description=f"Install {pkg}{spec_str}",
114
+ command=cmd,
115
+ safe=False,
116
+ ),
117
+ check_id=f"{self.name}_spec_{pkg}",
118
+ ))
119
+ except importlib.metadata.PackageNotFoundError:
120
+ # Missing package will also be caught or highlighted here
121
+ pass
122
+ except (InvalidSpecifier, InvalidVersion):
123
+ pass
124
+
125
+ return diagnostics
@@ -0,0 +1,128 @@
1
+ """Django framework diagnostic checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List
10
+
11
+ from devdoctor.checks.base import Check, register_check
12
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
13
+
14
+
15
+ @register_check
16
+ class DjangoCheck(Check):
17
+ name = "django"
18
+ title = "Django Framework Diagnostics"
19
+ category = "framework"
20
+ description = "Checks Django security settings, secret key, allowed hosts, and migration state."
21
+
22
+ def run(self, context: ScanContext) -> List[Diagnostic]:
23
+ diagnostics: List[Diagnostic] = []
24
+ root = context.project_path
25
+
26
+ manage_py = root / "manage.py"
27
+ is_django = manage_py.is_file() or (context.project_info and "Django" in context.project_info.frameworks)
28
+
29
+ if not is_django:
30
+ return diagnostics
31
+
32
+ # Search for settings.py
33
+ settings_files = list(root.glob("**/settings.py"))
34
+ # Filter out venvs
35
+ settings_files = [f for f in settings_files if not any(x in f.parts for x in [".venv", "venv", "build"])]
36
+
37
+ for sf in settings_files:
38
+ try:
39
+ content = sf.read_text(encoding="utf-8", errors="replace")
40
+ rel_path = sf.relative_to(root)
41
+
42
+ # 1. DEBUG = True check
43
+ if re.search(r"^\s*DEBUG\s*=\s*True", content, re.MULTILINE):
44
+ diagnostics.append(Diagnostic(
45
+ title="Django DEBUG is True",
46
+ severity=Severity.WARNING,
47
+ message="DEBUG is set to True in Django settings.",
48
+ why="Running with DEBUG=True in production exposes sensitive tracebacks, settings, and database schema to users.",
49
+ evidence=f"Found in:\n {rel_path}",
50
+ fix="Set DEBUG via environment variable:\n DEBUG = os.getenv('DJANGO_DEBUG', 'False').lower() in ('true', '1')",
51
+ confidence=Confidence.HIGH,
52
+ check_id=f"{self.name}_debug_true",
53
+ metadata={"file": str(rel_path)},
54
+ ))
55
+
56
+ # 2. Insecure SECRET_KEY check
57
+ if "django-insecure-" in content:
58
+ diagnostics.append(Diagnostic(
59
+ title="Insecure Django SECRET_KEY",
60
+ severity=Severity.WARNING,
61
+ message="SECRET_KEY appears to be using the default development value.",
62
+ why="Using a hardcoded or django-insecure secret key compromises session signing and CSRF tokens.",
63
+ evidence=f"Found in:\n {rel_path}",
64
+ fix="Load SECRET_KEY from environment:\n SECRET_KEY = os.environ['DJANGO_SECRET_KEY']",
65
+ confidence=Confidence.HIGH,
66
+ check_id=f"{self.name}_secret_key",
67
+ metadata={"file": str(rel_path)},
68
+ ))
69
+
70
+ # 3. ALLOWED_HOSTS check
71
+ allowed_match = re.search(r"^\s*ALLOWED_HOSTS\s*=\s*\[(.*?)\]", content, re.MULTILINE | re.DOTALL)
72
+ if allowed_match:
73
+ hosts_inner = allowed_match.group(1).strip()
74
+ if not hosts_inner:
75
+ diagnostics.append(Diagnostic(
76
+ title="Django ALLOWED_HOSTS is empty",
77
+ severity=Severity.WARNING,
78
+ message="ALLOWED_HOSTS is empty.",
79
+ why="When DEBUG is False, an empty ALLOWED_HOSTS list will block all incoming HTTP requests.",
80
+ evidence=f"Found in:\n {rel_path}",
81
+ fix="Configure ALLOWED_HOSTS:\n ALLOWED_HOSTS = ['localhost', '127.0.0.1']",
82
+ confidence=Confidence.HIGH,
83
+ check_id=f"{self.name}_allowed_hosts",
84
+ metadata={"file": str(rel_path)},
85
+ ))
86
+ except Exception:
87
+ pass
88
+
89
+ # 4. Check migrations if manage.py is present
90
+ if manage_py.is_file():
91
+ try:
92
+ res = subprocess.run(
93
+ [sys.executable, str(manage_py), "showmigrations", "--plan"],
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=8,
97
+ cwd=str(root),
98
+ )
99
+ if res.returncode == 0:
100
+ unapplied = [line for line in res.stdout.splitlines() if "[ ]" in line]
101
+ if unapplied:
102
+ diagnostics.append(Diagnostic(
103
+ title="Unapplied Django migrations",
104
+ severity=Severity.WARNING,
105
+ message=f"{len(unapplied)} unapplied database migration(s) detected.",
106
+ why="Running without applied migrations causes database errors or missing tables.",
107
+ evidence="\n ".join(unapplied[:3]),
108
+ fix="Apply migrations:\n python manage.py migrate",
109
+ command=f"{sys.executable} manage.py migrate",
110
+ confidence=Confidence.HIGH,
111
+ fix_action=FixAction(
112
+ description="Run python manage.py migrate",
113
+ command=f"{sys.executable} manage.py migrate",
114
+ safe=False,
115
+ ),
116
+ check_id=f"{self.name}_migrations",
117
+ ))
118
+ else:
119
+ diagnostics.append(Diagnostic(
120
+ title="Django migrations",
121
+ severity=Severity.OK,
122
+ message="Migrations are up to date.",
123
+ check_id=f"{self.name}_migrations_ok",
124
+ ))
125
+ except Exception:
126
+ pass
127
+
128
+ return diagnostics
@@ -0,0 +1,124 @@
1
+ """Docker daemon, container health, and port collision diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from typing import List
8
+
9
+ from devdoctor.checks.base import Check, register_check
10
+ from devdoctor.models import Confidence, Diagnostic, ScanContext, Severity
11
+
12
+
13
+ @register_check
14
+ class DockerCheck(Check):
15
+ name = "docker"
16
+ title = "Docker Diagnostics"
17
+ category = "framework"
18
+ description = "Checks Docker daemon accessibility, exited container states, and compose service ports."
19
+
20
+ def run(self, context: ScanContext) -> List[Diagnostic]:
21
+ diagnostics: List[Diagnostic] = []
22
+ is_docker_project = context.project_info and "Docker" in context.project_info.technologies
23
+
24
+ # 1. Check docker CLI
25
+ try:
26
+ res_ver = subprocess.run(["docker", "--version"], capture_output=True, text=True, timeout=4)
27
+ if res_ver.returncode != 0:
28
+ if is_docker_project:
29
+ diagnostics.append(Diagnostic(
30
+ title="Docker CLI unavailable",
31
+ severity=Severity.ERROR,
32
+ message="Project uses Docker, but 'docker' command is not available in PATH.",
33
+ why="Dockerfile or docker-compose is present, but Docker CLI is not installed.",
34
+ fix="Install Docker Desktop from https://www.docker.com/products/docker-desktop/.",
35
+ confidence=Confidence.HIGH,
36
+ check_id=f"{self.name}_cli_missing",
37
+ ))
38
+ return diagnostics
39
+
40
+ match = re.search(r"Docker version\s+([0-9.]+)", res_ver.stdout)
41
+ ver = match.group(1) if match else "available"
42
+ diagnostics.append(Diagnostic(
43
+ title=f"Docker {ver}",
44
+ severity=Severity.OK,
45
+ message=f"Docker CLI is installed and available (version {ver}).",
46
+ check_id=f"{self.name}_version",
47
+ ))
48
+
49
+ except Exception:
50
+ if is_docker_project:
51
+ diagnostics.append(Diagnostic(
52
+ title="Docker CLI unavailable",
53
+ severity=Severity.ERROR,
54
+ message="Docker CLI is not found in PATH.",
55
+ fix="Install Docker Desktop and ensure it is in your PATH.",
56
+ confidence=Confidence.HIGH,
57
+ check_id=f"{self.name}_cli_missing",
58
+ ))
59
+ return diagnostics
60
+
61
+ # 2. Check Docker daemon running
62
+ try:
63
+ res_info = subprocess.run(["docker", "info"], capture_output=True, text=True, timeout=5)
64
+ if res_info.returncode != 0:
65
+ diagnostics.append(Diagnostic(
66
+ title="Docker daemon not running",
67
+ severity=Severity.ERROR,
68
+ message="Docker daemon is stopped or inaccessible.",
69
+ why="The docker client cannot communicate with the Docker daemon engine.",
70
+ evidence=res_info.stderr.strip()[:200] if res_info.stderr else "Connection refused",
71
+ fix="Start Docker Desktop or run 'sudo systemctl start docker'.",
72
+ confidence=Confidence.HIGH,
73
+ check_id=f"{self.name}_daemon_stopped",
74
+ ))
75
+ return diagnostics
76
+ else:
77
+ diagnostics.append(Diagnostic(
78
+ title="Docker daemon running",
79
+ severity=Severity.OK,
80
+ message="Docker engine is healthy and accepting commands.",
81
+ check_id=f"{self.name}_daemon_ok",
82
+ ))
83
+ except Exception:
84
+ diagnostics.append(Diagnostic(
85
+ title="Docker daemon unreachable",
86
+ severity=Severity.WARNING,
87
+ message="Timed out communicating with Docker daemon.",
88
+ fix="Ensure Docker Desktop is active.",
89
+ check_id=f"{self.name}_daemon_timeout",
90
+ ))
91
+ return diagnostics
92
+
93
+ # 3. Check for crashed or failed exited containers
94
+ try:
95
+ res_ps = subprocess.run(
96
+ ["docker", "ps", "-a", "--filter", "status=exited", "--format", "{{.Names}}\t{{.Status}}"],
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=5,
100
+ )
101
+ if res_ps.returncode == 0 and res_ps.stdout.strip():
102
+ for line in res_ps.stdout.strip().splitlines():
103
+ parts = line.split("\t")
104
+ if len(parts) >= 2:
105
+ name, status = parts[0], parts[1]
106
+ # e.g. "Exited (1) 2 minutes ago"
107
+ match_code = re.search(r"Exited\s+\((\d+)\)", status)
108
+ if match_code and match_code.group(1) != "0":
109
+ code = match_code.group(1)
110
+ diagnostics.append(Diagnostic(
111
+ title=f"Container {name} crashed",
112
+ severity=Severity.ERROR,
113
+ message=f"Container '{name}' exited with error code {code}.",
114
+ why=f"Container process terminated abnormally ({status}).",
115
+ evidence=f"Container: {name} | Status: {status}",
116
+ fix=f"Inspect container logs:\n docker logs {name}",
117
+ command=f"docker logs {name}",
118
+ confidence=Confidence.HIGH,
119
+ check_id=f"{self.name}_container_{name}",
120
+ ))
121
+ except Exception:
122
+ pass
123
+
124
+ return diagnostics