slopgate-cli 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.
- slopgate/__init__.py +17 -0
- slopgate/__main__.py +6 -0
- slopgate/ast_tamper.py +267 -0
- slopgate/cli.py +330 -0
- slopgate/diff_parser.py +186 -0
- slopgate/engine.py +266 -0
- slopgate/fragment.py +102 -0
- slopgate/phantom.py +388 -0
- slopgate/reporter.py +205 -0
- slopgate_cli-0.1.0.dist-info/METADATA +289 -0
- slopgate_cli-0.1.0.dist-info/RECORD +15 -0
- slopgate_cli-0.1.0.dist-info/WHEEL +5 -0
- slopgate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- slopgate_cli-0.1.0.dist-info/licenses/LICENSE +178 -0
- slopgate_cli-0.1.0.dist-info/top_level.txt +1 -0
slopgate/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SlopGate: AST-based Code Integrity & PR Integrity Shield for Open Source Maintainers.
|
|
3
|
+
Zero-dependency, deterministic detection of test tampering, phantom symbols, and slop bloat.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__author__ = "Shinraya Tech Labs"
|
|
8
|
+
|
|
9
|
+
from slopgate.engine import AuditEngine, AuditReport, Severity, Violation
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"__version__",
|
|
13
|
+
"AuditEngine",
|
|
14
|
+
"AuditReport",
|
|
15
|
+
"Violation",
|
|
16
|
+
"Severity",
|
|
17
|
+
]
|
slopgate/__main__.py
ADDED
slopgate/ast_tamper.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AST-Level Test Assertion & Mock Tamper Detection.
|
|
3
|
+
Detects when PRs dishonestly neuter tests to pass CI (removing asserts, tautologies,
|
|
4
|
+
try-except suppressions, and skipped tests).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from slopgate.diff_parser import FileDiff, Hunk
|
|
14
|
+
from slopgate.fragment import Fragment
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TamperKind(str, Enum):
|
|
18
|
+
ASSERT_DELETED = "ASSERT_DELETED"
|
|
19
|
+
ASSERT_WEAKENED = "ASSERT_WEAKENED"
|
|
20
|
+
TRY_EXCEPT_BYPASS = "TRY_EXCEPT_BYPASS"
|
|
21
|
+
TEST_SKIPPED = "TEST_SKIPPED"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TamperIncident:
|
|
26
|
+
kind: TamperKind
|
|
27
|
+
file_path: str
|
|
28
|
+
line_number: int
|
|
29
|
+
message: str
|
|
30
|
+
snippet: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Exact attribute chains that neuter a test. Substring matching is not enough:
|
|
34
|
+
# '@pytest.mark.skipif' starts with '@pytest.mark.skip' but is legitimate.
|
|
35
|
+
SKIP_SYMBOLS = {"pytest.skip", "pytest.mark.skip", "unittest.skip"}
|
|
36
|
+
SKIP_FALLBACK_RE = re.compile(
|
|
37
|
+
r"^\s*@?(?:pytest\.mark\.skip|unittest\.skip|pytest\.skip)\b"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dotted(node: ast.AST) -> str:
|
|
42
|
+
if isinstance(node, ast.Name):
|
|
43
|
+
return node.id
|
|
44
|
+
if isinstance(node, ast.Attribute):
|
|
45
|
+
base = _dotted(node.value)
|
|
46
|
+
return f"{base}.{node.attr}" if base else node.attr
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AstTamperAnalyzer:
|
|
51
|
+
"""Analyzes test file diffs to catch assertion weakening and test neutering."""
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def analyze_file(cls, file_diff: FileDiff) -> List[TamperIncident]:
|
|
55
|
+
incidents: List[TamperIncident] = []
|
|
56
|
+
if not file_diff.is_test_file or not file_diff.is_python:
|
|
57
|
+
return incidents
|
|
58
|
+
|
|
59
|
+
cls._check_assert_deletions(file_diff, incidents)
|
|
60
|
+
|
|
61
|
+
# 2. Check for Weakened Assertions / Tautologies in newly added lines using AST
|
|
62
|
+
covered = Fragment(file_diff).string_lines()
|
|
63
|
+
for idx, (ln, line) in enumerate(file_diff.added_lines, start=1):
|
|
64
|
+
stripped = line.strip()
|
|
65
|
+
if idx in covered or not cls._is_assert_line(stripped):
|
|
66
|
+
continue
|
|
67
|
+
weakened_msg = cls._check_weakened_assert(stripped)
|
|
68
|
+
if weakened_msg:
|
|
69
|
+
incidents.append(
|
|
70
|
+
TamperIncident(
|
|
71
|
+
kind=TamperKind.ASSERT_WEAKENED,
|
|
72
|
+
file_path=file_diff.target_path,
|
|
73
|
+
line_number=ln,
|
|
74
|
+
message=weakened_msg,
|
|
75
|
+
snippet=stripped,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 3. Check for Try-Except Suppressions (try: ... except: pass) in added lines
|
|
80
|
+
cls._check_try_except_suppression(file_diff, incidents)
|
|
81
|
+
|
|
82
|
+
# 4. Check for newly introduced skips
|
|
83
|
+
cls._check_skips(file_diff, incidents)
|
|
84
|
+
|
|
85
|
+
return incidents
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def _check_assert_deletions(
|
|
89
|
+
cls, file_diff: FileDiff, incidents: List[TamperIncident]
|
|
90
|
+
):
|
|
91
|
+
"""A hunk that drops assertions without replacing them is a tamper signal.
|
|
92
|
+
|
|
93
|
+
Comparing per hunk (not per file) stops an unrelated new assertion in a
|
|
94
|
+
different test from masking a deletion.
|
|
95
|
+
"""
|
|
96
|
+
if file_diff.is_deleted:
|
|
97
|
+
return
|
|
98
|
+
for hunk in file_diff.hunks:
|
|
99
|
+
cls._check_hunk_assertions(file_diff, hunk, incidents)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def _check_hunk_assertions(
|
|
103
|
+
cls, file_diff: FileDiff, hunk: Hunk, incidents: List[TamperIncident]
|
|
104
|
+
):
|
|
105
|
+
removed_asserts = cls._assert_lines(hunk.removed)
|
|
106
|
+
added_asserts = cls._assert_lines(hunk.added)
|
|
107
|
+
|
|
108
|
+
net_loss = len(removed_asserts) - len(added_asserts)
|
|
109
|
+
if net_loss <= 0:
|
|
110
|
+
return
|
|
111
|
+
for ln, line in removed_asserts[:net_loss]:
|
|
112
|
+
incidents.append(
|
|
113
|
+
TamperIncident(
|
|
114
|
+
kind=TamperKind.ASSERT_DELETED,
|
|
115
|
+
file_path=file_diff.target_path,
|
|
116
|
+
line_number=ln,
|
|
117
|
+
message=f"Removed {net_loss} assertion(s) in this hunk without equivalent replacement.",
|
|
118
|
+
snippet=line.strip(),
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def _assert_lines(cls, lines: List[Tuple[int, str]]) -> List[Tuple[int, str]]:
|
|
124
|
+
"""Assertions in these lines, ignoring text inside block strings."""
|
|
125
|
+
covered = Fragment.from_lines(lines).string_lines()
|
|
126
|
+
return [
|
|
127
|
+
(ln, line)
|
|
128
|
+
for idx, (ln, line) in enumerate(lines, start=1)
|
|
129
|
+
if idx not in covered and cls._is_assert_line(line)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def _check_weakened_assert(cls, assert_line: str) -> Optional[str]:
|
|
134
|
+
"""Uses AST to determine if an assertion is a tautology or weakened existence check."""
|
|
135
|
+
try:
|
|
136
|
+
tree = ast.parse(assert_line)
|
|
137
|
+
for node in ast.walk(tree):
|
|
138
|
+
if isinstance(node, ast.Assert):
|
|
139
|
+
test = node.test
|
|
140
|
+
# assert True or assert 1
|
|
141
|
+
if isinstance(test, ast.Constant) and test.value in (True, 1):
|
|
142
|
+
return "Tautological assertion: 'assert True' / 'assert 1' validates nothing."
|
|
143
|
+
|
|
144
|
+
# assert ... is not None
|
|
145
|
+
if isinstance(test, ast.Compare):
|
|
146
|
+
for op, comparator in zip(test.ops, test.comparators):
|
|
147
|
+
if (
|
|
148
|
+
isinstance(op, ast.IsNot)
|
|
149
|
+
and isinstance(comparator, ast.Constant)
|
|
150
|
+
and comparator.value is None
|
|
151
|
+
):
|
|
152
|
+
return "Weakened assertion: 'is not None' checks existence instead of value equivalence."
|
|
153
|
+
if (
|
|
154
|
+
isinstance(op, ast.GtE)
|
|
155
|
+
and isinstance(comparator, ast.Constant)
|
|
156
|
+
and comparator.value == 0
|
|
157
|
+
):
|
|
158
|
+
if (
|
|
159
|
+
isinstance(test.left, ast.Call)
|
|
160
|
+
and isinstance(test.left.func, ast.Name)
|
|
161
|
+
and test.left.func.id == "len"
|
|
162
|
+
):
|
|
163
|
+
return "Tautological assertion: 'len(...) >= 0' is unconditionally True."
|
|
164
|
+
except SyntaxError:
|
|
165
|
+
# Fallback regex
|
|
166
|
+
if re.search(r"assert\s+True\b|assert\s+1\b", assert_line):
|
|
167
|
+
return "Tautological assertion: 'assert True' / 'assert 1' validates nothing."
|
|
168
|
+
if re.search(r"\bis\s+not\s+None\b", assert_line):
|
|
169
|
+
return "Weakened assertion: 'is not None' checks existence instead of value equivalence."
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _is_assert_line(line: str) -> bool:
|
|
174
|
+
stripped = line.strip()
|
|
175
|
+
return (
|
|
176
|
+
stripped.startswith("assert ")
|
|
177
|
+
or stripped.startswith("assert(")
|
|
178
|
+
or stripped.startswith("self.assert")
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def _check_try_except_suppression(
|
|
183
|
+
cls, file_diff: FileDiff, incidents: List[TamperIncident]
|
|
184
|
+
):
|
|
185
|
+
"""Detects if added lines contain an except block that silently passes."""
|
|
186
|
+
fragment = Fragment(file_diff)
|
|
187
|
+
if "except" not in fragment.text or "pass" not in fragment.text:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
tree, offset = fragment.parse()
|
|
191
|
+
if tree is not None:
|
|
192
|
+
for node in ast.walk(tree):
|
|
193
|
+
if isinstance(node, ast.ExceptHandler):
|
|
194
|
+
if len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
|
|
195
|
+
incidents.append(
|
|
196
|
+
TamperIncident(
|
|
197
|
+
kind=TamperKind.TRY_EXCEPT_BYPASS,
|
|
198
|
+
file_path=file_diff.target_path,
|
|
199
|
+
line_number=fragment.real_line(
|
|
200
|
+
getattr(node, "lineno", 1), offset
|
|
201
|
+
),
|
|
202
|
+
message="Test failure silently suppressed with 'except ...: pass'.",
|
|
203
|
+
snippet="except: pass",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# Fallback regex if the snippet isn't parseable even with the stub wrapper
|
|
209
|
+
covered = fragment.string_lines()
|
|
210
|
+
code_text = "\n".join(
|
|
211
|
+
line
|
|
212
|
+
for idx, (_, line) in enumerate(file_diff.added_lines, start=1)
|
|
213
|
+
if idx not in covered
|
|
214
|
+
)
|
|
215
|
+
if re.search(r"except\s*.*:\s*(?:\n\s*)?pass\b", code_text):
|
|
216
|
+
incidents.append(
|
|
217
|
+
TamperIncident(
|
|
218
|
+
kind=TamperKind.TRY_EXCEPT_BYPASS,
|
|
219
|
+
file_path=file_diff.target_path,
|
|
220
|
+
line_number=fragment.line_map[0] if fragment.line_map else 1,
|
|
221
|
+
message="Test failure silently suppressed with 'except ...: pass'.",
|
|
222
|
+
snippet="except: pass",
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def _check_skips(cls, file_diff: FileDiff, incidents: List[TamperIncident]):
|
|
228
|
+
fragment = Fragment(file_diff)
|
|
229
|
+
covered = fragment.string_lines()
|
|
230
|
+
found_lines = set()
|
|
231
|
+
|
|
232
|
+
tree, offset = fragment.parse()
|
|
233
|
+
if tree is not None:
|
|
234
|
+
for node in ast.walk(tree):
|
|
235
|
+
if _dotted(node) in SKIP_SYMBOLS:
|
|
236
|
+
found_lines.add(
|
|
237
|
+
fragment.real_line(getattr(node, "lineno", 1), offset)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if found_lines:
|
|
241
|
+
for ln in sorted(found_lines):
|
|
242
|
+
incidents.append(
|
|
243
|
+
TamperIncident(
|
|
244
|
+
kind=TamperKind.TEST_SKIPPED,
|
|
245
|
+
file_path=file_diff.target_path,
|
|
246
|
+
line_number=ln,
|
|
247
|
+
message="Test explicitly skipped with pytest.skip / @mark.skip in PR.",
|
|
248
|
+
snippet="skip",
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
# Fallback for fragments the parser cannot handle (e.g. mixed indentation)
|
|
254
|
+
for idx, (ln, line) in enumerate(file_diff.added_lines, start=1):
|
|
255
|
+
if idx in covered:
|
|
256
|
+
continue
|
|
257
|
+
stripped = line.strip()
|
|
258
|
+
if SKIP_FALLBACK_RE.match(stripped):
|
|
259
|
+
incidents.append(
|
|
260
|
+
TamperIncident(
|
|
261
|
+
kind=TamperKind.TEST_SKIPPED,
|
|
262
|
+
file_path=file_diff.target_path,
|
|
263
|
+
line_number=ln,
|
|
264
|
+
message="Test explicitly skipped with pytest.skip / @mark.skip in PR.",
|
|
265
|
+
snippet=stripped,
|
|
266
|
+
)
|
|
267
|
+
)
|
slopgate/cli.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SlopGate Command Line Interface.
|
|
3
|
+
Runs PR audits, simulated interactive demos, and GitHub Actions workflow installations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Ensure UTF-8 output on Windows consoles
|
|
13
|
+
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
14
|
+
try:
|
|
15
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
16
|
+
except Exception:
|
|
17
|
+
pass
|
|
18
|
+
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
|
|
19
|
+
try:
|
|
20
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
from slopgate import __version__
|
|
25
|
+
from slopgate.engine import AuditEngine, Severity
|
|
26
|
+
from slopgate.reporter import AnsiColor, Reporter
|
|
27
|
+
|
|
28
|
+
GITHUB_WORKFLOW_TEMPLATE = f"""name: SlopGate PR Integrity Check
|
|
29
|
+
|
|
30
|
+
on:
|
|
31
|
+
pull_request:
|
|
32
|
+
types: [opened, synchronize, reopened]
|
|
33
|
+
|
|
34
|
+
permissions:
|
|
35
|
+
contents: read
|
|
36
|
+
|
|
37
|
+
jobs:
|
|
38
|
+
slopgate-audit:
|
|
39
|
+
name: SlopGate PR Audit
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- name: Checkout Code
|
|
43
|
+
uses: actions/checkout@v4
|
|
44
|
+
with:
|
|
45
|
+
fetch-depth: 0
|
|
46
|
+
|
|
47
|
+
- name: Run SlopGate Integrity Check
|
|
48
|
+
uses: Shinraya-Tech-Labs/slopgate@v{__version__}
|
|
49
|
+
with:
|
|
50
|
+
fail-on: blocked
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SAMPLE_TAMPERED_DIFF = """diff --git a/src/auth.py b/src/auth.py
|
|
55
|
+
index 1234567..89abcdef 100644
|
|
56
|
+
--- a/src/auth.py
|
|
57
|
+
+++ b/src/auth.py
|
|
58
|
+
@@ -10,3 +10,4 @@ def verify_token(token: str) -> bool:
|
|
59
|
+
if not token:
|
|
60
|
+
return False
|
|
61
|
+
+ # AI: simplified token validation
|
|
62
|
+
return True
|
|
63
|
+
diff --git a/tests/test_auth.py b/tests/test_auth.py
|
|
64
|
+
index 2345678..9abcdef0 100644
|
|
65
|
+
--- a/tests/test_auth.py
|
|
66
|
+
+++ b/tests/test_auth.py
|
|
67
|
+
@@ -14,3 +14,3 @@ def test_expired_token():
|
|
68
|
+
- assert verify_token("expired_xyz") == False
|
|
69
|
+
+ assert verify_token("expired_xyz") is not None
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
SAMPLE_PHANTOM_DIFF = """diff --git a/src/database.py b/src/database.py
|
|
73
|
+
index 3456789..abcdef01 100644
|
|
74
|
+
--- a/src/database.py
|
|
75
|
+
+++ b/src/database.py
|
|
76
|
+
@@ -5,2 +5,4 @@ import json
|
|
77
|
+
+import phantom_ai_driver
|
|
78
|
+
+from hallucinated_orm import AsyncSession
|
|
79
|
+
|
|
80
|
+
def load_config():
|
|
81
|
+
- return json.loads(data)
|
|
82
|
+
+ return json.loads_async(data)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
SAMPLE_CLEAN_DIFF = """diff --git a/src/calculator.py b/src/calculator.py
|
|
86
|
+
index 4567890..bcdef012 100644
|
|
87
|
+
--- a/src/calculator.py
|
|
88
|
+
+++ b/src/calculator.py
|
|
89
|
+
@@ -8,2 +8,4 @@ def divide(a: float, b: float) -> float:
|
|
90
|
+
+ if b == 0:
|
|
91
|
+
+ raise ValueError("Cannot divide by zero")
|
|
92
|
+
return a / b
|
|
93
|
+
diff --git a/tests/test_calculator.py b/tests/test_calculator.py
|
|
94
|
+
index 5678901..cdef0123 100644
|
|
95
|
+
--- a/tests/test_calculator.py
|
|
96
|
+
+++ b/tests/test_calculator.py
|
|
97
|
+
@@ -20,2 +20,6 @@ def test_divide_valid():
|
|
98
|
+
assert divide(10, 2) == 5.0
|
|
99
|
+
+
|
|
100
|
+
+def test_divide_zero_repro():
|
|
101
|
+
+ import pytest
|
|
102
|
+
+ with pytest.raises(ValueError, match="Cannot divide by zero"):
|
|
103
|
+
+ divide(10, 0)
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_git_diff() -> str:
|
|
108
|
+
"""Gets git diff against SLOPGATE_BASE_SHA, GITHUB_BASE_REF, origin/main, or working tree."""
|
|
109
|
+
base_sha = os.environ.get("SLOPGATE_BASE_SHA")
|
|
110
|
+
base_ref = os.environ.get("GITHUB_BASE_REF")
|
|
111
|
+
cmds = []
|
|
112
|
+
if base_sha:
|
|
113
|
+
# The action passes the PR base commit: it always exists after checkout,
|
|
114
|
+
# unlike origin/<branch>, which shallow or unusual checkouts may omit.
|
|
115
|
+
cmds.extend(
|
|
116
|
+
[
|
|
117
|
+
["git", "diff", f"{base_sha}...HEAD"],
|
|
118
|
+
["git", "diff", base_sha, "HEAD"],
|
|
119
|
+
]
|
|
120
|
+
)
|
|
121
|
+
if base_ref:
|
|
122
|
+
cmds.extend(
|
|
123
|
+
[
|
|
124
|
+
["git", "diff", f"origin/{base_ref}...HEAD"],
|
|
125
|
+
["git", "diff", f"{base_ref}...HEAD"],
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
cmds.extend(
|
|
129
|
+
[
|
|
130
|
+
["git", "diff", "origin/main...HEAD"],
|
|
131
|
+
["git", "diff", "main...HEAD"],
|
|
132
|
+
["git", "diff", "origin/master...HEAD"],
|
|
133
|
+
["git", "diff", "master...HEAD"],
|
|
134
|
+
["git", "diff", "HEAD~1"],
|
|
135
|
+
["git", "diff", "--cached"],
|
|
136
|
+
["git", "diff"],
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
for cmd in cmds:
|
|
140
|
+
try:
|
|
141
|
+
res = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
142
|
+
if res.stdout.strip():
|
|
143
|
+
return res.stdout
|
|
144
|
+
except Exception:
|
|
145
|
+
continue
|
|
146
|
+
return ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
150
|
+
"""Audits diff text."""
|
|
151
|
+
if args.diff_file == "-":
|
|
152
|
+
diff_text = sys.stdin.read()
|
|
153
|
+
elif args.diff_file:
|
|
154
|
+
diff_text = Path(args.diff_file).read_text(encoding="utf-8", errors="ignore")
|
|
155
|
+
else:
|
|
156
|
+
diff_text = get_git_diff()
|
|
157
|
+
if not diff_text:
|
|
158
|
+
print(
|
|
159
|
+
"⚠️ No git diff detected. Provide a diff via --diff <file> or piped via stdin.",
|
|
160
|
+
file=sys.stderr,
|
|
161
|
+
)
|
|
162
|
+
# Failing open in CI would report success on a broken checkout
|
|
163
|
+
return 1 if os.environ.get("GITHUB_ACTIONS") == "true" else 0
|
|
164
|
+
|
|
165
|
+
engine = AuditEngine()
|
|
166
|
+
report = engine.audit_diff(diff_text)
|
|
167
|
+
|
|
168
|
+
# Output formatting
|
|
169
|
+
if args.format == "json":
|
|
170
|
+
print(Reporter.render_json(report))
|
|
171
|
+
elif args.format == "github":
|
|
172
|
+
print(Reporter.render_github_markdown(report))
|
|
173
|
+
annotations = Reporter.render_github_annotations(report)
|
|
174
|
+
if annotations:
|
|
175
|
+
print(annotations)
|
|
176
|
+
else:
|
|
177
|
+
print(Reporter.render_terminal(report))
|
|
178
|
+
|
|
179
|
+
# Write summary file if requested (e.g. $GITHUB_STEP_SUMMARY)
|
|
180
|
+
if args.summary_file:
|
|
181
|
+
Path(args.summary_file).write_text(
|
|
182
|
+
Reporter.render_github_markdown(report), encoding="utf-8"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Exit code evaluation
|
|
186
|
+
if args.fail_on == "blocked" and report.status == Severity.BLOCKED:
|
|
187
|
+
return 1
|
|
188
|
+
elif args.fail_on == "warning" and report.status in (
|
|
189
|
+
Severity.BLOCKED,
|
|
190
|
+
Severity.WARNING,
|
|
191
|
+
):
|
|
192
|
+
return 1
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_demo(args: argparse.Namespace) -> int:
|
|
197
|
+
"""Runs interactive demonstration of SlopGate detecting AI slop scenarios in real time."""
|
|
198
|
+
print(
|
|
199
|
+
f"\n{AnsiColor.CYAN}{AnsiColor.BOLD}======================================================={AnsiColor.RESET}"
|
|
200
|
+
)
|
|
201
|
+
print(
|
|
202
|
+
f"{AnsiColor.BOLD} 🎮 SLOPGATE INTERACTIVE AUDIT SIMULATOR v{__version__}{AnsiColor.RESET}"
|
|
203
|
+
)
|
|
204
|
+
print(
|
|
205
|
+
f"{AnsiColor.CYAN}======================================================={AnsiColor.RESET}\n"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
engine = AuditEngine()
|
|
209
|
+
|
|
210
|
+
print(
|
|
211
|
+
f"{AnsiColor.BOLD}Scenario 1: Rogue AI PR weakens test assertion to pass CI{AnsiColor.RESET}"
|
|
212
|
+
)
|
|
213
|
+
print(
|
|
214
|
+
f"{AnsiColor.GRAY}Simulating: Contributor PR replaces 'assert val == False' with 'assert val is not None'...{AnsiColor.RESET}"
|
|
215
|
+
)
|
|
216
|
+
rep1 = engine.audit_diff(SAMPLE_TAMPERED_DIFF)
|
|
217
|
+
print(Reporter.render_terminal(rep1))
|
|
218
|
+
|
|
219
|
+
print(
|
|
220
|
+
f"{AnsiColor.BOLD}Scenario 2: AI PR with phantom packages and hallucinated stdlib APIs{AnsiColor.RESET}"
|
|
221
|
+
)
|
|
222
|
+
print(
|
|
223
|
+
f"{AnsiColor.GRAY}Simulating: PR imports 'phantom_ai_driver' and calls 'json.loads_async()'...{AnsiColor.RESET}"
|
|
224
|
+
)
|
|
225
|
+
rep2 = engine.audit_diff(SAMPLE_PHANTOM_DIFF)
|
|
226
|
+
print(Reporter.render_terminal(rep2))
|
|
227
|
+
|
|
228
|
+
print(
|
|
229
|
+
f"{AnsiColor.BOLD}Scenario 3: Genuine human bugfix with reproduction test case{AnsiColor.RESET}"
|
|
230
|
+
)
|
|
231
|
+
print(
|
|
232
|
+
f"{AnsiColor.GRAY}Simulating: Legitimate PR fixing ZeroDivisionError with reproduction test...{AnsiColor.RESET}"
|
|
233
|
+
)
|
|
234
|
+
rep3 = engine.audit_diff(SAMPLE_CLEAN_DIFF)
|
|
235
|
+
print(Reporter.render_terminal(rep3))
|
|
236
|
+
|
|
237
|
+
print(
|
|
238
|
+
f"{AnsiColor.GREEN}{AnsiColor.BOLD}✓ Simulator completed: 2 rogue AI PRs intercepted, 1 clean bugfix approved.{AnsiColor.RESET}\n"
|
|
239
|
+
)
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_install(args: argparse.Namespace) -> int:
|
|
244
|
+
"""Installs .github/workflows/slopgate.yml into the current repository."""
|
|
245
|
+
wf_dir = Path(".github/workflows")
|
|
246
|
+
wf_dir.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
wf_file = wf_dir / "slopgate.yml"
|
|
248
|
+
wf_file.write_text(GITHUB_WORKFLOW_TEMPLATE, encoding="utf-8")
|
|
249
|
+
print(f"✨ Successfully installed SlopGate workflow to {wf_file}")
|
|
250
|
+
print("Now commit and push to enable automated AI slop PR protection in your repo!")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_badge(args: argparse.Namespace) -> int:
|
|
255
|
+
"""Outputs Markdown and HTML README badges."""
|
|
256
|
+
print("\n### 🛡️ SlopGate README Badges\n")
|
|
257
|
+
print("**Markdown:**")
|
|
258
|
+
print(
|
|
259
|
+
"[](https://github.com/Shinraya-Tech-Labs/slopgate)"
|
|
260
|
+
)
|
|
261
|
+
print("\n**HTML:**")
|
|
262
|
+
print(
|
|
263
|
+
'<a href="https://github.com/Shinraya-Tech-Labs/slopgate"><img src="https://img.shields.io/badge/PRs-SlopGate_Protected-brightgreen?style=flat-square&logo=github" alt="Protected by SlopGate" /></a>\n'
|
|
264
|
+
)
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main():
|
|
269
|
+
parser = argparse.ArgumentParser(
|
|
270
|
+
prog="slopgate",
|
|
271
|
+
description="🛡️ SlopGate: AST-Based Code Integrity & PR Integrity Shield",
|
|
272
|
+
)
|
|
273
|
+
parser.add_argument(
|
|
274
|
+
"-v", "--version", action="version", version=f"slopgate {__version__}"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
278
|
+
|
|
279
|
+
# check
|
|
280
|
+
check_parser = subparsers.add_parser(
|
|
281
|
+
"check", help="Audit git diff or PR for code integrity violations"
|
|
282
|
+
)
|
|
283
|
+
check_parser.add_argument(
|
|
284
|
+
"--diff", dest="diff_file", help="Path to unified diff file (or '-' for stdin)"
|
|
285
|
+
)
|
|
286
|
+
check_parser.add_argument(
|
|
287
|
+
"--format", choices=["terminal", "github", "json"], default="terminal"
|
|
288
|
+
)
|
|
289
|
+
check_parser.add_argument(
|
|
290
|
+
"--fail-on", choices=["blocked", "warning", "never"], default="blocked"
|
|
291
|
+
)
|
|
292
|
+
check_parser.add_argument(
|
|
293
|
+
"--summary-file", help="Write GitHub Step Summary markdown to file path"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# demo
|
|
297
|
+
subparsers.add_parser(
|
|
298
|
+
"demo", help="Run interactive terminal simulation of SlopGate in action"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# install
|
|
302
|
+
subparsers.add_parser(
|
|
303
|
+
"install", help="Install SlopGate GitHub Action into current repository"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# badge
|
|
307
|
+
subparsers.add_parser("badge", help="Generate README badges for your repository")
|
|
308
|
+
|
|
309
|
+
args = parser.parse_args()
|
|
310
|
+
|
|
311
|
+
if not args.command:
|
|
312
|
+
# Default behavior: run check
|
|
313
|
+
args.command = "check"
|
|
314
|
+
args.diff_file = None
|
|
315
|
+
args.format = "terminal"
|
|
316
|
+
args.fail_on = "blocked"
|
|
317
|
+
args.summary_file = None
|
|
318
|
+
|
|
319
|
+
if args.command == "check":
|
|
320
|
+
sys.exit(cmd_check(args))
|
|
321
|
+
elif args.command == "demo":
|
|
322
|
+
sys.exit(cmd_demo(args))
|
|
323
|
+
elif args.command == "install":
|
|
324
|
+
sys.exit(cmd_install(args))
|
|
325
|
+
elif args.command == "badge":
|
|
326
|
+
sys.exit(cmd_badge(args))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
main()
|