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 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
@@ -0,0 +1,6 @@
1
+ """Allows `python -m slopgate` as an alias for the `slopgate` command."""
2
+
3
+ from slopgate.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
+ "[![Protected by SlopGate](https://img.shields.io/badge/PRs-SlopGate_Protected-brightgreen?style=flat-square&logo=github)](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()