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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Docker analyzers."""
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Environment analyzer implementing ENV-001, ENV-002, ENV-003."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from packaging.specifiers import SpecifierSet
|
|
6
|
+
from packaging.version import Version
|
|
7
|
+
|
|
8
|
+
from qv.core.context import ProjectContext
|
|
9
|
+
from qv.core.models import Diagnostic, Evidence, Suggestion
|
|
10
|
+
from qv.rules.registry import get_rule_definition
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EnvironmentAnalyzer:
|
|
14
|
+
"""Analyzes runtime environments, Dockerfiles, and CI matrices for drift."""
|
|
15
|
+
|
|
16
|
+
id = "environment"
|
|
17
|
+
name = "Environment Analyzer"
|
|
18
|
+
description = (
|
|
19
|
+
"Checks for Python runtime drift, Docker runtime drift, and CI test matrix mismatches."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def analyze(self, context: ProjectContext) -> list[Diagnostic]:
|
|
23
|
+
diagnostics: list[Diagnostic] = []
|
|
24
|
+
|
|
25
|
+
diagnostics.extend(self._check_python_version_drift(context))
|
|
26
|
+
diagnostics.extend(self._check_docker_drift(context))
|
|
27
|
+
diagnostics.extend(self._check_ci_drift(context))
|
|
28
|
+
|
|
29
|
+
return diagnostics
|
|
30
|
+
|
|
31
|
+
def _check_python_version_drift(self, context: ProjectContext) -> list[Diagnostic]:
|
|
32
|
+
"""ENV-001: Check if running Python satisfies project requires-python or config."""
|
|
33
|
+
diagnostics: list[Diagnostic] = []
|
|
34
|
+
rule = get_rule_definition("ENV-001")
|
|
35
|
+
if not rule:
|
|
36
|
+
return diagnostics
|
|
37
|
+
|
|
38
|
+
target_python = context.config.get("target_python")
|
|
39
|
+
if target_python:
|
|
40
|
+
try:
|
|
41
|
+
spec = SpecifierSet(
|
|
42
|
+
target_python
|
|
43
|
+
if any(c in target_python for c in "<>=~!")
|
|
44
|
+
else f"=={target_python}.*"
|
|
45
|
+
)
|
|
46
|
+
current_ver = Version(context.python_runtime.version_str)
|
|
47
|
+
if not spec.contains(current_ver):
|
|
48
|
+
diagnostics.append(
|
|
49
|
+
Diagnostic(
|
|
50
|
+
id=rule.id,
|
|
51
|
+
severity=rule.default_severity,
|
|
52
|
+
category=rule.category,
|
|
53
|
+
title=rule.title,
|
|
54
|
+
message=f"Active Python {context.python_runtime.version_str} does not match target Python {target_python}.",
|
|
55
|
+
evidence=[
|
|
56
|
+
Evidence(
|
|
57
|
+
fact=f"Active Python version: {context.python_runtime.version_str}",
|
|
58
|
+
source=context.python_runtime.executable
|
|
59
|
+
or "Active Interpreter",
|
|
60
|
+
),
|
|
61
|
+
Evidence(
|
|
62
|
+
fact=f"Configured Python target: {target_python}",
|
|
63
|
+
source="pyproject.toml",
|
|
64
|
+
),
|
|
65
|
+
],
|
|
66
|
+
suggestions=[
|
|
67
|
+
Suggestion(
|
|
68
|
+
description=f"Use Python {target_python} for your virtual environment.",
|
|
69
|
+
command=f"uv venv --python {target_python}"
|
|
70
|
+
if context.package_manager == "uv"
|
|
71
|
+
else None,
|
|
72
|
+
)
|
|
73
|
+
],
|
|
74
|
+
doc_url=rule.doc_url,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
return diagnostics
|
|
81
|
+
|
|
82
|
+
def _check_docker_drift(self, context: ProjectContext) -> list[Diagnostic]:
|
|
83
|
+
"""ENV-002: Check Dockerfile Python version against target/active Python."""
|
|
84
|
+
diagnostics: list[Diagnostic] = []
|
|
85
|
+
rule = get_rule_definition("ENV-002")
|
|
86
|
+
if not rule or not context.docker.has_dockerfile or not context.docker.base_python_version:
|
|
87
|
+
return diagnostics
|
|
88
|
+
|
|
89
|
+
docker_py = context.docker.base_python_version
|
|
90
|
+
active_py = f"{context.python_runtime.major}.{context.python_runtime.minor}"
|
|
91
|
+
|
|
92
|
+
if docker_py != active_py:
|
|
93
|
+
rel_docker = (
|
|
94
|
+
context.docker.dockerfile_path.relative_to(context.project_root)
|
|
95
|
+
if context.docker.dockerfile_path
|
|
96
|
+
and context.docker.dockerfile_path.is_relative_to(context.project_root)
|
|
97
|
+
else "Dockerfile"
|
|
98
|
+
)
|
|
99
|
+
diagnostics.append(
|
|
100
|
+
Diagnostic(
|
|
101
|
+
id=rule.id,
|
|
102
|
+
severity=rule.default_severity,
|
|
103
|
+
category=rule.category,
|
|
104
|
+
title=rule.title,
|
|
105
|
+
message=f"Dockerfile base image uses Python {docker_py}, while local runtime is {active_py}.",
|
|
106
|
+
evidence=[
|
|
107
|
+
Evidence(
|
|
108
|
+
fact=f"Dockerfile base image: {context.docker.base_image}",
|
|
109
|
+
source=str(rel_docker),
|
|
110
|
+
),
|
|
111
|
+
Evidence(
|
|
112
|
+
fact=f"Local Python version: {context.python_runtime.version_str}",
|
|
113
|
+
source="Local environment",
|
|
114
|
+
),
|
|
115
|
+
],
|
|
116
|
+
suggestions=[
|
|
117
|
+
Suggestion(
|
|
118
|
+
description=f"Update base image in {rel_docker} to use python:{active_py}-slim.",
|
|
119
|
+
code_snippet=f"FROM python:{active_py}-slim",
|
|
120
|
+
)
|
|
121
|
+
],
|
|
122
|
+
file=str(rel_docker),
|
|
123
|
+
doc_url=rule.doc_url,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return diagnostics
|
|
128
|
+
|
|
129
|
+
def _check_ci_drift(self, context: ProjectContext) -> list[Diagnostic]:
|
|
130
|
+
"""ENV-003: Check CI workflow python versions against active/target python."""
|
|
131
|
+
diagnostics: list[Diagnostic] = []
|
|
132
|
+
rule = get_rule_definition("ENV-003")
|
|
133
|
+
if not rule or not context.ci.has_ci or not context.ci.matrix_python_versions:
|
|
134
|
+
return diagnostics
|
|
135
|
+
|
|
136
|
+
active_major_minor = f"{context.python_runtime.major}.{context.python_runtime.minor}"
|
|
137
|
+
if active_major_minor not in context.ci.matrix_python_versions:
|
|
138
|
+
diagnostics.append(
|
|
139
|
+
Diagnostic(
|
|
140
|
+
id=rule.id,
|
|
141
|
+
severity=rule.default_severity,
|
|
142
|
+
category=rule.category,
|
|
143
|
+
title=rule.title,
|
|
144
|
+
message=f"Local Python {active_major_minor} is not tested in CI matrix ({', '.join(context.ci.matrix_python_versions)}).",
|
|
145
|
+
evidence=[
|
|
146
|
+
Evidence(
|
|
147
|
+
fact=f"CI test matrix versions: {', '.join(context.ci.matrix_python_versions)}",
|
|
148
|
+
source="CI workflows",
|
|
149
|
+
),
|
|
150
|
+
Evidence(
|
|
151
|
+
fact=f"Local Python version: {active_major_minor}",
|
|
152
|
+
source="Local environment",
|
|
153
|
+
),
|
|
154
|
+
],
|
|
155
|
+
suggestions=[
|
|
156
|
+
Suggestion(
|
|
157
|
+
description=f"Add '{active_major_minor}' to your CI matrix configuration.",
|
|
158
|
+
)
|
|
159
|
+
],
|
|
160
|
+
doc_url=rule.doc_url,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return diagnostics
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Import and Architecture analyzer implementing IMP-001 and IMP-002."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from qv.core.context import ProjectContext
|
|
8
|
+
from qv.core.models import Diagnostic, Evidence, Suggestion
|
|
9
|
+
from qv.rules.registry import get_rule_definition
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ImportAnalyzer:
|
|
13
|
+
"""Analyzes import graphs for circular dependencies and unresolved local imports."""
|
|
14
|
+
|
|
15
|
+
id = "imports"
|
|
16
|
+
name = "Import & Architecture Analyzer"
|
|
17
|
+
description = "Detects circular imports and unresolved internal modules."
|
|
18
|
+
|
|
19
|
+
def analyze(self, context: ProjectContext) -> list[Diagnostic]:
|
|
20
|
+
diagnostics: list[Diagnostic] = []
|
|
21
|
+
|
|
22
|
+
diagnostics.extend(self._check_circular_imports(context))
|
|
23
|
+
diagnostics.extend(self._check_unresolved_local_imports(context))
|
|
24
|
+
|
|
25
|
+
return diagnostics
|
|
26
|
+
|
|
27
|
+
def _check_circular_imports(self, context: ProjectContext) -> list[Diagnostic]:
|
|
28
|
+
"""IMP-001: Detect cycles in local import graph."""
|
|
29
|
+
diagnostics: list[Diagnostic] = []
|
|
30
|
+
rule = get_rule_definition("IMP-001")
|
|
31
|
+
if not rule or not context.source_files:
|
|
32
|
+
return diagnostics
|
|
33
|
+
|
|
34
|
+
# Build module mapping
|
|
35
|
+
module_to_file: dict[str, str] = {}
|
|
36
|
+
file_to_module: dict[str, str] = {}
|
|
37
|
+
for sf in context.source_files:
|
|
38
|
+
module_to_file[sf.module_name] = str(sf.relative_path)
|
|
39
|
+
file_to_module[str(sf.path)] = sf.module_name
|
|
40
|
+
|
|
41
|
+
# Build graph
|
|
42
|
+
graph: dict[str, set[str]] = defaultdict(set)
|
|
43
|
+
for imp in context.imports:
|
|
44
|
+
src_mod = file_to_module.get(str(imp.source_file))
|
|
45
|
+
if not src_mod:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
target_mod = imp.module_name
|
|
49
|
+
# If target module is in project
|
|
50
|
+
if target_mod in module_to_file and target_mod != src_mod:
|
|
51
|
+
graph[src_mod].add(target_mod)
|
|
52
|
+
|
|
53
|
+
# Detect cycles using DFS
|
|
54
|
+
visited: set[str] = set()
|
|
55
|
+
recursion_stack: list[str] = []
|
|
56
|
+
detected_cycles: list[list[str]] = []
|
|
57
|
+
|
|
58
|
+
def dfs(node: str) -> None:
|
|
59
|
+
visited.add(node)
|
|
60
|
+
recursion_stack.append(node)
|
|
61
|
+
|
|
62
|
+
for neighbor in graph.get(node, []):
|
|
63
|
+
if neighbor not in visited:
|
|
64
|
+
dfs(neighbor)
|
|
65
|
+
elif neighbor in recursion_stack:
|
|
66
|
+
# Cycle found
|
|
67
|
+
idx = recursion_stack.index(neighbor)
|
|
68
|
+
cycle = recursion_stack[idx:] + [neighbor]
|
|
69
|
+
detected_cycles.append(cycle)
|
|
70
|
+
|
|
71
|
+
recursion_stack.pop()
|
|
72
|
+
|
|
73
|
+
for mod in list(graph.keys()):
|
|
74
|
+
if mod not in visited:
|
|
75
|
+
dfs(mod)
|
|
76
|
+
|
|
77
|
+
# Format diagnostics for cycles
|
|
78
|
+
seen_cycle_sets = set()
|
|
79
|
+
for cycle in detected_cycles:
|
|
80
|
+
cycle_key = tuple(sorted(set(cycle)))
|
|
81
|
+
if cycle_key in seen_cycle_sets:
|
|
82
|
+
continue
|
|
83
|
+
seen_cycle_sets.add(cycle_key)
|
|
84
|
+
|
|
85
|
+
cycle_str = " -> ".join(cycle)
|
|
86
|
+
primary_mod = cycle[0]
|
|
87
|
+
rel_file = module_to_file.get(primary_mod, primary_mod)
|
|
88
|
+
|
|
89
|
+
diagnostics.append(
|
|
90
|
+
Diagnostic(
|
|
91
|
+
id=rule.id,
|
|
92
|
+
severity=rule.default_severity,
|
|
93
|
+
category=rule.category,
|
|
94
|
+
title="Circular import detected",
|
|
95
|
+
message=f"Circular import cycle detected: {cycle_str}",
|
|
96
|
+
evidence=[
|
|
97
|
+
Evidence(
|
|
98
|
+
fact=f"Import chain: {cycle_str}",
|
|
99
|
+
source=rel_file,
|
|
100
|
+
)
|
|
101
|
+
],
|
|
102
|
+
suggestions=[
|
|
103
|
+
Suggestion(
|
|
104
|
+
description=f"Break the cycle between {cycle[0]} and {cycle[1]} by refactoring shared logic into a separate module or using deferred imports inside functions.",
|
|
105
|
+
)
|
|
106
|
+
],
|
|
107
|
+
file=rel_file,
|
|
108
|
+
doc_url=rule.doc_url,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return diagnostics
|
|
113
|
+
|
|
114
|
+
def _check_unresolved_local_imports(self, context: ProjectContext) -> list[Diagnostic]:
|
|
115
|
+
"""IMP-002: Detect local imports that cannot be resolved to any project source file."""
|
|
116
|
+
diagnostics: list[Diagnostic] = []
|
|
117
|
+
rule = get_rule_definition("IMP-002")
|
|
118
|
+
if not rule or not context.source_files:
|
|
119
|
+
return diagnostics
|
|
120
|
+
|
|
121
|
+
known_modules = {sf.module_name for sf in context.source_files}
|
|
122
|
+
|
|
123
|
+
for imp in context.imports:
|
|
124
|
+
if not imp.module_name:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# If it's a relative import or clearly aiming at a known root
|
|
128
|
+
if imp.is_relative:
|
|
129
|
+
rel_path = (
|
|
130
|
+
imp.source_file.relative_to(context.project_root)
|
|
131
|
+
if imp.source_file.is_relative_to(context.project_root)
|
|
132
|
+
else imp.source_file
|
|
133
|
+
)
|
|
134
|
+
# If relative and target module is empty or not in known modules
|
|
135
|
+
if imp.module_name and imp.module_name not in known_modules:
|
|
136
|
+
diagnostics.append(
|
|
137
|
+
Diagnostic(
|
|
138
|
+
id=rule.id,
|
|
139
|
+
severity=rule.default_severity,
|
|
140
|
+
category=rule.category,
|
|
141
|
+
title=f"Unresolved relative import: {imp.module_name}",
|
|
142
|
+
message=f"Module '{imp.module_name}' imported at {rel_path}:{imp.line_number} could not be resolved.",
|
|
143
|
+
evidence=[
|
|
144
|
+
Evidence(
|
|
145
|
+
fact=f"Relative import statement at {rel_path}:{imp.line_number}",
|
|
146
|
+
source=str(rel_path),
|
|
147
|
+
)
|
|
148
|
+
],
|
|
149
|
+
suggestions=[
|
|
150
|
+
Suggestion(
|
|
151
|
+
description=f"Verify target file exists or correct relative import path in {rel_path}.",
|
|
152
|
+
)
|
|
153
|
+
],
|
|
154
|
+
file=str(rel_path),
|
|
155
|
+
line=imp.line_number,
|
|
156
|
+
doc_url=rule.doc_url,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return diagnostics
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Packaging analyzer implementing PKG-001 and PKG-002."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from qv.core.context import ProjectContext
|
|
8
|
+
from qv.core.models import Diagnostic, Evidence, Suggestion
|
|
9
|
+
from qv.rules.registry import get_rule_definition
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else:
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PackagingAnalyzer:
|
|
18
|
+
"""Analyzes pyproject.toml and packaging metadata for validity and completeness."""
|
|
19
|
+
|
|
20
|
+
id = "packaging"
|
|
21
|
+
name = "Packaging Analyzer"
|
|
22
|
+
description = (
|
|
23
|
+
"Checks pyproject.toml and project metadata for PEP 621 compliance and completeness."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def analyze(self, context: ProjectContext) -> list[Diagnostic]:
|
|
27
|
+
diagnostics: list[Diagnostic] = []
|
|
28
|
+
pyproject_path = context.project_root / "pyproject.toml"
|
|
29
|
+
|
|
30
|
+
if not pyproject_path.exists():
|
|
31
|
+
return diagnostics
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with open(pyproject_path, "rb") as f:
|
|
35
|
+
data = tomllib.load(f)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
rule_pkg002 = get_rule_definition("PKG-002")
|
|
38
|
+
if rule_pkg002:
|
|
39
|
+
diagnostics.append(
|
|
40
|
+
Diagnostic(
|
|
41
|
+
id=rule_pkg002.id,
|
|
42
|
+
severity=rule_pkg002.default_severity,
|
|
43
|
+
category=rule_pkg002.category,
|
|
44
|
+
title=rule_pkg002.title,
|
|
45
|
+
message=f"Failed to parse pyproject.toml: {e}",
|
|
46
|
+
evidence=[
|
|
47
|
+
Evidence(
|
|
48
|
+
fact=str(e),
|
|
49
|
+
source="pyproject.toml",
|
|
50
|
+
)
|
|
51
|
+
],
|
|
52
|
+
suggestions=[
|
|
53
|
+
Suggestion(
|
|
54
|
+
description="Check TOML syntax in pyproject.toml for missing quotes, brackets, or invalid keys.",
|
|
55
|
+
)
|
|
56
|
+
],
|
|
57
|
+
file="pyproject.toml",
|
|
58
|
+
doc_url=rule_pkg002.doc_url,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return diagnostics
|
|
62
|
+
|
|
63
|
+
# Check PKG-001: Missing metadata
|
|
64
|
+
rule_pkg001 = get_rule_definition("PKG-001")
|
|
65
|
+
if rule_pkg001:
|
|
66
|
+
project_table = data.get("project")
|
|
67
|
+
poetry_table = data.get("tool", {}).get("poetry")
|
|
68
|
+
|
|
69
|
+
if project_table is None and poetry_table is None:
|
|
70
|
+
diagnostics.append(
|
|
71
|
+
Diagnostic(
|
|
72
|
+
id=rule_pkg001.id,
|
|
73
|
+
severity=rule_pkg001.default_severity,
|
|
74
|
+
category=rule_pkg001.category,
|
|
75
|
+
title="Missing [project] metadata in pyproject.toml",
|
|
76
|
+
message="pyproject.toml does not contain a standard PEP 621 [project] table or tool metadata table.",
|
|
77
|
+
evidence=[
|
|
78
|
+
Evidence(
|
|
79
|
+
fact="Neither [project] nor [tool.poetry] table found in pyproject.toml",
|
|
80
|
+
source="pyproject.toml",
|
|
81
|
+
)
|
|
82
|
+
],
|
|
83
|
+
suggestions=[
|
|
84
|
+
Suggestion(
|
|
85
|
+
description="Add a [project] table with name, version, and dependencies.",
|
|
86
|
+
)
|
|
87
|
+
],
|
|
88
|
+
file="pyproject.toml",
|
|
89
|
+
doc_url=rule_pkg001.doc_url,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
elif project_table is not None:
|
|
93
|
+
if not project_table.get("name"):
|
|
94
|
+
diagnostics.append(
|
|
95
|
+
Diagnostic(
|
|
96
|
+
id=rule_pkg001.id,
|
|
97
|
+
severity=rule_pkg001.default_severity,
|
|
98
|
+
category=rule_pkg001.category,
|
|
99
|
+
title="Missing project name in [project] table",
|
|
100
|
+
message="[project] table in pyproject.toml is missing the required 'name' field.",
|
|
101
|
+
evidence=[
|
|
102
|
+
Evidence(
|
|
103
|
+
fact="'name' field is missing or empty in [project]",
|
|
104
|
+
source="pyproject.toml",
|
|
105
|
+
)
|
|
106
|
+
],
|
|
107
|
+
suggestions=[
|
|
108
|
+
Suggestion(
|
|
109
|
+
description='Add name = "my-project" to [project].',
|
|
110
|
+
)
|
|
111
|
+
],
|
|
112
|
+
file="pyproject.toml",
|
|
113
|
+
doc_url=rule_pkg001.doc_url,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return diagnostics
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Security analyzers."""
|