python-qv 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,281 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-qv
3
+ Version: 0.1.0
4
+ Summary: Diagnose why a Python project is unhealthy — explain root cause and suggest safe fixes.
5
+ Project-URL: Homepage, https://github.com/inzamol/qv
6
+ Project-URL: Repository, https://github.com/inzamol/qv
7
+ Project-URL: Documentation, https://github.com/inzamol/qv#readme
8
+ Project-URL: Issues, https://github.com/inzamol/qv/issues
9
+ Author: Inzamul Hoque
10
+ License-Expression: MIT
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Bug Tracking
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: click>=8.1.0
23
+ Requires-Dist: packaging>=23.2
24
+ Requires-Dist: pydantic>=2.5.0
25
+ Requires-Dist: rich>=13.7.0
26
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
27
+ Provides-Extra: dev
28
+ Requires-Dist: pyright>=1.1.350; extra == 'dev'
29
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # 🔍 qv
35
+
36
+ <div align="center">
37
+
38
+ **Diagnose why your Python project is unhealthy — understand the root cause and get safe, actionable fixes.**
39
+
40
+ [![PyPI Version](https://img.shields.io/pypi/v/python-qv.svg?color=blue)](https://pypi.org/project/python-qv/)
41
+ [![Python Versions](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://pypi.org/project/python-qv/)
42
+ [![CI Status](https://github.com/inzamol/qv/actions/workflows/ci.yml/badge.svg)](https://github.com/inzamol/qv/actions/workflows/ci.yml)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
44
+
45
+ [Installation](#-installation) • [Quick Start](#-quick-start) • [Features](#-what-it-detects) • [CLI Commands](#-cli-commands) • [CI/CD Integration](#-cicd-integration) • [Documentation](docs/getting_started.md)
46
+
47
+ </div>
48
+
49
+ ---
50
+
51
+ ## 💡 Why qv?
52
+
53
+ Python projects rarely fail because of Python syntax. They fail because of **ecosystem friction**:
54
+ - Incompatible transitive dependency constraints that break your resolver.
55
+ - Missing dependencies you forgot to add to `pyproject.toml`.
56
+ - Docker containers running Python 3.10 while your team develops on 3.12.
57
+ - Silent circular imports that only crash at runtime when certain modules load.
58
+
59
+ Instead of parsing hundreds of lines of cryptic resolver logs, **`qv`** scans your project in milliseconds, pinpoints the root cause, shows the exact evidence, and gives you a copy-paste command to fix it.
60
+
61
+ ```text
62
+ 🔍 qv
63
+ Project: payment-service
64
+ Python: 3.12.7
65
+ Package Manager: uv
66
+
67
+ 🔴 1 Errors 🟡 1 Warnings 🟢 48 Checks Passed
68
+
69
+ ┌────────────────── 🔴 DEP-001 Dependency constraint conflict ─────────────────┐
70
+ │ celery 5.4.0 requires kombu<5.4.0,>=5.3.0, but installed is kombu 5.5.2. │
71
+ │ │
72
+ │ Evidence: │
73
+ │ • celery declared requirement: kombu<5.4.0,>=5.3.0 │
74
+ │ • Installed kombu version: 5.5.2 in active environment │
75
+ │ │
76
+ │ Suggested fix: │
77
+ │ Upgrade celery or pin kombu to <5.4.0,>=5.3.0. │
78
+ │ $ uv add 'kombu<5.4.0,>=5.3.0' │
79
+ └──────────────────────────────────────────────────────────────────────────────┘
80
+
81
+ Health Score: 85/100
82
+ ```
83
+
84
+ ---
85
+
86
+ ## 📦 Installation
87
+
88
+ Install `python-qv` into your virtual environment (provides the `qv` CLI):
89
+
90
+ ```bash
91
+ # Using pip
92
+ pip install python-qv
93
+
94
+ # Using uv
95
+ uv add python-qv --dev
96
+
97
+ # Run directly without installing (via uvx or pipx)
98
+ uvx python-qv scan
99
+ # or
100
+ pipx run python-qv scan
101
+ ```
102
+
103
+ ---
104
+
105
+ ## 🚀 Quick Start
106
+
107
+ ### 1. Run a Health Scan
108
+
109
+ Run `qv scan` inside any Python repository:
110
+
111
+ ```bash
112
+ qv scan
113
+ ```
114
+
115
+ ### 2. Understand Any Flagged Issue
116
+
117
+ Need more context on why a rule triggered? Run `explain`:
118
+
119
+ ```bash
120
+ qv explain DEP-001
121
+ ```
122
+
123
+ ### 3. Add Project Configuration
124
+
125
+ To add default configuration to your `pyproject.toml`:
126
+
127
+ ```bash
128
+ qv init
129
+ ```
130
+
131
+ ---
132
+
133
+ ## 🔍 What It Detects
134
+
135
+ | Category | Rule ID | Description | Default Severity |
136
+ |---|---|---|---|
137
+ | **Dependencies** | `DEP-001` | Incompatible package version constraints across dependency tree | `ERROR` |
138
+ | | `DEP-002` | Third-party packages imported in code but missing from `pyproject.toml` | `ERROR` |
139
+ | | `DEP-003` | Declared dependencies that are never imported anywhere in project | `WARNING` |
140
+ | | `DEP-004` | Package requires a Python version incompatible with target runtime | `WARNING` |
141
+ | | `DEP-005` | Installed virtualenv version does not match declared manifest pin | `WARNING` |
142
+ | **Environment** | `ENV-001` | Active interpreter version differs from project `requires-python` | `WARNING` |
143
+ | | `ENV-002` | Dockerfile base image Python version differs from project runtime | `WARNING` |
144
+ | | `ENV-003` | CI matrix does not cover the Python versions declared in project | `WARNING` |
145
+ | **Architecture** | `IMP-001` | Circular import cycles across local modules | `ERROR` |
146
+ | | `IMP-002` | Unresolved relative or internal module imports | `ERROR` |
147
+ | **Packaging** | `PKG-001` | Missing PEP 621 metadata (name, version, etc.) | `WARNING` |
148
+ | | `PKG-002` | Invalid syntax or malformed keys in `pyproject.toml` | `ERROR` |
149
+
150
+ 👉 *See full explanations and remediation steps in the [Rules Catalog](docs/rules.md).*
151
+
152
+ ---
153
+
154
+ ## 🛠️ CLI Commands
155
+
156
+ ```bash
157
+ # Full project diagnostic scan
158
+ qv scan
159
+
160
+ # Scan another folder
161
+ qv scan ./services/billing
162
+
163
+ # Strict mode: fail CI on warnings as well as errors
164
+ qv scan --strict
165
+
166
+ # Output machine-readable formats
167
+ qv scan --json
168
+ qv scan --sarif -o results.sarif
169
+
170
+ # Run focused subsystem scans
171
+ qv dependency # Check package constraints & imports
172
+ qv environment # Check Python, Docker & CI version drift
173
+ qv architecture # Check for circular imports & dead paths
174
+
175
+ # Explain a rule
176
+ qv explain DEP-002
177
+
178
+ # Check installed version
179
+ qv version
180
+ ```
181
+
182
+ 👉 *See detailed options in the [CLI Reference](docs/cli_reference.md).*
183
+
184
+ ---
185
+
186
+ ## ⚙️ Configuration
187
+
188
+ Configure `qv` in your `pyproject.toml`:
189
+
190
+ ```toml
191
+ [tool.qv]
192
+
193
+ # Override severity for any rule (error, warning, info, off)
194
+ [tool.qv.rules]
195
+ DEP-001 = "error"
196
+ DEP-003 = "warning"
197
+ ENV-002 = "info"
198
+
199
+ # Ignore specific rules
200
+ [tool.qv.ignore]
201
+ rules = ["DEP-004"]
202
+
203
+ # Exclude directories from scanning
204
+ [tool.qv.paths]
205
+ exclude = [
206
+ ".venv",
207
+ "build",
208
+ "dist",
209
+ "legacy_scripts",
210
+ ]
211
+
212
+ # Set expected target Python version
213
+ [tool.qv.runtime]
214
+ python = "3.12"
215
+ ```
216
+
217
+ 👉 *Learn more in the [Configuration Guide](docs/configuration.md).*
218
+
219
+ ---
220
+
221
+ ## 🤖 CI/CD Integration
222
+
223
+ ### GitHub Actions (with SARIF code scanning)
224
+
225
+ Add this step to your GitHub Actions workflow (`.github/workflows/ci.yml`):
226
+
227
+ ```yaml
228
+ name: Health & Dependency Scan
229
+
230
+ on: [push, pull_request]
231
+
232
+ jobs:
233
+ qv:
234
+ runs-on: ubuntu-latest
235
+ steps:
236
+ - uses: actions/checkout@v4
237
+ - uses: astral-sh/setup-uv@v3
238
+
239
+ # Run scan in CI mode
240
+ - name: Run qv
241
+ run: uv run qv scan --ci
242
+
243
+ # Optional: Generate SARIF report for GitHub Code Scanning
244
+ - name: Generate SARIF report
245
+ run: uv run qv scan --sarif -o qv.sarif
246
+ if: always()
247
+
248
+ - name: Upload SARIF to GitHub Security
249
+ uses: github/codeql-action/upload-sarif@v3
250
+ with:
251
+ sarif_file: qv.sarif
252
+ if: always()
253
+ ```
254
+
255
+ 👉 *See full details in [CI/CD Integration](docs/ci_integration.md).*
256
+
257
+ ---
258
+
259
+ ## 🎯 Design Principles
260
+
261
+ - **Diagnose first. Explain second. Fix safely.**
262
+ - **No destructive auto-mutations**: Remediations provide the exact commands/diffs for you to review and apply.
263
+ - **Local-first & Blazing fast**: Zero network calls required; scans complete in under a second.
264
+ - **Package-manager agnostic**: Works out of the box with `uv`, Poetry, `pip`, PDM, and Pipenv.
265
+
266
+ ---
267
+
268
+ ## 📚 Complete Documentation
269
+
270
+ - 🚀 [Getting Started Guide](docs/getting_started.md)
271
+ - 📖 [CLI Reference](docs/cli_reference.md)
272
+ - 📋 [Diagnostic Rules Catalog](docs/rules.md)
273
+ - ⚙️ [Configuration Guide](docs/configuration.md)
274
+ - 🤖 [CI/CD & SARIF Integration](docs/ci_integration.md)
275
+ - 🤝 [Contributing Guidelines](docs/contributing.md)
276
+
277
+ ---
278
+
279
+ ## 📄 License
280
+
281
+ Distributed under the [MIT License](LICENSE).
@@ -0,0 +1,33 @@
1
+ qv/__init__.py,sha256=v4AKHyYLemGctPfnaZ7qLHlpL1dgam5uAkTk1qzXUQw,100
2
+ qv/analyzers/__init__.py,sha256=_d-1_NpNFMDu1cT-SqTKRBBeipboWZlRt5zZ0tsVPk0,25
3
+ qv/analyzers/ci/__init__.py,sha256=W0Kr4IJBwjysEJPY-D3wNeDMWVFOFQzZt71LIzRDGfY,20
4
+ qv/analyzers/dependencies/__init__.py,sha256=AfzPubOgDrmbfPVe8I5egJ6tswolYzWsDwXjW-Maqhg,129
5
+ qv/analyzers/dependencies/analyzer.py,sha256=gtQeMuhUXs29cEn3RcRkWCXQzVHTs1PbE4_tVzSHhUQ,14075
6
+ qv/analyzers/docker/__init__.py,sha256=zQsRHV9IiRbjpb9AKmKzaMPtlXp1iSQCBSvyKDo-2q8,24
7
+ qv/analyzers/environment/__init__.py,sha256=5EtSq3sQIJJxKAfpwWiCul3VHWMA94y3-QBi5pjHVb8,128
8
+ qv/analyzers/environment/drift.py,sha256=tsu3Oi5Bng8e69Cvd6SBWsdyuK3Ut72saFvRpEjY-2I,7193
9
+ qv/analyzers/imports/__init__.py,sha256=bShRWSwJunl5mbkHI738-jTut0RGStVQhtdzCae-fuo,112
10
+ qv/analyzers/imports/analyzer.py,sha256=Vw0VKulyHcwONg9hkXEI2LfbMUxF4MZrNZVnaJnzSKw,6335
11
+ qv/analyzers/packaging/__init__.py,sha256=owgIeiZUNwhn-gPLbho1jCqA72cg1f8u_6wsqDqsgdk,123
12
+ qv/analyzers/packaging/analyzer.py,sha256=-57OYQPsS6uIN8u_1LR2CQRT8sOcDmYYCtGkgFo4jo0,4776
13
+ qv/analyzers/security/__init__.py,sha256=LsUd6Rerxa9qt3U0bP1uiNyEbs0doVtN8zvrXkjZOJY,26
14
+ qv/cli/__init__.py,sha256=EUnPKm0L_oVbU1jAO_Icle4TXMCVGpd_aFTQ4iaueik,88
15
+ qv/cli/main.py,sha256=eewmxZF9dpcUTITY2qwVpAFa8NRF2JW7LhmBJzW4ppo,8112
16
+ qv/core/analyzer.py,sha256=FPiEjghPbi-RVCOxODE_BRlkvtMsnrgbakhq7-YORuc,503
17
+ qv/core/config.py,sha256=LW8zjnuNlosQdqumbvycBu8fGe_Neg2fWpB_vdhNHVA,3629
18
+ qv/core/context.py,sha256=kAI_QENd6c9kRiIE5Ejt2Um4MZsQ3TL9ubkl3w5AJF0,2575
19
+ qv/core/engine.py,sha256=p4Lg8teRj8EgoVqc26TaK1SrijOKRHtBKrDnsE8BZ7w,4400
20
+ qv/core/models.py,sha256=9TDMASdnS_vaayjZSAu2kLUk-KPOKU6ikZlQr6KFSM0,3728
21
+ qv/core/project.py,sha256=1aSdCrlp1EbygAHhW3ptGx3_GwJKL-FwJrhQhO5mozc,12906
22
+ qv/frameworks/__init__.py,sha256=5XnOOPd2Kyb9uG3Z7MjqZO1emDZs5F1sCAmI4yjWMPI,25
23
+ qv/integrations/__init__.py,sha256=xBWG_BBOAn4l43MnjXmhbu4qjiMdYq32pVjmDO0Q8iM,37
24
+ qv/reporters/__init__.py,sha256=bTNB9uOshW9cgKv2Oja2uy4UrY5HJufUa0TzcLwO2fY,309
25
+ qv/reporters/base.py,sha256=LvtGAy5xocwB8gqYuAvFEIO73ov7BwXa06Azj_Pnuu4,329
26
+ qv/reporters/json_reporter.py,sha256=RRzEZAMpOw8HGEVbYV8n_PCJ0PrIvFlOgAGptUxvtvY,446
27
+ qv/reporters/sarif.py,sha256=CZEtjmqB7sNCc4qZJBLtm_7cT_gYa8frI1jl9JHzuyU,3062
28
+ qv/reporters/terminal.py,sha256=H5c5-HMKSZ2PIHx-ZE4XA5bhMH826JHzBav0EKFqU44,4549
29
+ qv/rules/registry.py,sha256=EkH_1CcmGwUWkldU2D9ZZATEZx6vxaWqs9Sf9LzVDKA,7410
30
+ python_qv-0.1.0.dist-info/METADATA,sha256=RmVlo5zNtDYwxBkpoZBY46m9uAfoJ3iPP64KfwepbAE,9046
31
+ python_qv-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
32
+ python_qv-0.1.0.dist-info/entry_points.txt,sha256=y0aZqfDPndkJ2yXxBM2RLL1qriH72zWEKSj4nJtc2q4,94
33
+ python_qv-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ pydoctor = qv.cli.main:cli
3
+ python-qv = qv.cli.main:cli
4
+ qv = qv.cli.main:cli
qv/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """qv - Package-manager-agnostic diagnostic platform for Python projects."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Analyzers package."""
@@ -0,0 +1 @@
1
+ """CI analyzers."""
@@ -0,0 +1,5 @@
1
+ """Dependency analyzers."""
2
+
3
+ from qv.analyzers.dependencies.analyzer import DependencyAnalyzer
4
+
5
+ __all__ = ["DependencyAnalyzer"]
@@ -0,0 +1,347 @@
1
+ """Dependency analyzer implementing DEP-001, DEP-002, DEP-003, DEP-004, DEP-005."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from packaging.requirements import Requirement
8
+ from packaging.specifiers import SpecifierSet
9
+ from packaging.version import Version
10
+
11
+ from qv.core.context import ProjectContext
12
+ from qv.core.models import Diagnostic, Evidence, Suggestion
13
+ from qv.rules.registry import get_rule_definition
14
+
15
+ # Known Python standard library modules (Python 3.10+)
16
+ STDLIB_MODULES = (
17
+ set(sys.stdlib_module_names)
18
+ if hasattr(sys, "stdlib_module_names")
19
+ else {
20
+ "abc",
21
+ "argparse",
22
+ "ast",
23
+ "asyncio",
24
+ "base64",
25
+ "collections",
26
+ "contextlib",
27
+ "csv",
28
+ "dataclasses",
29
+ "datetime",
30
+ "decimal",
31
+ "difflib",
32
+ "dis",
33
+ "enum",
34
+ "functools",
35
+ "glob",
36
+ "hashlib",
37
+ "http",
38
+ "importlib",
39
+ "inspect",
40
+ "io",
41
+ "itertools",
42
+ "json",
43
+ "logging",
44
+ "math",
45
+ "multiprocessing",
46
+ "os",
47
+ "pathlib",
48
+ "pickle",
49
+ "pprint",
50
+ "random",
51
+ "re",
52
+ "shutil",
53
+ "socket",
54
+ "sqlite3",
55
+ "ssl",
56
+ "string",
57
+ "subprocess",
58
+ "sys",
59
+ "tempfile",
60
+ "threading",
61
+ "time",
62
+ "traceback",
63
+ "typing",
64
+ "unittest",
65
+ "urllib",
66
+ "uuid",
67
+ "warnings",
68
+ "weakref",
69
+ "xml",
70
+ "zipfile",
71
+ "__future__",
72
+ }
73
+ )
74
+
75
+ # Known tools/packages that may not be imported directly in application code
76
+ IGNORED_UNUSED = {
77
+ "pytest",
78
+ "pytest-cov",
79
+ "ruff",
80
+ "black",
81
+ "mypy",
82
+ "pyright",
83
+ "flake8",
84
+ "isort",
85
+ "hatchling",
86
+ "flit",
87
+ "setuptools",
88
+ "wheel",
89
+ "gunicorn",
90
+ "uvicorn",
91
+ }
92
+
93
+
94
+ class DependencyAnalyzer:
95
+ """Analyzes declared and installed dependencies for conflicts and anomalies."""
96
+
97
+ id = "dependencies"
98
+ name = "Dependency Analyzer"
99
+ description = (
100
+ "Checks for dependency conflicts, missing imports, unused packages, and version mismatches."
101
+ )
102
+
103
+ def analyze(self, context: ProjectContext) -> list[Diagnostic]:
104
+ diagnostics: list[Diagnostic] = []
105
+
106
+ # Run individual sub-checks
107
+ diagnostics.extend(self._check_installed_conflicts(context))
108
+ diagnostics.extend(self._check_missing_dependencies(context))
109
+ diagnostics.extend(self._check_unused_dependencies(context))
110
+ diagnostics.extend(self._check_declaration_mismatch(context))
111
+
112
+ return diagnostics
113
+
114
+ def _check_installed_conflicts(self, context: ProjectContext) -> list[Diagnostic]:
115
+ """DEP-001: Check if any installed package's dependencies conflict with other installed packages."""
116
+ diagnostics: list[Diagnostic] = []
117
+ rule = get_rule_definition("DEP-001")
118
+ if not rule:
119
+ return diagnostics
120
+
121
+ for dist in context.installed_packages.values():
122
+ for req_str in dist.requires:
123
+ try:
124
+ req = Requirement(req_str)
125
+ # Ignore marker evaluation failure or unfulfilled markers for other OS/Python
126
+ if req.marker and not req.marker.evaluate():
127
+ continue
128
+
129
+ dep_name = req.name.lower()
130
+ if dep_name in context.installed_packages:
131
+ installed_dep = context.installed_packages[dep_name]
132
+ try:
133
+ dep_ver = Version(installed_dep.version)
134
+ if req.specifier and not req.specifier.contains(
135
+ dep_ver, prereleases=True
136
+ ):
137
+ diag = Diagnostic(
138
+ id=rule.id,
139
+ severity=rule.default_severity,
140
+ category=rule.category,
141
+ title=rule.title,
142
+ message=f"{dist.name} requires {req.name}{req.specifier}, but installed is {installed_dep.name} {installed_dep.version}.",
143
+ evidence=[
144
+ Evidence(
145
+ fact=f"{dist.name} declared requirement: {req_str}",
146
+ source=f"{dist.name} metadata",
147
+ ),
148
+ Evidence(
149
+ fact=f"Installed {installed_dep.name} version: {installed_dep.version}",
150
+ source="Active environment",
151
+ ),
152
+ ],
153
+ suggestions=[
154
+ Suggestion(
155
+ description=f"Upgrade {dist.name} or pin {installed_dep.name} to {req.specifier}.",
156
+ command=f"{context.package_manager} add '{installed_dep.name}{req.specifier}'"
157
+ if context.package_manager in ("uv", "poetry")
158
+ else f"pip install '{installed_dep.name}{req.specifier}'",
159
+ )
160
+ ],
161
+ affected_packages=[dist.name, installed_dep.name],
162
+ doc_url=rule.doc_url,
163
+ )
164
+ diagnostics.append(diag)
165
+ except Exception:
166
+ pass
167
+ except Exception:
168
+ pass
169
+
170
+ return diagnostics
171
+
172
+ def _check_missing_dependencies(self, context: ProjectContext) -> list[Diagnostic]:
173
+ """DEP-002: Check for imported packages not declared in dependencies."""
174
+ diagnostics: list[Diagnostic] = []
175
+ rule = get_rule_definition("DEP-002")
176
+ if not rule:
177
+ return diagnostics
178
+
179
+ declared_names = {d.name.lower().replace("-", "_"): d for d in context.dependencies}
180
+ declared_raw_names = {d.name.lower(): d for d in context.dependencies}
181
+
182
+ # Collect local top-level module names
183
+ local_modules = {
184
+ context.project_name.lower().replace("-", "_"),
185
+ context.project_name.lower(),
186
+ }
187
+ for sf in context.source_files:
188
+ parts = sf.relative_path.parts
189
+ if parts:
190
+ if parts[0] in ("src", "lib") and len(parts) > 1:
191
+ local_modules.add(parts[1].replace(".py", "").lower().replace("-", "_"))
192
+ local_modules.add(parts[0].replace(".py", "").lower().replace("-", "_"))
193
+
194
+ seen_missing: set[str] = set()
195
+
196
+ for imp in context.imports:
197
+ if imp.is_relative or not imp.module_name:
198
+ continue
199
+
200
+ top_level = imp.module_name.split(".")[0]
201
+ top_level_norm = top_level.lower().replace("-", "_")
202
+
203
+ if (
204
+ top_level in STDLIB_MODULES
205
+ or top_level in local_modules
206
+ or top_level_norm in local_modules
207
+ or top_level_norm in declared_names
208
+ or top_level.lower() in declared_raw_names
209
+ ):
210
+ continue
211
+
212
+ # If it's missing from declared dependencies
213
+ if top_level_norm not in seen_missing:
214
+ seen_missing.add(top_level_norm)
215
+ rel_path = (
216
+ imp.source_file.relative_to(context.project_root)
217
+ if imp.source_file.is_relative_to(context.project_root)
218
+ else imp.source_file
219
+ )
220
+ diag = Diagnostic(
221
+ id=rule.id,
222
+ severity=rule.default_severity,
223
+ category=rule.category,
224
+ title=f"Missing dependency: {top_level}",
225
+ message=f"Module '{top_level}' is imported in {rel_path}:{imp.line_number} but is not declared in project dependencies.",
226
+ evidence=[
227
+ Evidence(
228
+ fact=f"Import statement: `import {imp.module_name}` in {rel_path}:{imp.line_number}",
229
+ source=str(rel_path),
230
+ )
231
+ ],
232
+ suggestions=[
233
+ Suggestion(
234
+ description=f"Add '{top_level}' to project dependencies in pyproject.toml.",
235
+ command=f"{context.package_manager} add {top_level}"
236
+ if context.package_manager in ("uv", "poetry")
237
+ else f"pip install {top_level}",
238
+ )
239
+ ],
240
+ affected_packages=[top_level],
241
+ file=str(rel_path),
242
+ line=imp.line_number,
243
+ doc_url=rule.doc_url,
244
+ )
245
+ diagnostics.append(diag)
246
+
247
+ return diagnostics
248
+
249
+ def _check_unused_dependencies(self, context: ProjectContext) -> list[Diagnostic]:
250
+ """DEP-003: Check for declared direct dependencies that have no detected imports."""
251
+ diagnostics: list[Diagnostic] = []
252
+ rule = get_rule_definition("DEP-003")
253
+ if not rule or not context.source_files:
254
+ return diagnostics
255
+
256
+ imported_top_levels = {
257
+ imp.module_name.split(".")[0].lower().replace("-", "_")
258
+ for imp in context.imports
259
+ if imp.module_name
260
+ }
261
+
262
+ for dep in context.dependencies:
263
+ if dep.is_dev:
264
+ continue
265
+
266
+ dep_norm = dep.name.lower().replace("-", "_")
267
+ if dep.name.lower() in IGNORED_UNUSED or dep_norm in IGNORED_UNUSED:
268
+ continue
269
+
270
+ if dep_norm not in imported_top_levels and dep.name.lower() not in imported_top_levels:
271
+ diag = Diagnostic(
272
+ id=rule.id,
273
+ severity=rule.default_severity,
274
+ category=rule.category,
275
+ title=f"Unused declared dependency: {dep.name}",
276
+ message=f"Package '{dep.name}' is declared in {dep.source_file.name} but no imports were detected in project source files.",
277
+ evidence=[
278
+ Evidence(
279
+ fact=f"Declared requirement: {dep.name}{dep.specifier} in {dep.source_file.name}",
280
+ source=str(dep.source_file),
281
+ )
282
+ ],
283
+ suggestions=[
284
+ Suggestion(
285
+ description=f"Remove '{dep.name}' from dependencies if not needed at runtime.",
286
+ command=f"{context.package_manager} remove {dep.name}"
287
+ if context.package_manager in ("uv", "poetry")
288
+ else None,
289
+ )
290
+ ],
291
+ affected_packages=[dep.name],
292
+ file=str(dep.source_file),
293
+ line=dep.line_number,
294
+ confidence=0.85,
295
+ doc_url=rule.doc_url,
296
+ )
297
+ diagnostics.append(diag)
298
+
299
+ return diagnostics
300
+
301
+ def _check_declaration_mismatch(self, context: ProjectContext) -> list[Diagnostic]:
302
+ """DEP-005: Check for declared versions that do not match installed versions in active environment."""
303
+ diagnostics: list[Diagnostic] = []
304
+ rule = get_rule_definition("DEP-005")
305
+ if not rule or not context.installed_packages:
306
+ return diagnostics
307
+
308
+ for dep in context.dependencies:
309
+ dep_name_lower = dep.name.lower()
310
+ if dep_name_lower in context.installed_packages and dep.specifier:
311
+ installed = context.installed_packages[dep_name_lower]
312
+ try:
313
+ spec = SpecifierSet(dep.specifier)
314
+ installed_ver = Version(installed.version)
315
+ if not spec.contains(installed_ver, prereleases=True):
316
+ diag = Diagnostic(
317
+ id=rule.id,
318
+ severity=rule.default_severity,
319
+ category=rule.category,
320
+ title=f"Installed/declaration mismatch for {dep.name}",
321
+ message=f"Declared requirement is '{dep.name}{dep.specifier}', but environment has '{installed.name} {installed.version}'.",
322
+ evidence=[
323
+ Evidence(
324
+ fact=f"Declared specifier: {dep.specifier} in {dep.source_file.name}",
325
+ source=str(dep.source_file),
326
+ ),
327
+ Evidence(
328
+ fact=f"Installed version in environment: {installed.version}",
329
+ source="Active environment",
330
+ ),
331
+ ],
332
+ suggestions=[
333
+ Suggestion(
334
+ description="Synchronize the virtual environment with project manifest.",
335
+ command=f"{context.package_manager} sync"
336
+ if context.package_manager in ("uv", "poetry")
337
+ else f"pip install -r {dep.source_file.name}",
338
+ )
339
+ ],
340
+ affected_packages=[dep.name],
341
+ doc_url=rule.doc_url,
342
+ )
343
+ diagnostics.append(diag)
344
+ except Exception:
345
+ pass
346
+
347
+ return diagnostics