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/core/models.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Core diagnostic and scan result models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Severity(str, Enum):
|
|
12
|
+
INFO = "info"
|
|
13
|
+
WARNING = "warning"
|
|
14
|
+
ERROR = "error"
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def rank(self) -> int:
|
|
18
|
+
return {"info": 1, "warning": 2, "error": 3}[self.value]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Evidence(BaseModel):
|
|
22
|
+
"""Fact supporting a diagnostic finding."""
|
|
23
|
+
|
|
24
|
+
fact: str
|
|
25
|
+
source: str | None = None
|
|
26
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Suggestion(BaseModel):
|
|
30
|
+
"""Safe, actionable remediation recommendation."""
|
|
31
|
+
|
|
32
|
+
description: str
|
|
33
|
+
command: str | None = None
|
|
34
|
+
code_snippet: str | None = None
|
|
35
|
+
is_safe: bool = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Diagnostic(BaseModel):
|
|
39
|
+
"""Standardized diagnostic finding emitted by all analyzers."""
|
|
40
|
+
|
|
41
|
+
id: str
|
|
42
|
+
severity: Severity
|
|
43
|
+
category: str
|
|
44
|
+
title: str
|
|
45
|
+
message: str
|
|
46
|
+
evidence: list[Evidence] = Field(default_factory=list)
|
|
47
|
+
suggestions: list[Suggestion] = Field(default_factory=list)
|
|
48
|
+
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
49
|
+
affected_packages: list[str] = Field(default_factory=list)
|
|
50
|
+
file: str | None = None
|
|
51
|
+
line: int | None = None
|
|
52
|
+
column: int | None = None
|
|
53
|
+
dependency_chain: list[str] = Field(default_factory=list)
|
|
54
|
+
doc_url: str | None = None
|
|
55
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RootCause(BaseModel):
|
|
59
|
+
"""Group of related symptoms belonging to one underlying root cause."""
|
|
60
|
+
|
|
61
|
+
id: str
|
|
62
|
+
title: str
|
|
63
|
+
summary: str
|
|
64
|
+
primary_diagnostic: Diagnostic
|
|
65
|
+
related_diagnostics: list[Diagnostic] = Field(default_factory=list)
|
|
66
|
+
recommendation: Suggestion
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ScanSummary(BaseModel):
|
|
70
|
+
"""High-level summary of a scan run."""
|
|
71
|
+
|
|
72
|
+
errors_count: int = 0
|
|
73
|
+
warnings_count: int = 0
|
|
74
|
+
info_count: int = 0
|
|
75
|
+
checks_passed: int = 0
|
|
76
|
+
health_score: int = 100
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ScanResult(BaseModel):
|
|
80
|
+
"""Complete output of a qv scan."""
|
|
81
|
+
|
|
82
|
+
project_name: str
|
|
83
|
+
project_path: str
|
|
84
|
+
python_version: str
|
|
85
|
+
package_manager: str
|
|
86
|
+
summary: ScanSummary
|
|
87
|
+
diagnostics: list[Diagnostic] = Field(default_factory=list)
|
|
88
|
+
root_causes: list[RootCause] = Field(default_factory=list)
|
|
89
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def has_blocking_errors(self) -> bool:
|
|
93
|
+
return self.summary.errors_count > 0
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def create(
|
|
97
|
+
cls,
|
|
98
|
+
project_name: str,
|
|
99
|
+
project_path: str,
|
|
100
|
+
python_version: str,
|
|
101
|
+
package_manager: str,
|
|
102
|
+
diagnostics: list[Diagnostic],
|
|
103
|
+
checks_passed: int = 0,
|
|
104
|
+
root_causes: list[RootCause] | None = None,
|
|
105
|
+
metadata: dict[str, Any] | None = None,
|
|
106
|
+
) -> ScanResult:
|
|
107
|
+
errors = sum(1 for d in diagnostics if d.severity == Severity.ERROR)
|
|
108
|
+
warnings = sum(1 for d in diagnostics if d.severity == Severity.WARNING)
|
|
109
|
+
infos = sum(1 for d in diagnostics if d.severity == Severity.INFO)
|
|
110
|
+
|
|
111
|
+
# Health score calculation (100 base, deductions for errors and warnings)
|
|
112
|
+
penalty = (errors * 15) + (warnings * 5)
|
|
113
|
+
health_score = max(0, min(100, 100 - penalty))
|
|
114
|
+
|
|
115
|
+
summary = ScanSummary(
|
|
116
|
+
errors_count=errors,
|
|
117
|
+
warnings_count=warnings,
|
|
118
|
+
info_count=infos,
|
|
119
|
+
checks_passed=checks_passed,
|
|
120
|
+
health_score=health_score,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return cls(
|
|
124
|
+
project_name=project_name,
|
|
125
|
+
project_path=project_path,
|
|
126
|
+
python_version=python_version,
|
|
127
|
+
package_manager=package_manager,
|
|
128
|
+
summary=summary,
|
|
129
|
+
diagnostics=diagnostics,
|
|
130
|
+
root_causes=root_causes or [],
|
|
131
|
+
metadata=metadata or {},
|
|
132
|
+
)
|
qv/core/project.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Project discovery and context builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from packaging.requirements import Requirement
|
|
12
|
+
|
|
13
|
+
from qv.core.config import QvConfig
|
|
14
|
+
from qv.core.context import (
|
|
15
|
+
CIConfig,
|
|
16
|
+
DependencyDeclaration,
|
|
17
|
+
DockerConfig,
|
|
18
|
+
ImportRecord,
|
|
19
|
+
InstalledDistribution,
|
|
20
|
+
ProjectContext,
|
|
21
|
+
PythonRuntime,
|
|
22
|
+
SourceFile,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if sys.version_info >= (3, 11):
|
|
26
|
+
import tomllib
|
|
27
|
+
else:
|
|
28
|
+
import tomli as tomllib
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProjectDiscovery:
|
|
32
|
+
"""Discovers project files, manifests, environment, and source files."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, root: Path, config: QvConfig | None = None) -> None:
|
|
35
|
+
self.root = root.resolve()
|
|
36
|
+
self.config = config or QvConfig.from_pyproject(self.root / "pyproject.toml")
|
|
37
|
+
|
|
38
|
+
def discover_context(self) -> ProjectContext:
|
|
39
|
+
"""Build and return an immutable ProjectContext."""
|
|
40
|
+
pyproject_path = self.root / "pyproject.toml"
|
|
41
|
+
project_name = self.root.name
|
|
42
|
+
manifest_files: list[Path] = []
|
|
43
|
+
lock_files: list[Path] = []
|
|
44
|
+
dependencies: list[DependencyDeclaration] = []
|
|
45
|
+
|
|
46
|
+
# 1. Package manager detection & manifest parsing
|
|
47
|
+
package_manager = "pip"
|
|
48
|
+
|
|
49
|
+
if (self.root / "uv.lock").exists():
|
|
50
|
+
package_manager = "uv"
|
|
51
|
+
lock_files.append(self.root / "uv.lock")
|
|
52
|
+
elif (self.root / "poetry.lock").exists():
|
|
53
|
+
package_manager = "poetry"
|
|
54
|
+
lock_files.append(self.root / "poetry.lock")
|
|
55
|
+
elif (self.root / "pdm.lock").exists():
|
|
56
|
+
package_manager = "pdm"
|
|
57
|
+
lock_files.append(self.root / "pdm.lock")
|
|
58
|
+
elif (self.root / "Pipfile.lock").exists():
|
|
59
|
+
package_manager = "pipenv"
|
|
60
|
+
lock_files.append(self.root / "Pipfile.lock")
|
|
61
|
+
|
|
62
|
+
# Check pyproject.toml
|
|
63
|
+
if pyproject_path.exists():
|
|
64
|
+
manifest_files.append(pyproject_path)
|
|
65
|
+
try:
|
|
66
|
+
with open(pyproject_path, "rb") as f:
|
|
67
|
+
pyproject_data = tomllib.load(f)
|
|
68
|
+
|
|
69
|
+
# Name
|
|
70
|
+
if "project" in pyproject_data and "name" in pyproject_data["project"]:
|
|
71
|
+
project_name = pyproject_data["project"]["name"]
|
|
72
|
+
|
|
73
|
+
# Dependencies from [project.dependencies]
|
|
74
|
+
for dep_str in pyproject_data.get("project", {}).get("dependencies", []):
|
|
75
|
+
try:
|
|
76
|
+
req = Requirement(dep_str)
|
|
77
|
+
dependencies.append(
|
|
78
|
+
DependencyDeclaration(
|
|
79
|
+
name=req.name,
|
|
80
|
+
specifier=str(req.specifier),
|
|
81
|
+
source_file=pyproject_path,
|
|
82
|
+
extras=tuple(req.extras),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
# Optional/dev dependencies
|
|
89
|
+
for group, deps in (
|
|
90
|
+
pyproject_data.get("project", {}).get("optional-dependencies", {}).items()
|
|
91
|
+
):
|
|
92
|
+
is_dev = group in ("dev", "test", "lint")
|
|
93
|
+
for dep_str in deps:
|
|
94
|
+
try:
|
|
95
|
+
req = Requirement(dep_str)
|
|
96
|
+
dependencies.append(
|
|
97
|
+
DependencyDeclaration(
|
|
98
|
+
name=req.name,
|
|
99
|
+
specifier=str(req.specifier),
|
|
100
|
+
source_file=pyproject_path,
|
|
101
|
+
is_dev=is_dev,
|
|
102
|
+
extras=tuple(req.extras),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# Poetry style [tool.poetry.dependencies]
|
|
109
|
+
poetry_deps = (
|
|
110
|
+
pyproject_data.get("tool", {}).get("poetry", {}).get("dependencies", {})
|
|
111
|
+
)
|
|
112
|
+
for dep_name, dep_val in poetry_deps.items():
|
|
113
|
+
if dep_name.lower() == "python":
|
|
114
|
+
continue
|
|
115
|
+
spec = dep_val if isinstance(dep_val, str) else str(dep_val.get("version", ""))
|
|
116
|
+
dependencies.append(
|
|
117
|
+
DependencyDeclaration(
|
|
118
|
+
name=dep_name,
|
|
119
|
+
specifier=spec,
|
|
120
|
+
source_file=pyproject_path,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
# Check requirements files
|
|
127
|
+
req_candidates = [
|
|
128
|
+
self.root / "requirements.txt",
|
|
129
|
+
self.root / "requirements-dev.txt",
|
|
130
|
+
self.root / "requirements" / "base.txt",
|
|
131
|
+
self.root / "requirements" / "dev.txt",
|
|
132
|
+
self.root / "requirements" / "prod.txt",
|
|
133
|
+
]
|
|
134
|
+
for req_path in req_candidates:
|
|
135
|
+
if req_path.exists():
|
|
136
|
+
manifest_files.append(req_path)
|
|
137
|
+
is_dev = "dev" in req_path.name.lower()
|
|
138
|
+
try:
|
|
139
|
+
with open(req_path, encoding="utf-8") as f:
|
|
140
|
+
for line_idx, line in enumerate(f, 1):
|
|
141
|
+
line = line.strip()
|
|
142
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
req = Requirement(line)
|
|
146
|
+
dependencies.append(
|
|
147
|
+
DependencyDeclaration(
|
|
148
|
+
name=req.name,
|
|
149
|
+
specifier=str(req.specifier),
|
|
150
|
+
source_file=req_path,
|
|
151
|
+
line_number=line_idx,
|
|
152
|
+
is_dev=is_dev,
|
|
153
|
+
extras=tuple(req.extras),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# 2. Python runtime detection
|
|
162
|
+
is_venv = hasattr(sys, "real_prefix") or (
|
|
163
|
+
hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
|
|
164
|
+
)
|
|
165
|
+
runtime = PythonRuntime(
|
|
166
|
+
version_str=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
167
|
+
major=sys.version_info.major,
|
|
168
|
+
minor=sys.version_info.minor,
|
|
169
|
+
micro=sys.version_info.micro,
|
|
170
|
+
executable=sys.executable,
|
|
171
|
+
is_virtualenv=is_venv,
|
|
172
|
+
virtualenv_path=Path(sys.prefix) if is_venv else None,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# 3. Installed packages collection
|
|
176
|
+
installed_packages: dict[str, InstalledDistribution] = {}
|
|
177
|
+
try:
|
|
178
|
+
for dist in importlib.metadata.distributions():
|
|
179
|
+
name = dist.metadata["Name"]
|
|
180
|
+
if not name:
|
|
181
|
+
continue
|
|
182
|
+
requires = dist.requires or []
|
|
183
|
+
installed_packages[name.lower()] = InstalledDistribution(
|
|
184
|
+
name=name,
|
|
185
|
+
version=dist.version,
|
|
186
|
+
location=str(dist.locate_file("")),
|
|
187
|
+
requires=tuple(requires),
|
|
188
|
+
)
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
# 4. Source file discovery & AST import extraction
|
|
193
|
+
source_files: list[SourceFile] = []
|
|
194
|
+
imports: list[ImportRecord] = []
|
|
195
|
+
|
|
196
|
+
exclude_patterns = set(self.config.paths.exclude)
|
|
197
|
+
for py_path in self.root.rglob("*.py"):
|
|
198
|
+
rel_path = py_path.relative_to(self.root)
|
|
199
|
+
# Check exclusions
|
|
200
|
+
parts = rel_path.parts
|
|
201
|
+
if any(part in exclude_patterns or part.startswith(".") for part in parts):
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
content = py_path.read_text(encoding="utf-8")
|
|
206
|
+
is_init = py_path.name == "__init__.py"
|
|
207
|
+
# Compute logical module name
|
|
208
|
+
mod_parts = list(rel_path.with_suffix("").parts)
|
|
209
|
+
if is_init:
|
|
210
|
+
mod_parts = mod_parts[:-1]
|
|
211
|
+
module_name = ".".join(mod_parts)
|
|
212
|
+
|
|
213
|
+
source_file = SourceFile(
|
|
214
|
+
path=py_path,
|
|
215
|
+
relative_path=rel_path,
|
|
216
|
+
content=content,
|
|
217
|
+
is_init=is_init,
|
|
218
|
+
module_name=module_name,
|
|
219
|
+
)
|
|
220
|
+
source_files.append(source_file)
|
|
221
|
+
|
|
222
|
+
# Parse AST
|
|
223
|
+
try:
|
|
224
|
+
tree = ast.parse(content, filename=str(py_path))
|
|
225
|
+
for node in ast.walk(tree):
|
|
226
|
+
if isinstance(node, ast.Import):
|
|
227
|
+
for alias in node.names:
|
|
228
|
+
imports.append(
|
|
229
|
+
ImportRecord(
|
|
230
|
+
module_name=alias.name,
|
|
231
|
+
source_file=py_path,
|
|
232
|
+
line_number=node.lineno,
|
|
233
|
+
is_relative=False,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
elif isinstance(node, ast.ImportFrom):
|
|
237
|
+
mod = node.module or ""
|
|
238
|
+
is_rel = node.level > 0
|
|
239
|
+
symbols = tuple(alias.name for alias in node.names)
|
|
240
|
+
imports.append(
|
|
241
|
+
ImportRecord(
|
|
242
|
+
module_name=mod,
|
|
243
|
+
source_file=py_path,
|
|
244
|
+
line_number=node.lineno,
|
|
245
|
+
is_relative=is_rel,
|
|
246
|
+
imported_symbols=symbols,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
except SyntaxError:
|
|
250
|
+
pass
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
# 5. Docker discovery
|
|
255
|
+
docker_config = self._discover_docker()
|
|
256
|
+
|
|
257
|
+
# 6. CI discovery
|
|
258
|
+
ci_config = self._discover_ci()
|
|
259
|
+
|
|
260
|
+
return ProjectContext(
|
|
261
|
+
project_root=self.root,
|
|
262
|
+
project_name=project_name,
|
|
263
|
+
python_runtime=runtime,
|
|
264
|
+
package_manager=package_manager,
|
|
265
|
+
manifest_files=tuple(manifest_files),
|
|
266
|
+
lock_files=tuple(lock_files),
|
|
267
|
+
dependencies=tuple(dependencies),
|
|
268
|
+
installed_packages=installed_packages,
|
|
269
|
+
source_files=tuple(source_files),
|
|
270
|
+
imports=tuple(imports),
|
|
271
|
+
docker=docker_config,
|
|
272
|
+
ci=ci_config,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _discover_docker(self) -> DockerConfig:
|
|
276
|
+
dockerfile = self.root / "Dockerfile"
|
|
277
|
+
if not dockerfile.exists():
|
|
278
|
+
return DockerConfig(has_dockerfile=False)
|
|
279
|
+
|
|
280
|
+
base_image = None
|
|
281
|
+
base_python = None
|
|
282
|
+
try:
|
|
283
|
+
content = dockerfile.read_text(encoding="utf-8")
|
|
284
|
+
from_match = re.search(r"^FROM\s+([^\s]+)", content, re.MULTILINE | re.IGNORECASE)
|
|
285
|
+
if from_match:
|
|
286
|
+
base_image = from_match.group(1)
|
|
287
|
+
py_match = re.search(r"python:?([0-9]+\.[0-9]+)", base_image, re.IGNORECASE)
|
|
288
|
+
if py_match:
|
|
289
|
+
base_python = py_match.group(1)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
return DockerConfig(
|
|
294
|
+
has_dockerfile=True,
|
|
295
|
+
dockerfile_path=dockerfile,
|
|
296
|
+
base_image=base_image,
|
|
297
|
+
base_python_version=base_python,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def _discover_ci(self) -> CIConfig:
|
|
301
|
+
gh_workflows = self.root / ".github" / "workflows"
|
|
302
|
+
workflow_files: list[Path] = []
|
|
303
|
+
matrix_versions: list[str] = []
|
|
304
|
+
|
|
305
|
+
if gh_workflows.exists() and gh_workflows.is_dir():
|
|
306
|
+
for f in gh_workflows.glob("*.yml"):
|
|
307
|
+
workflow_files.append(f)
|
|
308
|
+
for f in gh_workflows.glob("*.yaml"):
|
|
309
|
+
workflow_files.append(f)
|
|
310
|
+
|
|
311
|
+
# Look for python-version in workflow files
|
|
312
|
+
for wf in workflow_files:
|
|
313
|
+
try:
|
|
314
|
+
text = wf.read_text(encoding="utf-8")
|
|
315
|
+
found_versions = re.findall(r'["\']?3\.\d+["\']?', text)
|
|
316
|
+
for v in found_versions:
|
|
317
|
+
clean_v = v.strip("\"'")
|
|
318
|
+
if clean_v not in matrix_versions:
|
|
319
|
+
matrix_versions.append(clean_v)
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
return CIConfig(
|
|
324
|
+
has_ci=True,
|
|
325
|
+
provider="github_actions",
|
|
326
|
+
workflow_files=tuple(workflow_files),
|
|
327
|
+
matrix_python_versions=tuple(matrix_versions),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return CIConfig(has_ci=False)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework plugins."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Third-party tool integrations."""
|
qv/reporters/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Reporters package."""
|
|
2
|
+
|
|
3
|
+
from qv.reporters.base import Reporter
|
|
4
|
+
from qv.reporters.json_reporter import JsonReporter
|
|
5
|
+
from qv.reporters.sarif import SarifReporter
|
|
6
|
+
from qv.reporters.terminal import TerminalReporter
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Reporter",
|
|
10
|
+
"TerminalReporter",
|
|
11
|
+
"JsonReporter",
|
|
12
|
+
"SarifReporter",
|
|
13
|
+
]
|
qv/reporters/base.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Base reporter interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from qv.core.models import ScanResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Reporter(Protocol):
|
|
11
|
+
"""Protocol for diagnostic output formatters."""
|
|
12
|
+
|
|
13
|
+
def render(self, result: ScanResult) -> str:
|
|
14
|
+
"""Render scan result to string output."""
|
|
15
|
+
...
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""JSON reporter for machine-readable diagnostic outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from qv.core.models import ScanResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JsonReporter:
|
|
9
|
+
"""Serializes scan result to formatted JSON string."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, indent: int = 2) -> None:
|
|
12
|
+
self.indent = indent
|
|
13
|
+
|
|
14
|
+
def render(self, result: ScanResult) -> str:
|
|
15
|
+
"""Render scan result as JSON."""
|
|
16
|
+
return result.model_dump_json(indent=self.indent)
|
qv/reporters/sarif.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""SARIF (Static Analysis Results Interchange Format) reporter conforming to v2.1.0."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from qv.core.models import ScanResult, Severity
|
|
9
|
+
from qv.rules.registry import RULES_CATALOG
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SarifReporter:
|
|
13
|
+
"""Generates standard SARIF v2.1.0 output for CI / Code Scanning integration."""
|
|
14
|
+
|
|
15
|
+
def render(self, result: ScanResult) -> str:
|
|
16
|
+
"""Render scan result to SARIF v2.1.0 JSON format."""
|
|
17
|
+
sarif_data = self.to_sarif_dict(result)
|
|
18
|
+
return json.dumps(sarif_data, indent=2)
|
|
19
|
+
|
|
20
|
+
def to_sarif_dict(self, result: ScanResult) -> dict[str, Any]:
|
|
21
|
+
rules_dict: list[dict[str, Any]] = []
|
|
22
|
+
seen_rules = set()
|
|
23
|
+
|
|
24
|
+
for diag in result.diagnostics:
|
|
25
|
+
if diag.id not in seen_rules:
|
|
26
|
+
seen_rules.add(diag.id)
|
|
27
|
+
rule_def = RULES_CATALOG.get(diag.id)
|
|
28
|
+
rule_entry: dict[str, Any] = {
|
|
29
|
+
"id": diag.id,
|
|
30
|
+
"name": diag.id.replace("-", "_"),
|
|
31
|
+
"shortDescription": {"text": diag.title},
|
|
32
|
+
"fullDescription": {"text": rule_def.description if rule_def else diag.title},
|
|
33
|
+
}
|
|
34
|
+
if diag.doc_url:
|
|
35
|
+
rule_entry["helpUri"] = diag.doc_url
|
|
36
|
+
rules_dict.append(rule_entry)
|
|
37
|
+
|
|
38
|
+
sarif_results: list[dict[str, Any]] = []
|
|
39
|
+
for diag in result.diagnostics:
|
|
40
|
+
level = (
|
|
41
|
+
"error"
|
|
42
|
+
if diag.severity == Severity.ERROR
|
|
43
|
+
else "warning"
|
|
44
|
+
if diag.severity == Severity.WARNING
|
|
45
|
+
else "note"
|
|
46
|
+
)
|
|
47
|
+
res_entry: dict[str, Any] = {
|
|
48
|
+
"ruleId": diag.id,
|
|
49
|
+
"level": level,
|
|
50
|
+
"message": {"text": diag.message},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if diag.file:
|
|
54
|
+
res_entry["locations"] = [
|
|
55
|
+
{
|
|
56
|
+
"physicalLocation": {
|
|
57
|
+
"artifactLocation": {
|
|
58
|
+
"uri": diag.file.replace("\\", "/"),
|
|
59
|
+
},
|
|
60
|
+
"region": {
|
|
61
|
+
"startLine": diag.line or 1,
|
|
62
|
+
"startColumn": diag.column or 1,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
sarif_results.append(res_entry)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
71
|
+
"version": "2.1.0",
|
|
72
|
+
"runs": [
|
|
73
|
+
{
|
|
74
|
+
"tool": {
|
|
75
|
+
"driver": {
|
|
76
|
+
"name": "qv",
|
|
77
|
+
"semanticVersion": "0.1.0",
|
|
78
|
+
"informationUri": "https://github.com/inzamol/qv",
|
|
79
|
+
"rules": rules_dict,
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"results": sarif_results,
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
}
|