deployproof 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.
- deployproof/__init__.py +3 -0
- deployproof/cli.py +288 -0
- deployproof/dependencies.py +874 -0
- deployproof/diff.py +234 -0
- deployproof/mutator.py +475 -0
- deployproof/reporter.py +281 -0
- deployproof/secrets.py +221 -0
- deployproof/symlinks.py +156 -0
- deployproof/wsl.py +147 -0
- deployproof-0.1.0.dist-info/METADATA +111 -0
- deployproof-0.1.0.dist-info/RECORD +15 -0
- deployproof-0.1.0.dist-info/WHEEL +5 -0
- deployproof-0.1.0.dist-info/entry_points.txt +2 -0
- deployproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- deployproof-0.1.0.dist-info/top_level.txt +1 -0
deployproof/diff.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Git diff-scoping resolver for DeployProof."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Set
|
|
7
|
+
|
|
8
|
+
IGNORED_DIRS = {
|
|
9
|
+
".venv",
|
|
10
|
+
"venv",
|
|
11
|
+
".env",
|
|
12
|
+
"env",
|
|
13
|
+
"node_modules",
|
|
14
|
+
"build",
|
|
15
|
+
"dist",
|
|
16
|
+
".tox",
|
|
17
|
+
".mutmut-cache",
|
|
18
|
+
"scratch_repos",
|
|
19
|
+
".git",
|
|
20
|
+
"__pycache__",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DiffScopeError(Exception):
|
|
26
|
+
"""Base exception for diff scoping errors."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotAGitRepositoryError(DiffScopeError):
|
|
31
|
+
"""Raised when current working directory is not a git repository."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InvalidBaseRefError(DiffScopeError):
|
|
36
|
+
"""Raised when a specified base ref does not exist."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_git(args: List[str], cwd: Path) -> subprocess.CompletedProcess:
|
|
41
|
+
"""Run a git command in the specified directory."""
|
|
42
|
+
try:
|
|
43
|
+
return subprocess.run(
|
|
44
|
+
["git", *args],
|
|
45
|
+
cwd=cwd,
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
errors="replace",
|
|
50
|
+
check=False,
|
|
51
|
+
)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
raise DiffScopeError("git executable not found in PATH.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_git_repo(cwd: Path) -> bool:
|
|
57
|
+
"""Check if the directory is inside a git work tree."""
|
|
58
|
+
res = run_git(["rev-parse", "--is-inside-work-tree"], cwd)
|
|
59
|
+
return res.returncode == 0 and res.stdout.strip() == "true"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_git_root(cwd: Path) -> Path:
|
|
63
|
+
"""Get the root directory of the git repository."""
|
|
64
|
+
if not is_git_repo(cwd):
|
|
65
|
+
raise NotAGitRepositoryError("Not a git repository.")
|
|
66
|
+
res = run_git(["rev-parse", "--show-toplevel"], cwd)
|
|
67
|
+
if res.returncode != 0:
|
|
68
|
+
raise NotAGitRepositoryError("Failed to determine git repository root.")
|
|
69
|
+
return Path(res.stdout.strip()).resolve()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verify_ref_exists(ref: str, cwd: Path) -> bool:
|
|
73
|
+
"""Check if a git ref exists."""
|
|
74
|
+
res = run_git(["rev-parse", "--verify", "--quiet", ref], cwd)
|
|
75
|
+
return res.returncode == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def has_commits(cwd: Path) -> bool:
|
|
79
|
+
"""Check if the repository has at least one commit."""
|
|
80
|
+
res = run_git(["rev-parse", "--verify", "--quiet", "HEAD"], cwd)
|
|
81
|
+
return res.returncode == 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_test_file(path: Path) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Determine whether a path is a test file or located within a test folder.
|
|
87
|
+
|
|
88
|
+
Test files should be excluded from mutation targets (though they verify targets).
|
|
89
|
+
"""
|
|
90
|
+
name = path.name.lower()
|
|
91
|
+
if name.startswith("test_") or name.endswith("_test.py") or name == "conftest.py":
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
parts = [p.lower() for p in path.parts]
|
|
95
|
+
if "tests" in parts or "test" in parts or "testing" in parts:
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_uncommitted_session_files(root: Path) -> Set[Path]:
|
|
101
|
+
"""Get uncommitted (staged, unstaged, untracked) files across all extensions."""
|
|
102
|
+
files: Set[Path] = set()
|
|
103
|
+
|
|
104
|
+
# 1. Check status for modified, staged, untracked files
|
|
105
|
+
res = run_git(["status", "--porcelain"], root)
|
|
106
|
+
if res.returncode == 0:
|
|
107
|
+
for line in res.stdout.splitlines():
|
|
108
|
+
line = line.strip()
|
|
109
|
+
if not line:
|
|
110
|
+
continue
|
|
111
|
+
parts = line.split(maxsplit=1)
|
|
112
|
+
if len(parts) == 2:
|
|
113
|
+
status, rel_path = parts[0], parts[1]
|
|
114
|
+
if "->" in rel_path:
|
|
115
|
+
rel_path = rel_path.split("->")[-1].strip()
|
|
116
|
+
rel_path = rel_path.strip('"')
|
|
117
|
+
p = (root / rel_path).resolve()
|
|
118
|
+
if p.is_file():
|
|
119
|
+
files.add(p)
|
|
120
|
+
|
|
121
|
+
# 2. Check unstaged diff against HEAD (if commits exist)
|
|
122
|
+
if has_commits(root):
|
|
123
|
+
res_diff = run_git(["diff", "--name-only", "HEAD"], root)
|
|
124
|
+
if res_diff.returncode == 0:
|
|
125
|
+
for rel_path in res_diff.stdout.splitlines():
|
|
126
|
+
rel_path = rel_path.strip().strip('"')
|
|
127
|
+
if rel_path:
|
|
128
|
+
p = (root / rel_path).resolve()
|
|
129
|
+
if p.is_file():
|
|
130
|
+
files.add(p)
|
|
131
|
+
|
|
132
|
+
return files
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_latest_commit_session_files(root: Path) -> Set[Path]:
|
|
136
|
+
"""Get all files changed in the most recent commit (HEAD)."""
|
|
137
|
+
files: Set[Path] = set()
|
|
138
|
+
if not has_commits(root):
|
|
139
|
+
return files
|
|
140
|
+
|
|
141
|
+
res = run_git(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "HEAD"], root)
|
|
142
|
+
if res.returncode == 0:
|
|
143
|
+
for rel_path in res.stdout.splitlines():
|
|
144
|
+
rel_path = rel_path.strip().strip('"')
|
|
145
|
+
if rel_path:
|
|
146
|
+
p = (root / rel_path).resolve()
|
|
147
|
+
if p.is_file():
|
|
148
|
+
files.add(p)
|
|
149
|
+
return files
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_diff_against_base_session_files(root: Path, base: str) -> Set[Path]:
|
|
153
|
+
"""Get files changed between base ref and working tree / HEAD."""
|
|
154
|
+
if not verify_ref_exists(base, root):
|
|
155
|
+
raise InvalidBaseRefError(f"Git reference '{base}' does not exist.")
|
|
156
|
+
|
|
157
|
+
files: Set[Path] = set()
|
|
158
|
+
|
|
159
|
+
# Diff base against working tree
|
|
160
|
+
res = run_git(["diff", "--name-only", base], root)
|
|
161
|
+
if res.returncode == 0:
|
|
162
|
+
for rel_path in res.stdout.splitlines():
|
|
163
|
+
rel_path = rel_path.strip().strip('"')
|
|
164
|
+
if rel_path:
|
|
165
|
+
p = (root / rel_path).resolve()
|
|
166
|
+
if p.is_file():
|
|
167
|
+
files.add(p)
|
|
168
|
+
|
|
169
|
+
# Include untracked files
|
|
170
|
+
res_status = run_git(["status", "--porcelain"], root)
|
|
171
|
+
if res_status.returncode == 0:
|
|
172
|
+
for line in res_status.stdout.splitlines():
|
|
173
|
+
line = line.strip()
|
|
174
|
+
if line.startswith("??"):
|
|
175
|
+
parts = line.split(maxsplit=1)
|
|
176
|
+
if len(parts) == 2:
|
|
177
|
+
rel_path = parts[1].strip().strip('"')
|
|
178
|
+
p = (root / rel_path).resolve()
|
|
179
|
+
if p.is_file():
|
|
180
|
+
files.add(p)
|
|
181
|
+
|
|
182
|
+
return files
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def resolve_changed_session_files(
|
|
186
|
+
cwd: Optional[Path] = None,
|
|
187
|
+
base: Optional[str] = None,
|
|
188
|
+
) -> List[Path]:
|
|
189
|
+
"""
|
|
190
|
+
Resolve list of all changed session files (any extension) for secret scanning and general scoping.
|
|
191
|
+
"""
|
|
192
|
+
target_dir = (cwd or Path.cwd()).resolve()
|
|
193
|
+
root = get_git_root(target_dir)
|
|
194
|
+
|
|
195
|
+
if base:
|
|
196
|
+
changed = get_diff_against_base_session_files(root, base)
|
|
197
|
+
else:
|
|
198
|
+
# Step 1: Check working-tree uncommitted changes
|
|
199
|
+
changed = get_uncommitted_session_files(root)
|
|
200
|
+
# Step 2: If clean, check latest commit
|
|
201
|
+
if not changed:
|
|
202
|
+
changed = get_latest_commit_session_files(root)
|
|
203
|
+
|
|
204
|
+
filtered: List[Path] = []
|
|
205
|
+
for p in changed:
|
|
206
|
+
try:
|
|
207
|
+
rel_parts = set(p.relative_to(root).parts)
|
|
208
|
+
except ValueError:
|
|
209
|
+
rel_parts = set(p.parts)
|
|
210
|
+
if rel_parts.intersection(IGNORED_DIRS):
|
|
211
|
+
continue
|
|
212
|
+
filtered.append(p)
|
|
213
|
+
|
|
214
|
+
return sorted(filtered)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve_changed_python_files(
|
|
218
|
+
cwd: Optional[Path] = None,
|
|
219
|
+
base: Optional[str] = None,
|
|
220
|
+
include_tests: bool = False,
|
|
221
|
+
) -> List[Path]:
|
|
222
|
+
"""
|
|
223
|
+
Resolve list of changed Python files based on the Smart Session Cascade or --base flag.
|
|
224
|
+
|
|
225
|
+
Excludes virtualenvs, build directories, and test files (unless include_tests=True).
|
|
226
|
+
Returns sorted list of absolute Paths.
|
|
227
|
+
"""
|
|
228
|
+
all_session_files = resolve_changed_session_files(cwd=cwd, base=base)
|
|
229
|
+
py_files = [p for p in all_session_files if p.suffix == ".py"]
|
|
230
|
+
|
|
231
|
+
if not include_tests:
|
|
232
|
+
py_files = [p for p in py_files if not is_test_file(p)]
|
|
233
|
+
|
|
234
|
+
return sorted(py_files)
|