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.
@@ -0,0 +1,146 @@
1
+ """Check environment variables, .env file presence, and .env vs .env.example discrepancies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import List
9
+
10
+ from devdoctor.checks.base import Check, register_check
11
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
12
+ from devdoctor.utils.ast_imports import scan_project_ast
13
+ from devdoctor.utils.env_parser import find_missing_env_keys, parse_env_file
14
+ from devdoctor.utils.platform_info import is_windows
15
+
16
+
17
+ @register_check
18
+ class EnvironmentCheck(Check):
19
+ name = "environment"
20
+ title = "Environment Configuration"
21
+ category = "environment"
22
+ description = "Checks for missing environment files, missing keys from .env.example, and missing required vars found in code."
23
+
24
+ def run(self, context: ScanContext) -> List[Diagnostic]:
25
+ diagnostics: List[Diagnostic] = []
26
+ project_path = context.project_path
27
+
28
+ env_file = project_path / ".env"
29
+ env_example = project_path / ".env.example"
30
+
31
+ # Read active environment variables (system + .env if present)
32
+ loaded_env = dict(os.environ)
33
+ if env_file.is_file():
34
+ loaded_env.update(parse_env_file(env_file))
35
+ context.env_vars = loaded_env
36
+
37
+ # 1. Check .env vs .env.example
38
+ if env_example.is_file() and not env_file.is_file():
39
+ cp_cmd = "copy .env.example .env" if is_windows() else "cp .env.example .env"
40
+
41
+ def copy_example_handler(ctx: ScanContext) -> bool:
42
+ try:
43
+ shutil.copy2(env_example, env_file)
44
+ return True
45
+ except Exception:
46
+ return False
47
+
48
+ diagnostics.append(Diagnostic(
49
+ title="Environment file missing",
50
+ severity=Severity.ERROR,
51
+ message=".env file is missing, but .env.example exists.",
52
+ why="The project provides a template configuration (.env.example), but no active .env file was found.",
53
+ evidence="Found .env.example, but .env was not found.",
54
+ fix="Create .env from template:\n " + cp_cmd,
55
+ command=cp_cmd,
56
+ confidence=Confidence.HIGH,
57
+ fix_action=FixAction(
58
+ description="Copy .env.example to .env",
59
+ command=cp_cmd,
60
+ safe=True,
61
+ handler=copy_example_handler,
62
+ ),
63
+ check_id=f"{self.name}_missing_env_file",
64
+ ))
65
+
66
+ elif env_example.is_file() and env_file.is_file():
67
+ missing_from_env = find_missing_env_keys(env_example, env_file)
68
+ for key in missing_from_env:
69
+ def make_add_key_handler(k: str):
70
+ def handler(ctx: ScanContext) -> bool:
71
+ try:
72
+ with open(env_file, "a", encoding="utf-8") as f:
73
+ f.write(f"\n{k}=\n")
74
+ return True
75
+ except Exception:
76
+ return False
77
+ return handler
78
+
79
+ diagnostics.append(Diagnostic(
80
+ title="Environment variable",
81
+ severity=Severity.ERROR,
82
+ message=f"{key} is required by .env.example but missing in .env.",
83
+ why=f"{key} is defined in .env.example, indicating it is required for project runtime.",
84
+ evidence="Defined in: .env.example",
85
+ fix=f"Add {key} to .env",
86
+ command=f'echo {key}= >> .env',
87
+ confidence=Confidence.HIGH,
88
+ fix_action=FixAction(
89
+ description=f"Add {key} placeholder to .env",
90
+ command=f'echo {key}= >> .env',
91
+ safe=True,
92
+ handler=make_add_key_handler(key),
93
+ ),
94
+ check_id=f"{self.name}_example_{key}",
95
+ metadata={"var_name": key, "source": ".env.example"},
96
+ ))
97
+
98
+ # 2. Check code files for required environment variable accesses (AST)
99
+ _, env_usages = scan_project_ast(project_path)
100
+ reported_vars = set()
101
+
102
+ for usage in env_usages:
103
+ var_name = usage.var_name
104
+ # Only strictly required environment variable accesses without defaults
105
+ if not usage.is_required or usage.has_default or var_name in reported_vars:
106
+ continue
107
+
108
+ # Check if set in loaded_env
109
+ if var_name not in loaded_env or not loaded_env[var_name].strip():
110
+ reported_vars.add(var_name)
111
+ try:
112
+ rel_file = usage.file_path.relative_to(project_path)
113
+ except Exception:
114
+ rel_file = usage.file_path.name
115
+
116
+ def make_append_var_handler(v: str):
117
+ def handler(ctx: ScanContext) -> bool:
118
+ try:
119
+ target = ctx.project_path / ".env"
120
+ with open(target, "a", encoding="utf-8") as f:
121
+ f.write(f"\n{v}=\n")
122
+ return True
123
+ except Exception:
124
+ return False
125
+ return handler
126
+
127
+ diagnostics.append(Diagnostic(
128
+ title="Environment variable",
129
+ severity=Severity.ERROR,
130
+ message=f"{var_name} is required but missing.",
131
+ why=f"Code in {rel_file} accesses os.environ['{var_name}'] without a default value.",
132
+ evidence=f"Found in:\n {rel_file} (line {usage.line_number})",
133
+ fix=f"Add {var_name} to .env",
134
+ command=f'echo {var_name}= >> .env',
135
+ confidence=Confidence.HIGH,
136
+ fix_action=FixAction(
137
+ description=f"Add {var_name} to .env",
138
+ command=f'echo {var_name}= >> .env',
139
+ safe=True,
140
+ handler=make_append_var_handler(var_name),
141
+ ),
142
+ check_id=f"{self.name}_code_{var_name}",
143
+ metadata={"var_name": var_name, "found_in": str(rel_file), "line": usage.line_number},
144
+ ))
145
+
146
+ return diagnostics
@@ -0,0 +1,96 @@
1
+ """FastAPI framework diagnostic checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import re
7
+ from typing import List
8
+
9
+ from devdoctor.checks.base import Check, register_check
10
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
11
+ from devdoctor.utils.ast_imports import collect_python_files
12
+ from devdoctor.utils.process_utils import is_port_in_use
13
+
14
+
15
+ @register_check
16
+ class FastAPICheck(Check):
17
+ name = "fastapi"
18
+ title = "FastAPI Framework Diagnostics"
19
+ category = "framework"
20
+ description = "Checks FastAPI setup: Uvicorn availability, default port 8000, and CORS configuration."
21
+
22
+ def run(self, context: ScanContext) -> List[Diagnostic]:
23
+ diagnostics: List[Diagnostic] = []
24
+ is_fastapi = context.project_info and "FastAPI" in context.project_info.frameworks
25
+
26
+ if not is_fastapi:
27
+ return diagnostics
28
+
29
+ # 1. Uvicorn installed check
30
+ uvicorn_spec = importlib.util.find_spec("uvicorn")
31
+ if uvicorn_spec:
32
+ diagnostics.append(Diagnostic(
33
+ title="Uvicorn installed",
34
+ severity=Severity.OK,
35
+ message="Uvicorn ASGI server is installed.",
36
+ check_id=f"{self.name}_uvicorn_installed",
37
+ ))
38
+ else:
39
+ diagnostics.append(Diagnostic(
40
+ title="Uvicorn not installed",
41
+ severity=Severity.ERROR,
42
+ message="Uvicorn is required to serve FastAPI applications.",
43
+ why="FastAPI applications need an ASGI server like Uvicorn to run.",
44
+ fix="Install uvicorn:\n pip install 'uvicorn[standard]'",
45
+ command="pip install 'uvicorn[standard]'",
46
+ confidence=Confidence.HIGH,
47
+ fix_action=FixAction(
48
+ description="Install uvicorn[standard]",
49
+ command="pip install 'uvicorn[standard]'",
50
+ safe=False,
51
+ ),
52
+ check_id=f"{self.name}_uvicorn_missing",
53
+ ))
54
+
55
+ # 2. Port 8000 check
56
+ if is_port_in_use(8000):
57
+ diagnostics.append(Diagnostic(
58
+ title="Port 8000 occupied",
59
+ severity=Severity.WARNING,
60
+ message="Default FastAPI port (8000) is currently occupied.",
61
+ why="Uvicorn defaults to port 8000; starting a new server without --port will fail.",
62
+ fix="Free port 8000 or specify another port:\n devdoctor port free 8000",
63
+ command="devdoctor port free 8000",
64
+ confidence=Confidence.HIGH,
65
+ check_id=f"{self.name}_port_8000",
66
+ ))
67
+ else:
68
+ diagnostics.append(Diagnostic(
69
+ title="Port 8000 available",
70
+ severity=Severity.OK,
71
+ message="Port 8000 is free and ready for Uvicorn.",
72
+ check_id=f"{self.name}_port_8000_free",
73
+ ))
74
+
75
+ # 3. CORS allows * check
76
+ py_files = collect_python_files(context.project_path)
77
+ for pf in py_files:
78
+ try:
79
+ txt = pf.read_text(encoding="utf-8", errors="replace")
80
+ if 'allow_origins=["*"]' in txt or "allow_origins=['*']" in txt:
81
+ rel_p = pf.relative_to(context.project_path) if pf.is_relative_to(context.project_path) else pf.name
82
+ diagnostics.append(Diagnostic(
83
+ title="CORS allows * wildcard",
84
+ severity=Severity.WARNING,
85
+ message="CORS middleware is configured with allow_origins=['*'].",
86
+ why="Allowing wildcard origins with credentials or in production allows any domain to send authenticated cross-origin requests.",
87
+ evidence=f"Found in:\n {rel_p}",
88
+ fix="Restrict allow_origins to specific frontend domains.",
89
+ confidence=Confidence.HIGH,
90
+ check_id=f"{self.name}_cors_wildcard",
91
+ ))
92
+ break
93
+ except Exception:
94
+ pass
95
+
96
+ return diagnostics
@@ -0,0 +1,72 @@
1
+ """Check filesystem permissions, disk space, and repository clutter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import List
9
+
10
+ from devdoctor.checks.base import Check, register_check
11
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
12
+
13
+
14
+ @register_check
15
+ class FilesystemCheck(Check):
16
+ name = "filesystem"
17
+ title = "Filesystem Integrity"
18
+ category = "filesystem"
19
+ description = "Checks filesystem permissions, write access, and unwanted repository clutter."
20
+
21
+ def run(self, context: ScanContext) -> List[Diagnostic]:
22
+ diagnostics: List[Diagnostic] = []
23
+ root = context.project_path
24
+
25
+ # 1. Test write permissions in project directory
26
+ try:
27
+ test_file = root / f".devdoctor_test_{os.getpid()}.tmp"
28
+ test_file.write_text("ok", encoding="utf-8")
29
+ test_file.unlink()
30
+ except Exception as ex:
31
+ diagnostics.append(Diagnostic(
32
+ title="Filesystem write permission denied",
33
+ severity=Severity.ERROR,
34
+ message=f"Project directory is not writable: {ex}",
35
+ why="DevDoctor and build tools require write access to create cache files, virtualenvs, and configs.",
36
+ evidence=f"Directory: {root}",
37
+ fix="Adjust directory permissions to grant write access to the current user.",
38
+ confidence=Confidence.HIGH,
39
+ check_id=f"{self.name}_write_permission",
40
+ ))
41
+
42
+ # 2. Check for large uncommitted files (> 50MB) in project (ignoring .git and .venv)
43
+ large_files = []
44
+ ignore_dirs = {".git", ".venv", "venv", "node_modules", "dist", "build"}
45
+ try:
46
+ for dirpath, dirnames, filenames in os.walk(root):
47
+ dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
48
+ for f in filenames:
49
+ fp = Path(dirpath) / f
50
+ try:
51
+ sz = fp.stat().st_size
52
+ if sz > 50 * 1024 * 1024: # 50MB
53
+ large_files.append((fp, sz))
54
+ except Exception:
55
+ pass
56
+ except Exception:
57
+ pass
58
+
59
+ if large_files:
60
+ file_list = "\n ".join([f"{fp.relative_to(root)} ({sz // (1024*1024)}MB)" for fp, sz in large_files[:3]])
61
+ diagnostics.append(Diagnostic(
62
+ title="Large files detected in repository",
63
+ severity=Severity.WARNING,
64
+ message=f"Found {len(large_files)} file(s) exceeding 50MB.",
65
+ why="Large files bloat git repository size and can cause git pushes to fail or slow down operations.",
66
+ evidence=f"Found:\n {file_list}",
67
+ fix="Track large files using Git LFS or add them to .gitignore.",
68
+ confidence=Confidence.HIGH,
69
+ check_id=f"{self.name}_large_files",
70
+ ))
71
+
72
+ return diagnostics
@@ -0,0 +1,151 @@
1
+ """Check Git availability, author configuration, and sensitive file tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import List
9
+
10
+ from devdoctor.checks.base import Check, register_check
11
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
12
+
13
+
14
+ @register_check
15
+ class GitCheck(Check):
16
+ name = "git"
17
+ title = "Git Configuration"
18
+ category = "git"
19
+ description = "Verifies Git CLI availability, repository status, and security of sensitive files."
20
+
21
+ def run(self, context: ScanContext) -> List[Diagnostic]:
22
+ diagnostics: List[Diagnostic] = []
23
+
24
+ # 1. Check Git CLI availability
25
+ git_version = None
26
+ try:
27
+ res = subprocess.run(["git", "--version"], capture_output=True, text=True, timeout=5)
28
+ if res.returncode == 0:
29
+ out = res.stdout.strip()
30
+ match = re.search(r"git version\s+([0-9]+(?:\.[0-9]+)+)", out)
31
+ git_version = match.group(1).rstrip(".") if match else "available"
32
+ diagnostics.append(Diagnostic(
33
+ title=f"Git {git_version}",
34
+ severity=Severity.OK,
35
+ message=f"Git is installed and available (version {git_version}).",
36
+ check_id=f"{self.name}_version",
37
+ metadata={"version": git_version},
38
+ ))
39
+ else:
40
+ diagnostics.append(Diagnostic(
41
+ title="Git CLI unavailable",
42
+ severity=Severity.WARNING,
43
+ message="Git command failed or is not installed.",
44
+ why="Git is required for version control, dependency tracking, and release management.",
45
+ fix="Install Git from https://git-scm.com/downloads.",
46
+ confidence=Confidence.HIGH,
47
+ check_id=f"{self.name}_missing",
48
+ ))
49
+ return diagnostics
50
+ except Exception:
51
+ diagnostics.append(Diagnostic(
52
+ title="Git CLI unavailable",
53
+ severity=Severity.WARNING,
54
+ message="Git is not installed or not in PATH.",
55
+ why="Version control commands and repository checks require git.",
56
+ fix="Install Git and ensure it is added to your PATH.",
57
+ confidence=Confidence.HIGH,
58
+ check_id=f"{self.name}_missing",
59
+ ))
60
+ return diagnostics
61
+
62
+ # 2. Check if this is a Git repository
63
+ git_dir = context.project_path / ".git"
64
+ if not git_dir.exists():
65
+ return diagnostics
66
+
67
+ # Check git config user.name and user.email
68
+ try:
69
+ res_name = subprocess.run(["git", "config", "user.name"], capture_output=True, text=True, cwd=str(context.project_path))
70
+ res_email = subprocess.run(["git", "config", "user.email"], capture_output=True, text=True, cwd=str(context.project_path))
71
+ has_name = bool(res_name.stdout.strip())
72
+ has_email = bool(res_email.stdout.strip())
73
+
74
+ if not (has_name and has_email):
75
+ diagnostics.append(Diagnostic(
76
+ title="Git author configuration",
77
+ severity=Severity.WARNING,
78
+ message="Git user.name or user.email is not configured.",
79
+ why="Commits cannot be properly authored without user name and email configured.",
80
+ evidence=f"user.name='{res_name.stdout.strip()}', user.email='{res_email.stdout.strip()}'",
81
+ fix="Set your Git author details:\n git config --global user.name \"Your Name\"\n git config --global user.email \"you@example.com\"",
82
+ command='git config --global user.name "Your Name" && git config --global user.email "you@example.com"',
83
+ confidence=Confidence.HIGH,
84
+ check_id=f"{self.name}_author",
85
+ ))
86
+ except Exception:
87
+ pass
88
+
89
+ # 3. Check .gitignore for .env
90
+ env_file = context.project_path / ".env"
91
+ gitignore_file = context.project_path / ".gitignore"
92
+
93
+ if env_file.is_file():
94
+ # Check if .env is ignored
95
+ is_ignored = False
96
+ if gitignore_file.is_file():
97
+ try:
98
+ lines = [line.strip() for line in gitignore_file.read_text(encoding="utf-8", errors="replace").splitlines()]
99
+ is_ignored = any(line in [".env", "*.env", ".env*", "/.env"] for line in lines)
100
+ except Exception:
101
+ pass
102
+
103
+ if not is_ignored:
104
+ diagnostics.append(Diagnostic(
105
+ title="Sensitive file .env not ignored",
106
+ severity=Severity.ERROR,
107
+ message=".env file exists but is not listed in .gitignore, risking secret leaks.",
108
+ why="Committing .env files exposes sensitive credentials, secret keys, and database passwords in git history.",
109
+ evidence="Found .env in project root, but '.env' pattern was not found in .gitignore.",
110
+ fix="Add .env to .gitignore",
111
+ command="echo .env >> .gitignore",
112
+ confidence=Confidence.HIGH,
113
+ fix_action=FixAction(
114
+ description="Add .env to .gitignore",
115
+ safe=True,
116
+ action_type="file_append",
117
+ target_path=gitignore_file,
118
+ content=".env",
119
+ ),
120
+ check_id=f"{self.name}_gitignore_env",
121
+ ))
122
+
123
+ # Check if .env is already tracked in git index
124
+ try:
125
+ res_tracked = subprocess.run(
126
+ ["git", "ls-files", "--error-unmatch", ".env"],
127
+ capture_output=True,
128
+ text=True,
129
+ cwd=str(context.project_path),
130
+ )
131
+ if res_tracked.returncode == 0:
132
+ diagnostics.append(Diagnostic(
133
+ title=".env is tracked by Git",
134
+ severity=Severity.ERROR,
135
+ message=".env file is tracked in git index! Secrets may already be committed.",
136
+ why="A tracked .env will be pushed to remotes, compromising credentials.",
137
+ evidence="git ls-files .env confirmed tracking.",
138
+ fix="Remove .env from git cache:\n git rm --cached .env",
139
+ command="git rm --cached .env",
140
+ confidence=Confidence.HIGH,
141
+ fix_action=FixAction(
142
+ description="Remove .env from git cache",
143
+ command="git rm --cached .env",
144
+ safe=False,
145
+ ),
146
+ check_id=f"{self.name}_tracked_env",
147
+ ))
148
+ except Exception:
149
+ pass
150
+
151
+ return diagnostics
@@ -0,0 +1,47 @@
1
+ """Check for missing or uninstalled Python imports in project code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from devdoctor.checks.base import Check, register_check
8
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
9
+ from devdoctor.utils.ast_imports import find_missing_installed_imports
10
+
11
+
12
+ @register_check
13
+ class BrokenImportsCheck(Check):
14
+ name = "broken_imports"
15
+ title = "Python Imports"
16
+ category = "dependencies"
17
+ description = "Scans project Python files for third-party imports that are not installed in the active environment."
18
+
19
+ def run(self, context: ScanContext) -> List[Diagnostic]:
20
+ diagnostics: List[Diagnostic] = []
21
+ missing = find_missing_installed_imports(context.project_path)
22
+
23
+ for mod, file_path, line_no in missing:
24
+ try:
25
+ rel_path = file_path.relative_to(context.project_path)
26
+ except Exception:
27
+ rel_path = file_path.name
28
+
29
+ diagnostics.append(Diagnostic(
30
+ title=f"Missing module '{mod}'",
31
+ severity=Severity.ERROR,
32
+ message=f"Module '{mod}' is imported but not installed in current environment.",
33
+ why=f"Code in {rel_path} requires '{mod}', but the package is not found in site-packages.",
34
+ evidence=f"Found in:\n {rel_path} (line {line_no})",
35
+ fix=f"Install {mod} via pip:\n pip install {mod}",
36
+ command=f"pip install {mod}",
37
+ confidence=Confidence.HIGH,
38
+ fix_action=FixAction(
39
+ description=f"Install {mod}",
40
+ command=f"pip install {mod}",
41
+ safe=False,
42
+ ),
43
+ check_id=f"{self.name}_{mod}",
44
+ metadata={"module": mod, "file": str(rel_path), "line": line_no},
45
+ ))
46
+
47
+ return diagnostics
@@ -0,0 +1,67 @@
1
+ """Check pip installation and availability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import sys
8
+
9
+ from devdoctor.checks.base import Check, register_check
10
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
11
+
12
+
13
+ @register_check
14
+ class PipAvailabilityCheck(Check):
15
+ name = "pip_availability"
16
+ title = "pip Availability"
17
+ category = "system"
18
+ description = "Checks that pip is available and functional in the active environment."
19
+
20
+ def run(self, context: ScanContext) -> Diagnostic:
21
+ try:
22
+ res = subprocess.run(
23
+ [sys.executable, "-m", "pip", "--version"],
24
+ capture_output=True,
25
+ text=True,
26
+ timeout=5,
27
+ )
28
+ if res.returncode == 0:
29
+ output = res.stdout.strip()
30
+ # e.g., "pip 25.0.1 from ..."
31
+ match = re.search(r"pip\s+([0-9.]+)", output)
32
+ version_str = match.group(1) if match else "available"
33
+ return Diagnostic(
34
+ title=f"pip {version_str}",
35
+ severity=Severity.OK,
36
+ message=f"pip is installed and functional (version {version_str}).",
37
+ check_id=self.name,
38
+ metadata={"version": version_str},
39
+ )
40
+ else:
41
+ return Diagnostic(
42
+ title="pip unavailable",
43
+ severity=Severity.ERROR,
44
+ message="pip command failed to execute in active Python environment.",
45
+ why="pip might be corrupt or missing in this Python installation.",
46
+ evidence=res.stderr.strip() or res.stdout.strip(),
47
+ fix="Run ensurepip to bootstrap pip into this environment.",
48
+ command=f"{sys.executable} -m ensurepip --upgrade",
49
+ confidence=Confidence.HIGH,
50
+ fix_action=FixAction(
51
+ description="Bootstrap pip via ensurepip",
52
+ command=f"{sys.executable} -m ensurepip --upgrade",
53
+ safe=True,
54
+ ),
55
+ check_id=self.name,
56
+ )
57
+ except Exception as ex:
58
+ return Diagnostic(
59
+ title="pip unavailable",
60
+ severity=Severity.ERROR,
61
+ message=f"Unable to execute pip: {ex}",
62
+ why="Python executable cannot find or execute the pip module.",
63
+ fix="Bootstrap pip or repair Python installation.",
64
+ command=f"{sys.executable} -m ensurepip --upgrade",
65
+ confidence=Confidence.HIGH,
66
+ check_id=self.name,
67
+ )
@@ -0,0 +1,90 @@
1
+ """Check port availability and detect process conflicts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import List, Set
7
+
8
+ from devdoctor.checks.base import Check, register_check
9
+ from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
10
+ from devdoctor.utils.process_utils import get_port_listener_info, is_port_in_use
11
+
12
+
13
+ @register_check
14
+ class PortConflictsCheck(Check):
15
+ name = "port_conflicts"
16
+ title = "Port Conflicts"
17
+ category = "network"
18
+ description = "Checks common development ports for process occupation and port collisions."
19
+
20
+ def run(self, context: ScanContext) -> List[Diagnostic]:
21
+ diagnostics: List[Diagnostic] = []
22
+
23
+ # Determine ports to check based on project and common dev ports
24
+ target_ports: Set[int] = set()
25
+
26
+ # Check project type defaults
27
+ proj = context.project_info
28
+ if proj:
29
+ if proj.project_type in ("Django", "FastAPI"):
30
+ target_ports.add(8000)
31
+ elif proj.project_type == "Flask":
32
+ target_ports.add(5000)
33
+ if "Node.js" in proj.technologies:
34
+ target_ports.update([3000, 5173])
35
+
36
+ # Check .env for PORT=...
37
+ if context.env_vars:
38
+ for k, v in context.env_vars.items():
39
+ if "PORT" in k:
40
+ try:
41
+ p = int(v.strip())
42
+ if 1 <= p <= 65535:
43
+ target_ports.add(p)
44
+ except ValueError:
45
+ pass
46
+
47
+ # If no specific ports found, check the standard dev ports
48
+ if not target_ports:
49
+ target_ports = {8000, 3000, 5000, 5173, 8080}
50
+
51
+ for port in sorted(list(target_ports)):
52
+ if is_port_in_use(port):
53
+ info = get_port_listener_info(port)
54
+ proc_name = info.process_name if info else "unknown"
55
+ pid = info.pid if info else 0
56
+
57
+ evidence_lines = []
58
+ if proc_name and proc_name != "unknown":
59
+ evidence_lines.append(f"Process: {proc_name}")
60
+ if pid > 0:
61
+ evidence_lines.append(f"PID: {pid}")
62
+
63
+ evidence_text = "\n ".join(evidence_lines) if evidence_lines else "Listening on local interface"
64
+ fix_cmd = f"devdoctor port free {port}"
65
+
66
+ fix_action = None
67
+ if pid > 0:
68
+ fix_action = FixAction(
69
+ description=f"Kill process {proc_name} (PID: {pid}) on port {port}",
70
+ command=fix_cmd,
71
+ safe=False,
72
+ action_type="kill_process",
73
+ metadata={"port": port, "pid": pid, "process_name": proc_name},
74
+ )
75
+
76
+ diagnostics.append(Diagnostic(
77
+ title=f"Port {port}",
78
+ severity=Severity.WARNING,
79
+ message="Port is already occupied.",
80
+ why=f"Another process ({proc_name}) is already listening on port {port}, which prevents starting the local dev server.",
81
+ evidence=evidence_text,
82
+ fix=f"devdoctor port free {port}",
83
+ command=fix_cmd,
84
+ confidence=Confidence.HIGH,
85
+ fix_action=fix_action,
86
+ check_id=f"{self.name}_{port}",
87
+ metadata={"port": port, "pid": pid, "process_name": proc_name},
88
+ ))
89
+
90
+ return diagnostics