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.

Files changed (82) hide show
  1. gwc_pybundle-2.1.2.dist-info/METADATA +903 -0
  2. gwc_pybundle-2.1.2.dist-info/RECORD +82 -0
  3. gwc_pybundle-2.1.2.dist-info/WHEEL +5 -0
  4. gwc_pybundle-2.1.2.dist-info/entry_points.txt +2 -0
  5. gwc_pybundle-2.1.2.dist-info/licenses/LICENSE.md +25 -0
  6. gwc_pybundle-2.1.2.dist-info/top_level.txt +1 -0
  7. pybundle/__init__.py +0 -0
  8. pybundle/__main__.py +4 -0
  9. pybundle/cli.py +546 -0
  10. pybundle/context.py +404 -0
  11. pybundle/doctor.py +148 -0
  12. pybundle/filters.py +228 -0
  13. pybundle/manifest.py +77 -0
  14. pybundle/packaging.py +45 -0
  15. pybundle/policy.py +132 -0
  16. pybundle/profiles.py +454 -0
  17. pybundle/roadmap_model.py +42 -0
  18. pybundle/roadmap_scan.py +328 -0
  19. pybundle/root_detect.py +14 -0
  20. pybundle/runner.py +180 -0
  21. pybundle/steps/__init__.py +26 -0
  22. pybundle/steps/ai_context.py +791 -0
  23. pybundle/steps/api_docs.py +219 -0
  24. pybundle/steps/asyncio_analysis.py +358 -0
  25. pybundle/steps/bandit.py +72 -0
  26. pybundle/steps/base.py +20 -0
  27. pybundle/steps/blocking_call_detection.py +291 -0
  28. pybundle/steps/call_graph.py +219 -0
  29. pybundle/steps/compileall.py +76 -0
  30. pybundle/steps/config_docs.py +319 -0
  31. pybundle/steps/config_validation.py +302 -0
  32. pybundle/steps/container_image.py +294 -0
  33. pybundle/steps/context_expand.py +272 -0
  34. pybundle/steps/copy_pack.py +293 -0
  35. pybundle/steps/coverage.py +101 -0
  36. pybundle/steps/cprofile_step.py +166 -0
  37. pybundle/steps/dependency_sizes.py +136 -0
  38. pybundle/steps/django_checks.py +214 -0
  39. pybundle/steps/dockerfile_lint.py +282 -0
  40. pybundle/steps/dockerignore.py +311 -0
  41. pybundle/steps/duplication.py +103 -0
  42. pybundle/steps/env_completeness.py +269 -0
  43. pybundle/steps/env_var_usage.py +253 -0
  44. pybundle/steps/error_refs.py +204 -0
  45. pybundle/steps/event_loop_patterns.py +280 -0
  46. pybundle/steps/exception_patterns.py +190 -0
  47. pybundle/steps/fastapi_integration.py +250 -0
  48. pybundle/steps/flask_debugging.py +312 -0
  49. pybundle/steps/git_analytics.py +315 -0
  50. pybundle/steps/handoff_md.py +176 -0
  51. pybundle/steps/import_time.py +175 -0
  52. pybundle/steps/interrogate.py +106 -0
  53. pybundle/steps/license_scan.py +96 -0
  54. pybundle/steps/line_profiler.py +117 -0
  55. pybundle/steps/link_validation.py +287 -0
  56. pybundle/steps/logging_analysis.py +233 -0
  57. pybundle/steps/memory_profile.py +176 -0
  58. pybundle/steps/migration_history.py +336 -0
  59. pybundle/steps/mutation_testing.py +141 -0
  60. pybundle/steps/mypy.py +103 -0
  61. pybundle/steps/orm_optimization.py +316 -0
  62. pybundle/steps/pip_audit.py +45 -0
  63. pybundle/steps/pipdeptree.py +62 -0
  64. pybundle/steps/pylance.py +562 -0
  65. pybundle/steps/pytest.py +66 -0
  66. pybundle/steps/query_pattern_analysis.py +334 -0
  67. pybundle/steps/radon.py +161 -0
  68. pybundle/steps/repro_md.py +161 -0
  69. pybundle/steps/rg_scans.py +78 -0
  70. pybundle/steps/roadmap.py +153 -0
  71. pybundle/steps/ruff.py +117 -0
  72. pybundle/steps/secrets_detection.py +235 -0
  73. pybundle/steps/security_headers.py +309 -0
  74. pybundle/steps/shell.py +74 -0
  75. pybundle/steps/slow_tests.py +178 -0
  76. pybundle/steps/sqlalchemy_validation.py +269 -0
  77. pybundle/steps/test_flakiness.py +184 -0
  78. pybundle/steps/tree.py +116 -0
  79. pybundle/steps/type_coverage.py +277 -0
  80. pybundle/steps/unused_deps.py +211 -0
  81. pybundle/steps/vulture.py +167 -0
  82. 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
+ }