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.
- python_qv-0.1.0.dist-info/METADATA +281 -0
- python_qv-0.1.0.dist-info/RECORD +33 -0
- python_qv-0.1.0.dist-info/WHEEL +4 -0
- python_qv-0.1.0.dist-info/entry_points.txt +4 -0
- qv/__init__.py +3 -0
- qv/analyzers/__init__.py +1 -0
- qv/analyzers/ci/__init__.py +1 -0
- qv/analyzers/dependencies/__init__.py +5 -0
- qv/analyzers/dependencies/analyzer.py +347 -0
- qv/analyzers/docker/__init__.py +1 -0
- qv/analyzers/environment/__init__.py +5 -0
- qv/analyzers/environment/drift.py +164 -0
- qv/analyzers/imports/__init__.py +5 -0
- qv/analyzers/imports/analyzer.py +160 -0
- qv/analyzers/packaging/__init__.py +5 -0
- qv/analyzers/packaging/analyzer.py +117 -0
- qv/analyzers/security/__init__.py +1 -0
- qv/cli/__init__.py +5 -0
- qv/cli/main.py +253 -0
- qv/core/analyzer.py +21 -0
- qv/core/config.py +122 -0
- qv/core/context.py +105 -0
- qv/core/engine.py +116 -0
- qv/core/models.py +132 -0
- qv/core/project.py +330 -0
- qv/frameworks/__init__.py +1 -0
- qv/integrations/__init__.py +1 -0
- qv/reporters/__init__.py +13 -0
- qv/reporters/base.py +15 -0
- qv/reporters/json_reporter.py +16 -0
- qv/reporters/sarif.py +85 -0
- qv/reporters/terminal.py +126 -0
- qv/rules/registry.py +151 -0
qv/cli/main.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""qv CLI entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
|
|
12
|
+
from qv import __version__
|
|
13
|
+
from qv.analyzers.dependencies.analyzer import DependencyAnalyzer
|
|
14
|
+
from qv.analyzers.environment.drift import EnvironmentAnalyzer
|
|
15
|
+
from qv.analyzers.imports.analyzer import ImportAnalyzer
|
|
16
|
+
from qv.core.config import QvConfig
|
|
17
|
+
from qv.core.engine import AnalysisEngine
|
|
18
|
+
from qv.core.models import Severity
|
|
19
|
+
from qv.core.project import ProjectDiscovery
|
|
20
|
+
from qv.reporters.json_reporter import JsonReporter
|
|
21
|
+
from qv.reporters.sarif import SarifReporter
|
|
22
|
+
from qv.reporters.terminal import TerminalReporter
|
|
23
|
+
from qv.rules.registry import RULES_CATALOG, get_rule_definition
|
|
24
|
+
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
try:
|
|
27
|
+
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
28
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
29
|
+
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
|
|
30
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group(invoke_without_command=False)
|
|
38
|
+
@click.version_option(version=__version__, prog_name="qv")
|
|
39
|
+
def cli() -> None:
|
|
40
|
+
"""qv - Diagnose why a Python project is unhealthy."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cli.command("scan")
|
|
45
|
+
@click.argument(
|
|
46
|
+
"path",
|
|
47
|
+
default=".",
|
|
48
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
49
|
+
)
|
|
50
|
+
@click.option("--strict", is_flag=True, help="Promote warnings to errors (fails CI on warnings).")
|
|
51
|
+
@click.option("--ci", "ci_mode", is_flag=True, help="Run in CI mode with non-interactive output.")
|
|
52
|
+
@click.option("--json", "as_json", is_flag=True, help="Output diagnostics in JSON format.")
|
|
53
|
+
@click.option(
|
|
54
|
+
"--sarif", "as_sarif", is_flag=True, help="Output diagnostics in SARIF v2.1.0 format."
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--output", "-o", type=click.Path(dir_okay=False, path_type=Path), help="Write output to file."
|
|
58
|
+
)
|
|
59
|
+
def scan(
|
|
60
|
+
path: Path,
|
|
61
|
+
strict: bool,
|
|
62
|
+
ci_mode: bool,
|
|
63
|
+
as_json: bool,
|
|
64
|
+
as_sarif: bool,
|
|
65
|
+
output: Path | None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Scan a Python project and report health findings."""
|
|
68
|
+
try:
|
|
69
|
+
pyproject_path = path / "pyproject.toml"
|
|
70
|
+
config = QvConfig.from_pyproject(pyproject_path if pyproject_path.exists() else None)
|
|
71
|
+
if strict or ci_mode:
|
|
72
|
+
config.strict = True
|
|
73
|
+
|
|
74
|
+
discovery = ProjectDiscovery(root=path, config=config)
|
|
75
|
+
context = discovery.discover_context()
|
|
76
|
+
|
|
77
|
+
engine = AnalysisEngine(config=config)
|
|
78
|
+
result = engine.run(context)
|
|
79
|
+
|
|
80
|
+
# Select reporter
|
|
81
|
+
if as_sarif:
|
|
82
|
+
reporter = SarifReporter()
|
|
83
|
+
rendered = reporter.render(result)
|
|
84
|
+
elif as_json:
|
|
85
|
+
reporter = JsonReporter()
|
|
86
|
+
rendered = reporter.render(result)
|
|
87
|
+
else:
|
|
88
|
+
reporter = TerminalReporter(console=console)
|
|
89
|
+
rendered = None
|
|
90
|
+
|
|
91
|
+
if output:
|
|
92
|
+
content_to_write = (
|
|
93
|
+
rendered if rendered is not None else TerminalReporter().render(result)
|
|
94
|
+
)
|
|
95
|
+
output.write_text(content_to_write, encoding="utf-8")
|
|
96
|
+
console.print(f"[green]Report successfully written to {output}[/green]")
|
|
97
|
+
elif rendered is not None:
|
|
98
|
+
click.echo(rendered)
|
|
99
|
+
else:
|
|
100
|
+
TerminalReporter(console=console).print_result(result)
|
|
101
|
+
|
|
102
|
+
# Determine exit code
|
|
103
|
+
if result.has_blocking_errors:
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
if (strict or ci_mode) and result.summary.warnings_count > 0:
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
sys.exit(0)
|
|
108
|
+
|
|
109
|
+
except click.ClickException:
|
|
110
|
+
raise
|
|
111
|
+
except SystemExit:
|
|
112
|
+
raise
|
|
113
|
+
except Exception as e:
|
|
114
|
+
console.print(f"[bold red]Analysis failed:[/bold red] {e}")
|
|
115
|
+
sys.exit(3)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@cli.command("explain")
|
|
119
|
+
@click.argument("rule_id", type=str)
|
|
120
|
+
def explain(rule_id: str) -> None:
|
|
121
|
+
"""Explain a specific rule and its remediation strategies."""
|
|
122
|
+
rule_def = get_rule_definition(rule_id)
|
|
123
|
+
if not rule_def:
|
|
124
|
+
console.print(f"[bold red]Error:[/bold red] Unknown rule ID '{rule_id}'.")
|
|
125
|
+
console.print("Use one of: " + ", ".join(RULES_CATALOG.keys()))
|
|
126
|
+
sys.exit(2)
|
|
127
|
+
|
|
128
|
+
color = "red" if rule_def.default_severity == Severity.ERROR else "yellow"
|
|
129
|
+
panel_content = (
|
|
130
|
+
f"[bold]Category:[/bold] {rule_def.category.title()}\n"
|
|
131
|
+
f"[bold]Default Severity:[/bold] [{color}]{rule_def.default_severity.value.upper()}[/{color}]\n\n"
|
|
132
|
+
f"[bold]Description:[/bold]\n{rule_def.description}\n\n"
|
|
133
|
+
f"[bold cyan]Remediation Recommendation:[/bold cyan]\n{rule_def.remediation_hint}\n\n"
|
|
134
|
+
f"[bold dim]Documentation:[/bold dim]\n{rule_def.doc_url or 'N/A'}"
|
|
135
|
+
)
|
|
136
|
+
panel = Panel(
|
|
137
|
+
panel_content,
|
|
138
|
+
title=f"[bold]{rule_def.id} — {rule_def.title}[/bold]",
|
|
139
|
+
border_style="cyan",
|
|
140
|
+
padding=(1, 2),
|
|
141
|
+
)
|
|
142
|
+
console.print(panel)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@cli.command("init")
|
|
146
|
+
@click.argument(
|
|
147
|
+
"path",
|
|
148
|
+
default=".",
|
|
149
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
150
|
+
)
|
|
151
|
+
def init_cmd(path: Path) -> None:
|
|
152
|
+
"""Initialize qv configuration in pyproject.toml without overwriting existing settings."""
|
|
153
|
+
pyproject_path = path / "pyproject.toml"
|
|
154
|
+
default_section = """
|
|
155
|
+
[tool.qv]
|
|
156
|
+
[tool.qv.rules]
|
|
157
|
+
DEP-001 = "error"
|
|
158
|
+
DEP-002 = "error"
|
|
159
|
+
DEP-003 = "warning"
|
|
160
|
+
DEP-004 = "warning"
|
|
161
|
+
DEP-005 = "warning"
|
|
162
|
+
IMP-001 = "error"
|
|
163
|
+
IMP-002 = "error"
|
|
164
|
+
|
|
165
|
+
[tool.qv.paths]
|
|
166
|
+
exclude = [
|
|
167
|
+
".venv",
|
|
168
|
+
"build",
|
|
169
|
+
"dist",
|
|
170
|
+
"node_modules",
|
|
171
|
+
]
|
|
172
|
+
"""
|
|
173
|
+
if not pyproject_path.exists():
|
|
174
|
+
pyproject_path.write_text(default_section.lstrip(), encoding="utf-8")
|
|
175
|
+
console.print(
|
|
176
|
+
f"[green]Created {pyproject_path} with default [tool.qv] configuration.[/green]"
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
content = pyproject_path.read_text(encoding="utf-8")
|
|
181
|
+
if "[tool.qv]" in content or "[tool.pydoctor]" in content:
|
|
182
|
+
console.print(
|
|
183
|
+
"[yellow]pyproject.toml already contains [tool.qv] configuration. Skipping.[/yellow]"
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# Append to existing
|
|
188
|
+
updated = content.rstrip() + "\n" + default_section
|
|
189
|
+
pyproject_path.write_text(updated, encoding="utf-8")
|
|
190
|
+
console.print(f"[green]Added [tool.qv] configuration to {pyproject_path}.[/green]")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@cli.command("dependency")
|
|
194
|
+
@click.argument(
|
|
195
|
+
"path",
|
|
196
|
+
default=".",
|
|
197
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
198
|
+
)
|
|
199
|
+
def dependency_cmd(path: Path) -> None:
|
|
200
|
+
"""Run dependency-focused checks only."""
|
|
201
|
+
discovery = ProjectDiscovery(root=path)
|
|
202
|
+
context = discovery.discover_context()
|
|
203
|
+
engine = AnalysisEngine(analyzers=[DependencyAnalyzer()])
|
|
204
|
+
result = engine.run(context)
|
|
205
|
+
TerminalReporter(console=console).print_result(result)
|
|
206
|
+
sys.exit(1 if result.has_blocking_errors else 0)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@cli.command("environment")
|
|
210
|
+
@click.argument(
|
|
211
|
+
"path",
|
|
212
|
+
default=".",
|
|
213
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
214
|
+
)
|
|
215
|
+
def environment_cmd(path: Path) -> None:
|
|
216
|
+
"""Run environment and runtime drift checks only."""
|
|
217
|
+
discovery = ProjectDiscovery(root=path)
|
|
218
|
+
context = discovery.discover_context()
|
|
219
|
+
engine = AnalysisEngine(analyzers=[EnvironmentAnalyzer()])
|
|
220
|
+
result = engine.run(context)
|
|
221
|
+
TerminalReporter(console=console).print_result(result)
|
|
222
|
+
sys.exit(1 if result.has_blocking_errors else 0)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@cli.command("architecture")
|
|
226
|
+
@click.argument(
|
|
227
|
+
"path",
|
|
228
|
+
default=".",
|
|
229
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
230
|
+
)
|
|
231
|
+
def architecture_cmd(path: Path) -> None:
|
|
232
|
+
"""Run AST and import architecture checks only."""
|
|
233
|
+
discovery = ProjectDiscovery(root=path)
|
|
234
|
+
context = discovery.discover_context()
|
|
235
|
+
engine = AnalysisEngine(analyzers=[ImportAnalyzer()])
|
|
236
|
+
result = engine.run(context)
|
|
237
|
+
TerminalReporter(console=console).print_result(result)
|
|
238
|
+
sys.exit(1 if result.has_blocking_errors else 0)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@cli.command("version")
|
|
242
|
+
def version_cmd() -> None:
|
|
243
|
+
"""Print qv version."""
|
|
244
|
+
console.print(f"qv v{__version__}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main() -> None:
|
|
248
|
+
"""Main entrypoint for setuptools / script runner."""
|
|
249
|
+
cli()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
main()
|
qv/core/analyzer.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Analyzer protocol and base classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from qv.core.context import ProjectContext
|
|
8
|
+
from qv.core.models import Diagnostic
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class Analyzer(Protocol):
|
|
13
|
+
"""Protocol for all diagnostic analyzers."""
|
|
14
|
+
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
|
|
19
|
+
def analyze(self, context: ProjectContext) -> list[Diagnostic]:
|
|
20
|
+
"""Analyze project context and return list of diagnostics."""
|
|
21
|
+
...
|
qv/core/config.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Configuration loader and validator for qv."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
import tomllib
|
|
12
|
+
else:
|
|
13
|
+
import tomli as tomllib
|
|
14
|
+
|
|
15
|
+
from qv.core.models import Severity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RuleConfig:
|
|
20
|
+
"""Per-rule configuration."""
|
|
21
|
+
|
|
22
|
+
severity: Severity | None = None # None means use rule default
|
|
23
|
+
disabled: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PathConfig:
|
|
28
|
+
"""Path matching configuration."""
|
|
29
|
+
|
|
30
|
+
exclude: list[str] = field(
|
|
31
|
+
default_factory=lambda: [
|
|
32
|
+
".venv",
|
|
33
|
+
"venv",
|
|
34
|
+
"env",
|
|
35
|
+
".env",
|
|
36
|
+
"build",
|
|
37
|
+
"dist",
|
|
38
|
+
"node_modules",
|
|
39
|
+
"__pycache__",
|
|
40
|
+
".pytest_cache",
|
|
41
|
+
".git",
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
include: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class QvConfig:
|
|
49
|
+
"""Top-level configuration for qv."""
|
|
50
|
+
|
|
51
|
+
rules: dict[str, RuleConfig] = field(default_factory=dict)
|
|
52
|
+
ignored_rules: set[str] = field(default_factory=set)
|
|
53
|
+
paths: PathConfig = field(default_factory=PathConfig)
|
|
54
|
+
target_python: str | None = None
|
|
55
|
+
strict: bool = False
|
|
56
|
+
|
|
57
|
+
def is_rule_enabled(self, rule_id: str) -> bool:
|
|
58
|
+
if rule_id in self.ignored_rules:
|
|
59
|
+
return False
|
|
60
|
+
if rule_id in self.rules and self.rules[rule_id].disabled:
|
|
61
|
+
return False
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def get_effective_severity(self, rule_id: str, default_severity: Severity) -> Severity:
|
|
65
|
+
if rule_id in self.rules and self.rules[rule_id].severity is not None:
|
|
66
|
+
sev = self.rules[rule_id].severity
|
|
67
|
+
assert sev is not None
|
|
68
|
+
if self.strict and sev == Severity.WARNING:
|
|
69
|
+
return Severity.ERROR
|
|
70
|
+
return sev
|
|
71
|
+
if self.strict and default_severity == Severity.WARNING:
|
|
72
|
+
return Severity.ERROR
|
|
73
|
+
return default_severity
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_pyproject(cls, pyproject_path: Path | None = None) -> QvConfig:
|
|
77
|
+
"""Load configuration from pyproject.toml if present."""
|
|
78
|
+
if pyproject_path is None or not pyproject_path.exists():
|
|
79
|
+
return cls()
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
with open(pyproject_path, "rb") as f:
|
|
83
|
+
data = tomllib.load(f)
|
|
84
|
+
except Exception:
|
|
85
|
+
return cls()
|
|
86
|
+
|
|
87
|
+
# Support [tool.qv] as primary and [tool.pydoctor] as fallback
|
|
88
|
+
tool_config: dict[str, Any] = data.get("tool", {}).get("qv") or data.get("tool", {}).get(
|
|
89
|
+
"pydoctor", {}
|
|
90
|
+
)
|
|
91
|
+
if not tool_config:
|
|
92
|
+
return cls()
|
|
93
|
+
|
|
94
|
+
rules: dict[str, RuleConfig] = {}
|
|
95
|
+
for rule_id, sev_str in tool_config.get("rules", {}).items():
|
|
96
|
+
sev_str_lower = str(sev_str).lower()
|
|
97
|
+
if sev_str_lower == "off":
|
|
98
|
+
rules[rule_id] = RuleConfig(disabled=True)
|
|
99
|
+
elif sev_str_lower in ("error", "warning", "info"):
|
|
100
|
+
rules[rule_id] = RuleConfig(severity=Severity(sev_str_lower))
|
|
101
|
+
|
|
102
|
+
ignored_rules = set(tool_config.get("ignore", {}).get("rules", []))
|
|
103
|
+
|
|
104
|
+
paths_dict = tool_config.get("paths", {})
|
|
105
|
+
paths = PathConfig(
|
|
106
|
+
exclude=paths_dict.get("exclude", PathConfig().exclude),
|
|
107
|
+
include=paths_dict.get("include", []),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
runtime_dict = tool_config.get("runtime", {})
|
|
111
|
+
target_python = runtime_dict.get("python")
|
|
112
|
+
|
|
113
|
+
return cls(
|
|
114
|
+
rules=rules,
|
|
115
|
+
ignored_rules=ignored_rules,
|
|
116
|
+
paths=paths,
|
|
117
|
+
target_python=target_python,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Backward compatibility alias
|
|
122
|
+
PyDoctorConfig = QvConfig
|
qv/core/context.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Immutable project context consumed by analyzers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class PythonRuntime:
|
|
12
|
+
"""Information about detected/configured Python runtime."""
|
|
13
|
+
|
|
14
|
+
version_str: str
|
|
15
|
+
major: int
|
|
16
|
+
minor: int
|
|
17
|
+
micro: int
|
|
18
|
+
executable: str | None = None
|
|
19
|
+
is_virtualenv: bool = False
|
|
20
|
+
virtualenv_path: Path | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DependencyDeclaration:
|
|
25
|
+
"""A declared project dependency (e.g. from pyproject.toml or requirements.txt)."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
specifier: str
|
|
29
|
+
source_file: Path
|
|
30
|
+
line_number: int | None = None
|
|
31
|
+
is_dev: bool = False
|
|
32
|
+
extras: tuple[str, ...] = ()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class InstalledDistribution:
|
|
37
|
+
"""An installed Python package in the environment."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
version: str
|
|
41
|
+
location: str | None = None
|
|
42
|
+
requires: tuple[str, ...] = ()
|
|
43
|
+
required_by: tuple[str, ...] = ()
|
|
44
|
+
direct_url: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ImportRecord:
|
|
49
|
+
"""An import statement discovered in source code."""
|
|
50
|
+
|
|
51
|
+
module_name: str
|
|
52
|
+
source_file: Path
|
|
53
|
+
line_number: int
|
|
54
|
+
is_relative: bool
|
|
55
|
+
imported_symbols: tuple[str, ...] = ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SourceFile:
|
|
60
|
+
"""A Python source file in the project."""
|
|
61
|
+
|
|
62
|
+
path: Path
|
|
63
|
+
relative_path: Path
|
|
64
|
+
content: str
|
|
65
|
+
is_init: bool = False
|
|
66
|
+
module_name: str = ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class DockerConfig:
|
|
71
|
+
"""Detected Docker configuration."""
|
|
72
|
+
|
|
73
|
+
has_dockerfile: bool
|
|
74
|
+
dockerfile_path: Path | None = None
|
|
75
|
+
base_image: str | None = None
|
|
76
|
+
base_python_version: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class CIConfig:
|
|
81
|
+
"""Detected CI configuration."""
|
|
82
|
+
|
|
83
|
+
has_ci: bool
|
|
84
|
+
provider: str | None = None # github_actions, gitlab_ci, etc.
|
|
85
|
+
workflow_files: tuple[Path, ...] = ()
|
|
86
|
+
matrix_python_versions: tuple[str, ...] = ()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class ProjectContext:
|
|
91
|
+
"""Immutable project context passed into each analyzer."""
|
|
92
|
+
|
|
93
|
+
project_root: Path
|
|
94
|
+
project_name: str
|
|
95
|
+
python_runtime: PythonRuntime
|
|
96
|
+
package_manager: str # uv, poetry, pip, pdm, flit, hatch, etc.
|
|
97
|
+
manifest_files: tuple[Path, ...]
|
|
98
|
+
lock_files: tuple[Path, ...]
|
|
99
|
+
dependencies: tuple[DependencyDeclaration, ...]
|
|
100
|
+
installed_packages: dict[str, InstalledDistribution]
|
|
101
|
+
source_files: tuple[SourceFile, ...]
|
|
102
|
+
imports: tuple[ImportRecord, ...]
|
|
103
|
+
docker: DockerConfig
|
|
104
|
+
ci: CIConfig
|
|
105
|
+
config: dict[str, Any] = field(default_factory=dict)
|
qv/core/engine.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Analysis engine coordinating analyzer execution and root cause grouping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from qv.analyzers.dependencies.analyzer import DependencyAnalyzer
|
|
8
|
+
from qv.analyzers.environment.drift import EnvironmentAnalyzer
|
|
9
|
+
from qv.analyzers.imports.analyzer import ImportAnalyzer
|
|
10
|
+
from qv.analyzers.packaging.analyzer import PackagingAnalyzer
|
|
11
|
+
from qv.core.analyzer import Analyzer
|
|
12
|
+
from qv.core.config import QvConfig
|
|
13
|
+
from qv.core.context import ProjectContext
|
|
14
|
+
from qv.core.models import Diagnostic, RootCause, ScanResult, Suggestion
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AnalysisEngine:
|
|
18
|
+
"""Coordinates execution of analyzers, applies config, and correlates root causes."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
config: QvConfig | None = None,
|
|
23
|
+
analyzers: Sequence[Analyzer] | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.config = config or QvConfig()
|
|
26
|
+
if analyzers is not None:
|
|
27
|
+
self.analyzers = list(analyzers)
|
|
28
|
+
else:
|
|
29
|
+
self.analyzers = [
|
|
30
|
+
PackagingAnalyzer(),
|
|
31
|
+
EnvironmentAnalyzer(),
|
|
32
|
+
DependencyAnalyzer(),
|
|
33
|
+
ImportAnalyzer(),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def run(self, context: ProjectContext) -> ScanResult:
|
|
37
|
+
"""Run all registered analyzers against the given ProjectContext."""
|
|
38
|
+
raw_diagnostics: list[Diagnostic] = []
|
|
39
|
+
checks_evaluated = 0
|
|
40
|
+
|
|
41
|
+
for analyzer in self.analyzers:
|
|
42
|
+
checks_evaluated += 1
|
|
43
|
+
try:
|
|
44
|
+
findings = analyzer.analyze(context)
|
|
45
|
+
raw_diagnostics.extend(findings)
|
|
46
|
+
except Exception:
|
|
47
|
+
# Keep engine resilient
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
# Apply configuration (filtering and severity overrides)
|
|
51
|
+
filtered_diagnostics: list[Diagnostic] = []
|
|
52
|
+
for diag in raw_diagnostics:
|
|
53
|
+
if not self.config.is_rule_enabled(diag.id):
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
effective_sev = self.config.get_effective_severity(diag.id, diag.severity)
|
|
57
|
+
updated_diag = diag.model_copy(update={"severity": effective_sev})
|
|
58
|
+
filtered_diagnostics.append(updated_diag)
|
|
59
|
+
|
|
60
|
+
# Correlate root causes
|
|
61
|
+
root_causes = self._correlate_root_causes(filtered_diagnostics)
|
|
62
|
+
|
|
63
|
+
# Calculate passed checks approximation
|
|
64
|
+
passed_count = max(0, 30 + checks_evaluated * 5 - len(filtered_diagnostics))
|
|
65
|
+
|
|
66
|
+
return ScanResult.create(
|
|
67
|
+
project_name=context.project_name,
|
|
68
|
+
project_path=str(context.project_root),
|
|
69
|
+
python_version=context.python_runtime.version_str,
|
|
70
|
+
package_manager=context.package_manager,
|
|
71
|
+
diagnostics=filtered_diagnostics,
|
|
72
|
+
checks_passed=passed_count,
|
|
73
|
+
root_causes=root_causes,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _correlate_root_causes(self, diagnostics: list[Diagnostic]) -> list[RootCause]:
|
|
77
|
+
"""Group related diagnostics by affected packages or rule categories."""
|
|
78
|
+
root_causes: list[RootCause] = []
|
|
79
|
+
package_groups: dict[str, list[Diagnostic]] = {}
|
|
80
|
+
|
|
81
|
+
for diag in diagnostics:
|
|
82
|
+
if diag.affected_packages:
|
|
83
|
+
for pkg in diag.affected_packages:
|
|
84
|
+
package_groups.setdefault(pkg.lower(), []).append(diag)
|
|
85
|
+
|
|
86
|
+
seen_diags: set[str] = set()
|
|
87
|
+
rc_counter = 1
|
|
88
|
+
|
|
89
|
+
for pkg, group in package_groups.items():
|
|
90
|
+
if len(group) > 1:
|
|
91
|
+
group_ids = [d.id for d in group]
|
|
92
|
+
if any(gid in seen_diags for gid in group_ids):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
primary = group[0]
|
|
96
|
+
primary_suggestion = (
|
|
97
|
+
primary.suggestions[0]
|
|
98
|
+
if primary.suggestions
|
|
99
|
+
else Suggestion(description=f"Resolve dependencies for {pkg}")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
root_causes.append(
|
|
103
|
+
RootCause(
|
|
104
|
+
id=f"ROOT-{rc_counter:03d}",
|
|
105
|
+
title=f"Package integrity issue with '{pkg}'",
|
|
106
|
+
summary=f"Found {len(group)} related findings affecting package '{pkg}'.",
|
|
107
|
+
primary_diagnostic=primary,
|
|
108
|
+
related_diagnostics=group[1:],
|
|
109
|
+
recommendation=primary_suggestion,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
rc_counter += 1
|
|
113
|
+
for d in group:
|
|
114
|
+
seen_diags.add(d.id)
|
|
115
|
+
|
|
116
|
+
return root_causes
|