gwc-pybundle 2.1.2__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.
Potentially problematic release.
This version of gwc-pybundle might be problematic. Click here for more details.
- gwc_pybundle-2.1.2.dist-info/METADATA +903 -0
- gwc_pybundle-2.1.2.dist-info/RECORD +82 -0
- gwc_pybundle-2.1.2.dist-info/WHEEL +5 -0
- gwc_pybundle-2.1.2.dist-info/entry_points.txt +2 -0
- gwc_pybundle-2.1.2.dist-info/licenses/LICENSE.md +25 -0
- gwc_pybundle-2.1.2.dist-info/top_level.txt +1 -0
- pybundle/__init__.py +0 -0
- pybundle/__main__.py +4 -0
- pybundle/cli.py +546 -0
- pybundle/context.py +404 -0
- pybundle/doctor.py +148 -0
- pybundle/filters.py +228 -0
- pybundle/manifest.py +77 -0
- pybundle/packaging.py +45 -0
- pybundle/policy.py +132 -0
- pybundle/profiles.py +454 -0
- pybundle/roadmap_model.py +42 -0
- pybundle/roadmap_scan.py +328 -0
- pybundle/root_detect.py +14 -0
- pybundle/runner.py +180 -0
- pybundle/steps/__init__.py +26 -0
- pybundle/steps/ai_context.py +791 -0
- pybundle/steps/api_docs.py +219 -0
- pybundle/steps/asyncio_analysis.py +358 -0
- pybundle/steps/bandit.py +72 -0
- pybundle/steps/base.py +20 -0
- pybundle/steps/blocking_call_detection.py +291 -0
- pybundle/steps/call_graph.py +219 -0
- pybundle/steps/compileall.py +76 -0
- pybundle/steps/config_docs.py +319 -0
- pybundle/steps/config_validation.py +302 -0
- pybundle/steps/container_image.py +294 -0
- pybundle/steps/context_expand.py +272 -0
- pybundle/steps/copy_pack.py +293 -0
- pybundle/steps/coverage.py +101 -0
- pybundle/steps/cprofile_step.py +166 -0
- pybundle/steps/dependency_sizes.py +136 -0
- pybundle/steps/django_checks.py +214 -0
- pybundle/steps/dockerfile_lint.py +282 -0
- pybundle/steps/dockerignore.py +311 -0
- pybundle/steps/duplication.py +103 -0
- pybundle/steps/env_completeness.py +269 -0
- pybundle/steps/env_var_usage.py +253 -0
- pybundle/steps/error_refs.py +204 -0
- pybundle/steps/event_loop_patterns.py +280 -0
- pybundle/steps/exception_patterns.py +190 -0
- pybundle/steps/fastapi_integration.py +250 -0
- pybundle/steps/flask_debugging.py +312 -0
- pybundle/steps/git_analytics.py +315 -0
- pybundle/steps/handoff_md.py +176 -0
- pybundle/steps/import_time.py +175 -0
- pybundle/steps/interrogate.py +106 -0
- pybundle/steps/license_scan.py +96 -0
- pybundle/steps/line_profiler.py +117 -0
- pybundle/steps/link_validation.py +287 -0
- pybundle/steps/logging_analysis.py +233 -0
- pybundle/steps/memory_profile.py +176 -0
- pybundle/steps/migration_history.py +336 -0
- pybundle/steps/mutation_testing.py +141 -0
- pybundle/steps/mypy.py +103 -0
- pybundle/steps/orm_optimization.py +316 -0
- pybundle/steps/pip_audit.py +45 -0
- pybundle/steps/pipdeptree.py +62 -0
- pybundle/steps/pylance.py +562 -0
- pybundle/steps/pytest.py +66 -0
- pybundle/steps/query_pattern_analysis.py +334 -0
- pybundle/steps/radon.py +161 -0
- pybundle/steps/repro_md.py +161 -0
- pybundle/steps/rg_scans.py +78 -0
- pybundle/steps/roadmap.py +153 -0
- pybundle/steps/ruff.py +117 -0
- pybundle/steps/secrets_detection.py +235 -0
- pybundle/steps/security_headers.py +309 -0
- pybundle/steps/shell.py +74 -0
- pybundle/steps/slow_tests.py +178 -0
- pybundle/steps/sqlalchemy_validation.py +269 -0
- pybundle/steps/test_flakiness.py +184 -0
- pybundle/steps/tree.py +116 -0
- pybundle/steps/type_coverage.py +277 -0
- pybundle/steps/unused_deps.py +211 -0
- pybundle/steps/vulture.py +167 -0
- pybundle/tools.py +63 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Step: .dockerignore Effectiveness Analysis
|
|
3
|
+
Validate .dockerignore and analyze build context efficiency.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import fnmatch
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Set, List
|
|
9
|
+
|
|
10
|
+
from .base import Step, StepResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DockerigoreStep(Step):
|
|
14
|
+
"""Analyze .dockerignore effectiveness and build context."""
|
|
15
|
+
|
|
16
|
+
name = "dockerignore analysis"
|
|
17
|
+
|
|
18
|
+
def run(self, ctx: "BundleContext") -> StepResult: # type: ignore[name-defined]
|
|
19
|
+
"""Analyze .dockerignore patterns and effectiveness."""
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
start = time.time()
|
|
23
|
+
|
|
24
|
+
root = ctx.root
|
|
25
|
+
|
|
26
|
+
# Check if Dockerfile exists
|
|
27
|
+
dockerfiles = list(root.rglob("Dockerfile*"))
|
|
28
|
+
if not dockerfiles:
|
|
29
|
+
elapsed = int(time.time() - start)
|
|
30
|
+
return StepResult(self.name, "SKIP", elapsed, "No Dockerfiles found")
|
|
31
|
+
|
|
32
|
+
# Find .dockerignore
|
|
33
|
+
dockerignore_path = root / ".dockerignore"
|
|
34
|
+
if not dockerignore_path.exists():
|
|
35
|
+
elapsed = int(time.time() - start)
|
|
36
|
+
return StepResult(self.name, "SKIP", elapsed, "No .dockerignore found")
|
|
37
|
+
|
|
38
|
+
# Parse .dockerignore patterns
|
|
39
|
+
ignore_patterns = self._parse_dockerignore(dockerignore_path)
|
|
40
|
+
|
|
41
|
+
# Analyze what would be included/excluded
|
|
42
|
+
included_files, excluded_files, analysis = self._analyze_build_context(
|
|
43
|
+
root, ignore_patterns
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Generate report
|
|
47
|
+
lines = [
|
|
48
|
+
"=" * 80,
|
|
49
|
+
".DOCKERIGNORE EFFECTIVENESS ANALYSIS",
|
|
50
|
+
"=" * 80,
|
|
51
|
+
"",
|
|
52
|
+
f"Build context root: {root}",
|
|
53
|
+
f".dockerignore location: {dockerignore_path}",
|
|
54
|
+
"",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# Stats
|
|
58
|
+
lines.extend(
|
|
59
|
+
[
|
|
60
|
+
"=" * 80,
|
|
61
|
+
"BUILD CONTEXT STATISTICS",
|
|
62
|
+
"=" * 80,
|
|
63
|
+
"",
|
|
64
|
+
f"Total files in build context: {len(included_files) + len(excluded_files)}",
|
|
65
|
+
f"Included files: {len(included_files)}",
|
|
66
|
+
f"Excluded files: {len(excluded_files)}",
|
|
67
|
+
f"Exclusion rate: {len(excluded_files) / (len(included_files) + len(excluded_files)) * 100:.1f}%",
|
|
68
|
+
"",
|
|
69
|
+
]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Show ignored files by category
|
|
73
|
+
if excluded_files:
|
|
74
|
+
lines.extend(
|
|
75
|
+
[
|
|
76
|
+
"=" * 80,
|
|
77
|
+
"EXCLUDED FILES BY CATEGORY",
|
|
78
|
+
"=" * 80,
|
|
79
|
+
"",
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
categories = {
|
|
84
|
+
"version_control": [],
|
|
85
|
+
"cache": [],
|
|
86
|
+
"tests": [],
|
|
87
|
+
"docs": [],
|
|
88
|
+
"build_artifacts": [],
|
|
89
|
+
"env": [],
|
|
90
|
+
"venv": [],
|
|
91
|
+
"node": [],
|
|
92
|
+
"other": [],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for file_path in sorted(excluded_files):
|
|
96
|
+
path_str = str(file_path).lower()
|
|
97
|
+
|
|
98
|
+
if any(
|
|
99
|
+
x in path_str
|
|
100
|
+
for x in [".git", ".hg", ".svn", ".bzr", ".gitignore"]
|
|
101
|
+
):
|
|
102
|
+
categories["version_control"].append(file_path)
|
|
103
|
+
elif any(
|
|
104
|
+
x in path_str for x in ["__pycache__", ".pytest_cache", ".mypy_cache"]
|
|
105
|
+
):
|
|
106
|
+
categories["cache"].append(file_path)
|
|
107
|
+
elif any(x in path_str for x in ["test", "spec"]):
|
|
108
|
+
categories["tests"].append(file_path)
|
|
109
|
+
elif any(x in path_str for x in ["docs", "readme", "doc"]):
|
|
110
|
+
categories["docs"].append(file_path)
|
|
111
|
+
elif any(
|
|
112
|
+
x in path_str
|
|
113
|
+
for x in ["build", "dist", ".egg", "*.pyc", "*.o", "*.so"]
|
|
114
|
+
):
|
|
115
|
+
categories["build_artifacts"].append(file_path)
|
|
116
|
+
elif any(x in path_str for x in [".env", "secrets"]):
|
|
117
|
+
categories["env"].append(file_path)
|
|
118
|
+
elif any(x in path_str for x in ["venv", ".venv", "env/"]):
|
|
119
|
+
categories["venv"].append(file_path)
|
|
120
|
+
elif any(x in path_str for x in ["node_modules", "npm", "yarn"]):
|
|
121
|
+
categories["node"].append(file_path)
|
|
122
|
+
else:
|
|
123
|
+
categories["other"].append(file_path)
|
|
124
|
+
|
|
125
|
+
for category, files in categories.items():
|
|
126
|
+
if files:
|
|
127
|
+
cat_name = category.replace("_", " ").title()
|
|
128
|
+
lines.append(f"\n{cat_name} ({len(files)} files):")
|
|
129
|
+
for f in files[:10]: # Show first 10
|
|
130
|
+
lines.append(f" - {f}")
|
|
131
|
+
if len(files) > 10:
|
|
132
|
+
lines.append(f" ... and {len(files) - 10} more")
|
|
133
|
+
|
|
134
|
+
lines.append("")
|
|
135
|
+
|
|
136
|
+
# Show .dockerignore patterns
|
|
137
|
+
lines.extend(
|
|
138
|
+
[
|
|
139
|
+
"=" * 80,
|
|
140
|
+
".DOCKERIGNORE PATTERNS",
|
|
141
|
+
"=" * 80,
|
|
142
|
+
"",
|
|
143
|
+
f"Total patterns: {len(ignore_patterns)}",
|
|
144
|
+
"",
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
for i, pattern in enumerate(ignore_patterns[:50], 1):
|
|
149
|
+
lines.append(f"{i:3}. {pattern}")
|
|
150
|
+
|
|
151
|
+
if len(ignore_patterns) > 50:
|
|
152
|
+
lines.append(f" ... and {len(ignore_patterns) - 50} more patterns")
|
|
153
|
+
|
|
154
|
+
lines.append("")
|
|
155
|
+
|
|
156
|
+
# Analysis of included files that might be unnecessary
|
|
157
|
+
if included_files:
|
|
158
|
+
large_files = []
|
|
159
|
+
for file_path in included_files:
|
|
160
|
+
try:
|
|
161
|
+
size = file_path.stat().st_size
|
|
162
|
+
if size > 1024 * 1024: # > 1MB
|
|
163
|
+
large_files.append((file_path, size))
|
|
164
|
+
except OSError:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
if large_files:
|
|
168
|
+
large_files.sort(key=lambda x: x[1], reverse=True)
|
|
169
|
+
|
|
170
|
+
lines.extend(
|
|
171
|
+
[
|
|
172
|
+
"=" * 80,
|
|
173
|
+
"LARGE INCLUDED FILES (potential optimization targets)",
|
|
174
|
+
"=" * 80,
|
|
175
|
+
"",
|
|
176
|
+
]
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
for file_path, size in large_files[:20]:
|
|
180
|
+
size_mb = size / (1024 * 1024)
|
|
181
|
+
lines.append(f" {str(file_path):50} {size_mb:8.2f} MB")
|
|
182
|
+
|
|
183
|
+
if len(large_files) > 20:
|
|
184
|
+
lines.append(f" ... and {len(large_files) - 20} more")
|
|
185
|
+
|
|
186
|
+
lines.append("")
|
|
187
|
+
|
|
188
|
+
# Recommendations
|
|
189
|
+
lines.extend(
|
|
190
|
+
[
|
|
191
|
+
"=" * 80,
|
|
192
|
+
"RECOMMENDATIONS",
|
|
193
|
+
"=" * 80,
|
|
194
|
+
"",
|
|
195
|
+
]
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if analysis["has_venv"]:
|
|
199
|
+
lines.append(" - Virtual environments should be excluded: add 'venv/' or '.venv/'")
|
|
200
|
+
if analysis["has_tests"]:
|
|
201
|
+
lines.append(" - Consider excluding test files: add 'test*' or 'tests/'")
|
|
202
|
+
if analysis["has_docs"]:
|
|
203
|
+
lines.append(" - Consider excluding documentation: add 'docs/'")
|
|
204
|
+
if analysis["has_cache"]:
|
|
205
|
+
lines.append(" - Cache directories should be excluded: add '__pycache__/'")
|
|
206
|
+
if analysis["has_git"]:
|
|
207
|
+
lines.append(" - VCS files should be excluded: add '.git/'")
|
|
208
|
+
|
|
209
|
+
if not (
|
|
210
|
+
analysis["has_venv"]
|
|
211
|
+
or analysis["has_tests"]
|
|
212
|
+
or analysis["has_cache"]
|
|
213
|
+
or analysis["has_git"]
|
|
214
|
+
):
|
|
215
|
+
lines.append(" - .dockerignore is well configured")
|
|
216
|
+
|
|
217
|
+
# Image size impact
|
|
218
|
+
if excluded_files:
|
|
219
|
+
estimated_excluded = 0
|
|
220
|
+
for file_path in excluded_files:
|
|
221
|
+
try:
|
|
222
|
+
estimated_excluded += file_path.stat().st_size
|
|
223
|
+
except OSError:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
if estimated_excluded > 1024 * 1024:
|
|
227
|
+
excluded_mb = estimated_excluded / (1024 * 1024)
|
|
228
|
+
lines.append(f" - Estimated space saved: ~{excluded_mb:.1f} MB")
|
|
229
|
+
|
|
230
|
+
lines.append("")
|
|
231
|
+
|
|
232
|
+
# Write report
|
|
233
|
+
output = "\n".join(lines)
|
|
234
|
+
dest = ctx.workdir / "meta" / "106_dockerignore_analysis.txt"
|
|
235
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
dest.write_text(output, encoding="utf-8")
|
|
237
|
+
|
|
238
|
+
elapsed = int(time.time() - start)
|
|
239
|
+
return StepResult(self.name, "OK", elapsed, "")
|
|
240
|
+
|
|
241
|
+
def _parse_dockerignore(self, dockerignore_path: Path) -> List[str]:
|
|
242
|
+
"""Parse .dockerignore file and return patterns."""
|
|
243
|
+
patterns = []
|
|
244
|
+
try:
|
|
245
|
+
content = dockerignore_path.read_text(encoding="utf-8", errors="ignore")
|
|
246
|
+
for line in content.split("\n"):
|
|
247
|
+
line = line.strip()
|
|
248
|
+
# Skip empty lines and comments
|
|
249
|
+
if line and not line.startswith("#"):
|
|
250
|
+
patterns.append(line)
|
|
251
|
+
except (OSError, UnicodeDecodeError):
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
return patterns
|
|
255
|
+
|
|
256
|
+
def _analyze_build_context(
|
|
257
|
+
self, root: Path, ignore_patterns: List[str]
|
|
258
|
+
) -> tuple[Set[Path], Set[Path], dict]:
|
|
259
|
+
"""Analyze build context and determine included/excluded files."""
|
|
260
|
+
included = set()
|
|
261
|
+
excluded = set()
|
|
262
|
+
analysis = {
|
|
263
|
+
"has_venv": False,
|
|
264
|
+
"has_tests": False,
|
|
265
|
+
"has_docs": False,
|
|
266
|
+
"has_cache": False,
|
|
267
|
+
"has_git": False,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
# Walk directory tree
|
|
271
|
+
for file_path in root.rglob("*"):
|
|
272
|
+
if not file_path.is_file():
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
rel_path = file_path.relative_to(root)
|
|
276
|
+
rel_str = str(rel_path)
|
|
277
|
+
|
|
278
|
+
# Check if matches any ignore pattern
|
|
279
|
+
is_ignored = False
|
|
280
|
+
for pattern in ignore_patterns:
|
|
281
|
+
# Handle patterns with/without trailing slash
|
|
282
|
+
if pattern.endswith("/"):
|
|
283
|
+
# Directory pattern
|
|
284
|
+
if fnmatch.fnmatch(rel_str, pattern.rstrip("/") + "*"):
|
|
285
|
+
is_ignored = True
|
|
286
|
+
break
|
|
287
|
+
else:
|
|
288
|
+
# File or glob pattern
|
|
289
|
+
if fnmatch.fnmatch(rel_str, pattern):
|
|
290
|
+
is_ignored = True
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
if is_ignored:
|
|
294
|
+
excluded.add(rel_path)
|
|
295
|
+
|
|
296
|
+
# Track what's being excluded
|
|
297
|
+
rel_lower = rel_str.lower()
|
|
298
|
+
if "venv" in rel_lower or ".venv" in rel_lower:
|
|
299
|
+
analysis["has_venv"] = True
|
|
300
|
+
if "test" in rel_lower:
|
|
301
|
+
analysis["has_tests"] = True
|
|
302
|
+
if "docs" in rel_lower or "readme" in rel_lower:
|
|
303
|
+
analysis["has_docs"] = True
|
|
304
|
+
if "__pycache__" in rel_lower or "cache" in rel_lower:
|
|
305
|
+
analysis["has_cache"] = True
|
|
306
|
+
if ".git" in rel_lower:
|
|
307
|
+
analysis["has_git"] = True
|
|
308
|
+
else:
|
|
309
|
+
included.add(rel_path)
|
|
310
|
+
|
|
311
|
+
return included, excluded, analysis
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess # nosec B404 - Required for tool execution, paths validated
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import StepResult
|
|
9
|
+
from ..context import BundleContext
|
|
10
|
+
from ..tools import which
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _repo_has_py_files(root: Path) -> bool:
|
|
14
|
+
"""Fast check if there are Python files to scan."""
|
|
15
|
+
for p in root.rglob("*.py"):
|
|
16
|
+
parts = set(p.parts)
|
|
17
|
+
if (
|
|
18
|
+
".venv" not in parts
|
|
19
|
+
and "__pycache__" not in parts
|
|
20
|
+
and "node_modules" not in parts
|
|
21
|
+
and "dist" not in parts
|
|
22
|
+
and "build" not in parts
|
|
23
|
+
and "artifacts" not in parts
|
|
24
|
+
):
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DuplicationStep:
|
|
31
|
+
name: str = "duplication"
|
|
32
|
+
target: str = "."
|
|
33
|
+
outfile: str = "logs/53_duplication.txt"
|
|
34
|
+
|
|
35
|
+
def run(self, ctx: BundleContext) -> StepResult:
|
|
36
|
+
start = time.time()
|
|
37
|
+
out = ctx.workdir / self.outfile
|
|
38
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
pylint = which("pylint")
|
|
41
|
+
if not pylint:
|
|
42
|
+
out.write_text(
|
|
43
|
+
"pylint not found; skipping (pip install pylint)\n", encoding="utf-8"
|
|
44
|
+
)
|
|
45
|
+
return StepResult(self.name, "SKIP", 0, "missing pylint")
|
|
46
|
+
|
|
47
|
+
if not _repo_has_py_files(ctx.root):
|
|
48
|
+
out.write_text(
|
|
49
|
+
"no .py files detected; skipping duplication check\n", encoding="utf-8"
|
|
50
|
+
)
|
|
51
|
+
return StepResult(self.name, "SKIP", 0, "no python files")
|
|
52
|
+
|
|
53
|
+
# Find the main package directory (directory with __init__.py)
|
|
54
|
+
# This is typically the directory with the same name as the project
|
|
55
|
+
# or explicitly named "src", "lib", etc.
|
|
56
|
+
target_path = ctx.root / self.target
|
|
57
|
+
|
|
58
|
+
# If target is ".", try to find the actual package directory
|
|
59
|
+
if self.target == ".":
|
|
60
|
+
# Look for a directory with __init__.py at root level
|
|
61
|
+
for item in ctx.root.iterdir():
|
|
62
|
+
if item.is_dir() and not item.name.startswith("."):
|
|
63
|
+
init_file = item / "__init__.py"
|
|
64
|
+
if init_file.exists():
|
|
65
|
+
target_path = item
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
cmd = [
|
|
69
|
+
pylint,
|
|
70
|
+
str(target_path),
|
|
71
|
+
"--disable=all", # Disable all other checks
|
|
72
|
+
"--enable=duplicate-code", # Only check for duplication
|
|
73
|
+
"--min-similarity-lines=6", # Minimum 6 lines to be considered duplicate
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = subprocess.run( # nosec B603 - Using full path from which()
|
|
78
|
+
cmd,
|
|
79
|
+
cwd=ctx.root,
|
|
80
|
+
stdout=subprocess.PIPE,
|
|
81
|
+
stderr=subprocess.STDOUT,
|
|
82
|
+
text=True,
|
|
83
|
+
timeout=180, # Duplication detection can be slower
|
|
84
|
+
)
|
|
85
|
+
out.write_text(result.stdout, encoding="utf-8")
|
|
86
|
+
elapsed = int((time.time() - start) * 1000)
|
|
87
|
+
|
|
88
|
+
# pylint returns various exit codes:
|
|
89
|
+
# 0 = no issues
|
|
90
|
+
# 1, 2, 4, 8, 16 = various issue types (we still want the output)
|
|
91
|
+
# We consider all of these as success
|
|
92
|
+
if result.returncode in (0, 1, 2, 4, 8, 16, 24, 32):
|
|
93
|
+
return StepResult(self.name, "OK", elapsed, "")
|
|
94
|
+
else:
|
|
95
|
+
return StepResult(
|
|
96
|
+
self.name, "FAIL", elapsed, f"exit {result.returncode}"
|
|
97
|
+
)
|
|
98
|
+
except subprocess.TimeoutExpired:
|
|
99
|
+
out.write_text("duplication check timed out after 180s\n", encoding="utf-8")
|
|
100
|
+
return StepResult(self.name, "FAIL", 180000, "timeout")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
out.write_text(f"duplication check error: {e}\n", encoding="utf-8")
|
|
103
|
+
return StepResult(self.name, "FAIL", 0, str(e))
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Step: Environment Variable Completeness Check
|
|
3
|
+
Verify that all required environment variables are documented.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Set, Tuple, Optional
|
|
9
|
+
|
|
10
|
+
from .base import Step, StepResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EnvCompletenessStep(Step):
|
|
14
|
+
"""Check environment variable completeness and documentation."""
|
|
15
|
+
|
|
16
|
+
name = "env completeness"
|
|
17
|
+
|
|
18
|
+
def run(self, ctx: "BundleContext") -> StepResult: # type: ignore[name-defined]
|
|
19
|
+
"""Analyze environment variable completeness."""
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
start = time.time()
|
|
23
|
+
|
|
24
|
+
root = ctx.root
|
|
25
|
+
|
|
26
|
+
# Find all env var usages
|
|
27
|
+
used_vars = self._find_env_var_usage(root)
|
|
28
|
+
|
|
29
|
+
# Find documented vars
|
|
30
|
+
documented_vars = self._find_documented_vars(root)
|
|
31
|
+
|
|
32
|
+
# Generate report
|
|
33
|
+
lines = [
|
|
34
|
+
"=" * 80,
|
|
35
|
+
"ENVIRONMENT VARIABLE COMPLETENESS REPORT",
|
|
36
|
+
"=" * 80,
|
|
37
|
+
"",
|
|
38
|
+
f"Project root: {root}",
|
|
39
|
+
"",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Summary
|
|
43
|
+
lines.extend(
|
|
44
|
+
[
|
|
45
|
+
"SUMMARY",
|
|
46
|
+
"=" * 80,
|
|
47
|
+
"",
|
|
48
|
+
f"Environment variables referenced: {len(used_vars['all_vars'])}",
|
|
49
|
+
f"Documented variables: {len(documented_vars['all_vars'])}",
|
|
50
|
+
"",
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Used but not documented
|
|
55
|
+
undocumented = used_vars["all_vars"] - documented_vars["all_vars"]
|
|
56
|
+
if undocumented:
|
|
57
|
+
lines.append(f"⚠ NOT DOCUMENTED ({len(undocumented)}):")
|
|
58
|
+
for var in sorted(undocumented):
|
|
59
|
+
lines.append(f" - {var}")
|
|
60
|
+
lines.append("")
|
|
61
|
+
else:
|
|
62
|
+
lines.append("✓ All referenced variables are documented")
|
|
63
|
+
lines.append("")
|
|
64
|
+
|
|
65
|
+
# Documented but not used
|
|
66
|
+
unused = documented_vars["all_vars"] - used_vars["all_vars"]
|
|
67
|
+
if unused:
|
|
68
|
+
lines.append(f"ℹ DOCUMENTED BUT NOT USED ({len(unused)}):")
|
|
69
|
+
for var in sorted(unused):
|
|
70
|
+
lines.append(f" - {var}")
|
|
71
|
+
lines.append("")
|
|
72
|
+
else:
|
|
73
|
+
lines.append("✓ All documented variables are referenced")
|
|
74
|
+
lines.append("")
|
|
75
|
+
|
|
76
|
+
# Detailed analysis
|
|
77
|
+
lines.extend(
|
|
78
|
+
[
|
|
79
|
+
"=" * 80,
|
|
80
|
+
"DETAILED ANALYSIS",
|
|
81
|
+
"=" * 80,
|
|
82
|
+
"",
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Env usage by type
|
|
87
|
+
if used_vars["getenv"] or used_vars["environ"]:
|
|
88
|
+
lines.append("ENVIRONMENT VARIABLE ACCESS PATTERNS:")
|
|
89
|
+
lines.append("")
|
|
90
|
+
|
|
91
|
+
if used_vars["getenv"]:
|
|
92
|
+
lines.append(f" os.getenv() calls: {len(used_vars['getenv'])}")
|
|
93
|
+
for var in sorted(list(used_vars["getenv"]))[:10]:
|
|
94
|
+
lines.append(f" - {var}")
|
|
95
|
+
if len(used_vars["getenv"]) > 10:
|
|
96
|
+
lines.append(f" ... and {len(used_vars['getenv']) - 10} more")
|
|
97
|
+
lines.append("")
|
|
98
|
+
|
|
99
|
+
if used_vars["environ"]:
|
|
100
|
+
lines.append(f" os.environ[] access: {len(used_vars['environ'])}")
|
|
101
|
+
for var in sorted(list(used_vars["environ"]))[:10]:
|
|
102
|
+
lines.append(f" - {var}")
|
|
103
|
+
if len(used_vars["environ"]) > 10:
|
|
104
|
+
lines.append(f" ... and {len(used_vars['environ']) - 10} more")
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
# Documentation sources
|
|
108
|
+
if documented_vars["env_example"] or documented_vars["env_file"]:
|
|
109
|
+
lines.append("DOCUMENTATION SOURCES:")
|
|
110
|
+
lines.append("")
|
|
111
|
+
|
|
112
|
+
if documented_vars["env_example"]:
|
|
113
|
+
lines.append(f" .env.example: {len(documented_vars['env_example'])} variables")
|
|
114
|
+
for var in sorted(list(documented_vars["env_example"]))[:10]:
|
|
115
|
+
lines.append(f" - {var}")
|
|
116
|
+
if len(documented_vars["env_example"]) > 10:
|
|
117
|
+
lines.append(f" ... and {len(documented_vars['env_example']) - 10} more")
|
|
118
|
+
lines.append("")
|
|
119
|
+
|
|
120
|
+
if documented_vars["env_file"]:
|
|
121
|
+
lines.append(f" .env: {len(documented_vars['env_file'])} variables")
|
|
122
|
+
for var in sorted(list(documented_vars["env_file"]))[:10]:
|
|
123
|
+
lines.append(f" - {var}")
|
|
124
|
+
if len(documented_vars["env_file"]) > 10:
|
|
125
|
+
lines.append(f" ... and {len(documented_vars['env_file']) - 10} more")
|
|
126
|
+
lines.append("")
|
|
127
|
+
|
|
128
|
+
# Documentation completeness score
|
|
129
|
+
total_vars = len(used_vars["all_vars"])
|
|
130
|
+
documented_count = len(used_vars["all_vars"] & documented_vars["all_vars"])
|
|
131
|
+
|
|
132
|
+
if total_vars > 0:
|
|
133
|
+
completeness = (documented_count / total_vars) * 100
|
|
134
|
+
lines.append("=" * 80)
|
|
135
|
+
lines.append("COMPLETENESS SCORE")
|
|
136
|
+
lines.append("=" * 80)
|
|
137
|
+
lines.append("")
|
|
138
|
+
lines.append(
|
|
139
|
+
f" {documented_count}/{total_vars} variables documented ({completeness:.1f}%)"
|
|
140
|
+
)
|
|
141
|
+
lines.append("")
|
|
142
|
+
|
|
143
|
+
if completeness == 100:
|
|
144
|
+
lines.append(" ✓ EXCELLENT: All variables properly documented")
|
|
145
|
+
elif completeness >= 80:
|
|
146
|
+
lines.append(" ✓ GOOD: Most variables documented")
|
|
147
|
+
elif completeness >= 50:
|
|
148
|
+
lines.append(" ⚠ FAIR: Several variables missing documentation")
|
|
149
|
+
else:
|
|
150
|
+
lines.append(" ✗ POOR: Majority of variables not documented")
|
|
151
|
+
|
|
152
|
+
lines.append("")
|
|
153
|
+
|
|
154
|
+
# Recommendations
|
|
155
|
+
lines.extend(
|
|
156
|
+
[
|
|
157
|
+
"=" * 80,
|
|
158
|
+
"RECOMMENDATIONS",
|
|
159
|
+
"=" * 80,
|
|
160
|
+
"",
|
|
161
|
+
]
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if undocumented:
|
|
165
|
+
lines.append(" - Add missing variables to .env.example:")
|
|
166
|
+
for var in sorted(list(undocumented))[:5]:
|
|
167
|
+
lines.append(f" {var}=<value>")
|
|
168
|
+
if len(undocumented) > 5:
|
|
169
|
+
lines.append(f" ... and {len(undocumented) - 5} more")
|
|
170
|
+
|
|
171
|
+
if unused:
|
|
172
|
+
lines.append(" - Remove or deprecate unused documented variables")
|
|
173
|
+
|
|
174
|
+
if not (documented_vars["env_example"] or documented_vars["env_file"]):
|
|
175
|
+
lines.append(" - Create .env.example to document all environment variables")
|
|
176
|
+
|
|
177
|
+
lines.append(" - Use descriptive comments in .env.example")
|
|
178
|
+
lines.append(" - Keep .env.example in version control, .env in .gitignore")
|
|
179
|
+
|
|
180
|
+
lines.append("")
|
|
181
|
+
|
|
182
|
+
# Write report
|
|
183
|
+
output = "\n".join(lines)
|
|
184
|
+
dest = ctx.workdir / "meta" / "120_config_completeness.txt"
|
|
185
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
dest.write_text(output, encoding="utf-8")
|
|
187
|
+
|
|
188
|
+
elapsed = int(time.time() - start)
|
|
189
|
+
return StepResult(self.name, "OK", elapsed, "")
|
|
190
|
+
|
|
191
|
+
def _find_env_var_usage(self, root: Path) -> Dict[str, Set[str]]:
|
|
192
|
+
"""Find all environment variable usage patterns."""
|
|
193
|
+
getenv_vars = set()
|
|
194
|
+
environ_vars = set()
|
|
195
|
+
|
|
196
|
+
python_files = list(root.rglob("*.py"))
|
|
197
|
+
|
|
198
|
+
for py_file in python_files:
|
|
199
|
+
# Skip venv and cache
|
|
200
|
+
if any(
|
|
201
|
+
part in py_file.parts
|
|
202
|
+
for part in ["venv", ".venv", "env", "__pycache__", "site-packages"]
|
|
203
|
+
):
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
source = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
208
|
+
|
|
209
|
+
# Find os.getenv patterns
|
|
210
|
+
getenv_pattern = r'os\.getenv\(["\']([A-Z_][A-Z0-9_]*)["\']'
|
|
211
|
+
for match in re.finditer(getenv_pattern, source):
|
|
212
|
+
getenv_vars.add(match.group(1))
|
|
213
|
+
|
|
214
|
+
# Find os.environ patterns
|
|
215
|
+
environ_pattern = r'os\.environ\[["\']([A-Z_][A-Z0-9_]*)["\']'
|
|
216
|
+
for match in re.finditer(environ_pattern, source):
|
|
217
|
+
environ_vars.add(match.group(1))
|
|
218
|
+
|
|
219
|
+
except (OSError, UnicodeDecodeError):
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
all_vars = getenv_vars | environ_vars
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
"all_vars": all_vars,
|
|
226
|
+
"getenv": getenv_vars,
|
|
227
|
+
"environ": environ_vars,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
def _find_documented_vars(self, root: Path) -> Dict[str, Set[str]]:
|
|
231
|
+
"""Find documented environment variables."""
|
|
232
|
+
env_example_vars = set()
|
|
233
|
+
env_file_vars = set()
|
|
234
|
+
|
|
235
|
+
# Check .env.example
|
|
236
|
+
env_example = root / ".env.example"
|
|
237
|
+
if env_example.exists():
|
|
238
|
+
try:
|
|
239
|
+
content = env_example.read_text(encoding="utf-8", errors="ignore")
|
|
240
|
+
for line in content.split("\n"):
|
|
241
|
+
line = line.strip()
|
|
242
|
+
if line and not line.startswith("#") and "=" in line:
|
|
243
|
+
var_name = line.split("=")[0].strip()
|
|
244
|
+
if var_name and var_name.isupper():
|
|
245
|
+
env_example_vars.add(var_name)
|
|
246
|
+
except (OSError, UnicodeDecodeError):
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
# Check .env
|
|
250
|
+
env_file = root / ".env"
|
|
251
|
+
if env_file.exists():
|
|
252
|
+
try:
|
|
253
|
+
content = env_file.read_text(encoding="utf-8", errors="ignore")
|
|
254
|
+
for line in content.split("\n"):
|
|
255
|
+
line = line.strip()
|
|
256
|
+
if line and not line.startswith("#") and "=" in line:
|
|
257
|
+
var_name = line.split("=")[0].strip()
|
|
258
|
+
if var_name and var_name.isupper():
|
|
259
|
+
env_file_vars.add(var_name)
|
|
260
|
+
except (OSError, UnicodeDecodeError):
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
all_vars = env_example_vars | env_file_vars
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
"all_vars": all_vars,
|
|
267
|
+
"env_example": env_example_vars,
|
|
268
|
+
"env_file": env_file_vars,
|
|
269
|
+
}
|