slopgate-cli 0.1.0__tar.gz → 0.1.1__tar.gz
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_cli-0.1.1/CHANGELOG.md +80 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/PKG-INFO +3 -2
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/README.md +1 -1
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/pyproject.toml +2 -1
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/__init__.py +1 -1
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/ast_tamper.py +35 -40
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/cli.py +72 -29
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/diff_parser.py +6 -3
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/engine.py +16 -2
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/fragment.py +3 -1
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/phantom.py +108 -17
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/PKG-INFO +3 -2
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/test_cli.py +25 -1
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/test_engine.py +12 -4
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/test_phantom.py +85 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/test_tamper.py +44 -3
- slopgate_cli-0.1.0/CHANGELOG.md +0 -38
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/CONTRIBUTING.md +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/LICENSE +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/MANIFEST.in +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/SECURITY.md +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/setup.cfg +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/__main__.py +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/reporter.py +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/SOURCES.txt +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/dependency_links.txt +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/entry_points.txt +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/requires.txt +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/top_level.txt +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/fixtures/genuine_bugfix.diff +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/fixtures/neutered_test.diff +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/fixtures/phantom_import.diff +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/fixtures/slop_doc_bloat.diff +0 -0
- {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/test_diff_parser.py +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.1] - 2026-10-28
|
|
10
|
+
|
|
11
|
+
Correctness release: two tamper classes that v0.1.0 reported as warnings now block.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Deleting a whole test file now blocks** (`SG-TEST_FILE_DELETED`) instead of
|
|
16
|
+
only emitting the missing-repro warning.
|
|
17
|
+
- **`assert True` / `x is not None` weakening now blocks.** `ASSERT_WEAKENED` is
|
|
18
|
+
part of the blocked set, so the default `fail-on: blocked` catches it.
|
|
19
|
+
- Local package detection walks the repo to depth 3 and no longer needs
|
|
20
|
+
`__init__.py`, so PEP 420 namespace packages, `src/` and `python/` layouts stop
|
|
21
|
+
reading as hallucinated imports. Virtualenv, `node_modules`, and build
|
|
22
|
+
directories are skipped.
|
|
23
|
+
- Dependency manifests are read from subproject directories too (monorepos).
|
|
24
|
+
- `setup.py` `extras_require` entries count as dependencies, not just
|
|
25
|
+
`install_requires`.
|
|
26
|
+
- Import/distribution aliases for `rest_framework`, `django_filters`, `github`,
|
|
27
|
+
`MySQLdb`, `python-jose`, `grpcio`, and friends.
|
|
28
|
+
- Django-style test files (`myapp/tests.py`, `conftest.py`, `*_spec.py`, `spec/`)
|
|
29
|
+
are treated as tests, so tampering there is detected.
|
|
30
|
+
- `from conftest import ...` and sibling helper modules resolve as local modules.
|
|
31
|
+
- A saved diff in UTF-16 (PowerShell `git diff > pr.diff`) is decoded instead of
|
|
32
|
+
parsing to zero files and reporting a pass.
|
|
33
|
+
- An unparseable or unreadable diff exits 2 instead of being reported as clean.
|
|
34
|
+
- Missing `--diff` file prints one line and exits 2 instead of a traceback.
|
|
35
|
+
- Imports under `if TYPE_CHECKING:` are not treated as runtime dependencies.
|
|
36
|
+
- A UTF-8 BOM no longer hides findings on line 1.
|
|
37
|
+
- `@pytest.mark.xfail`, `unittest.expectedFailure`, and `pytest.importorskip` are
|
|
38
|
+
detected as skips; the line fallback now always runs alongside the AST pass.
|
|
39
|
+
- Shallow checkouts suggest `fetch-depth: 0` instead of only "no diff detected".
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Assert deletions are counted per file rather than per hunk, so splitting a test
|
|
44
|
+
or moving assertions between hunks is not a false block; a net loss still is.
|
|
45
|
+
- `slopgate install` pins `@v0` (patch/minor updates) instead of the exact tag.
|
|
46
|
+
- The dogfood workflow no longer requests unused `pull-requests: write`.
|
|
47
|
+
- The Release job waits for the PyPI publish job, so a failed upload no longer
|
|
48
|
+
produces a GitHub Release.
|
|
49
|
+
- Added the Python 3.14 classifier.
|
|
50
|
+
|
|
51
|
+
## [0.1.0] - 2026-10-28
|
|
52
|
+
|
|
53
|
+
First public release. Python 3.10+, zero runtime dependencies.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- `slopgate check` — audits a unified git diff from a file, stdin, or the current
|
|
58
|
+
PR (`GITHUB_BASE_REF`), with `terminal`, `json`, and `github` output formats and
|
|
59
|
+
`--fail-on blocked|warning|never`.
|
|
60
|
+
- Test tamper detection: deleted assertions (per hunk), tautology/weakening swaps,
|
|
61
|
+
`try: ... except: pass` suppression, and newly added `pytest` skips — parsed
|
|
62
|
+
with `ast`, so `skipif` and comments do not trip it.
|
|
63
|
+
- Phantom symbol detection: imports missing from the stdlib, declared dependencies
|
|
64
|
+
(`pyproject.toml`, `requirements*.txt` incl. `-r` includes, `setup.py`,
|
|
65
|
+
`setup.cfg`, `Pipfile`, `environment.yml`, Poetry) and local modules; plus a
|
|
66
|
+
table of hallucinated stdlib methods.
|
|
67
|
+
- Reproduction gate: flags production changes that ship without a test change,
|
|
68
|
+
and only counts the reproduction as verified when the test change is untampered.
|
|
69
|
+
- Slop ratio: docstring/comment bloat versus executable statements added.
|
|
70
|
+
- `slopgate demo` — offline interactive simulation of three real PR scenarios.
|
|
71
|
+
- `slopgate install` — writes a pinned `.github/workflows/slopgate.yml` into a repo.
|
|
72
|
+
- `slopgate badge` — Markdown and HTML README badges.
|
|
73
|
+
- Composite GitHub Action with Step Summary output and `::error` annotations.
|
|
74
|
+
- GitHub Actions workflows: CI matrix (Python 3.10–3.13 on Linux/macOS/Windows),
|
|
75
|
+
package-integrity job (build + `twine check` + wheel smoke test), and a release
|
|
76
|
+
pipeline (build → PyPI via Trusted Publishing or `PYPI_API_TOKEN` → GitHub
|
|
77
|
+
Release) with SHA-pinned actions.
|
|
78
|
+
|
|
79
|
+
[Unreleased]: https://github.com/Shinraya-Tech-Labs/slopgate/compare/v0.1.0...HEAD
|
|
80
|
+
[0.1.0]: https://github.com/Shinraya-Tech-Labs/slopgate/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slopgate-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: AST-based code integrity, test tamper detection, and phantom symbol interception for GitHub Pull Requests.
|
|
5
5
|
Author-email: Shinraya Tech Labs <labs@shinraya.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
22
|
Requires-Python: >=3.10
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
23
24
|
License-File: LICENSE
|
|
@@ -196,7 +197,7 @@ jobs:
|
|
|
196
197
|
fetch-depth: 0
|
|
197
198
|
|
|
198
199
|
- name: Run SlopGate Shield
|
|
199
|
-
uses: Shinraya-Tech-Labs/slopgate@v0
|
|
200
|
+
uses: Shinraya-Tech-Labs/slopgate@v0
|
|
200
201
|
with:
|
|
201
202
|
fail-on: blocked # 'blocked' (fails on tamper/hallucinations) or 'warning'
|
|
202
203
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "slopgate-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "AST-based code integrity, test tamper detection, and phantom symbol interception for GitHub Pull Requests."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [
|
|
@@ -30,6 +30,7 @@ classifiers = [
|
|
|
30
30
|
"Programming Language :: Python :: 3.11",
|
|
31
31
|
"Programming Language :: Python :: 3.12",
|
|
32
32
|
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
33
34
|
]
|
|
34
35
|
requires-python = ">=3.10"
|
|
35
36
|
dependencies = []
|
|
@@ -3,7 +3,7 @@ SlopGate: AST-based Code Integrity & PR Integrity Shield for Open Source Maintai
|
|
|
3
3
|
Zero-dependency, deterministic detection of test tampering, phantom symbols, and slop bloat.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
__version__ = "0.1.
|
|
6
|
+
__version__ = "0.1.1"
|
|
7
7
|
__author__ = "Shinraya Tech Labs"
|
|
8
8
|
|
|
9
9
|
from slopgate.engine import AuditEngine, AuditReport, Severity, Violation
|
|
@@ -10,7 +10,7 @@ from dataclasses import dataclass
|
|
|
10
10
|
from enum import Enum
|
|
11
11
|
from typing import List, Optional, Tuple
|
|
12
12
|
|
|
13
|
-
from slopgate.diff_parser import FileDiff
|
|
13
|
+
from slopgate.diff_parser import FileDiff
|
|
14
14
|
from slopgate.fragment import Fragment
|
|
15
15
|
|
|
16
16
|
|
|
@@ -32,9 +32,17 @@ class TamperIncident:
|
|
|
32
32
|
|
|
33
33
|
# Exact attribute chains that neuter a test. Substring matching is not enough:
|
|
34
34
|
# '@pytest.mark.skipif' starts with '@pytest.mark.skip' but is legitimate.
|
|
35
|
-
SKIP_SYMBOLS = {
|
|
35
|
+
SKIP_SYMBOLS = {
|
|
36
|
+
"pytest.skip",
|
|
37
|
+
"pytest.mark.skip",
|
|
38
|
+
"pytest.mark.xfail",
|
|
39
|
+
"pytest.importorskip",
|
|
40
|
+
"unittest.skip",
|
|
41
|
+
"unittest.expectedFailure",
|
|
42
|
+
}
|
|
36
43
|
SKIP_FALLBACK_RE = re.compile(
|
|
37
|
-
r"^\s*@?(?:pytest\.mark\.skip|unittest\.skip|
|
|
44
|
+
r"^\s*@?(?:pytest\.mark\.(?:skip|xfail)|unittest\.(?:skip|expectedFailure)"
|
|
45
|
+
r"|pytest\.(?:skip|importorskip))\b"
|
|
38
46
|
)
|
|
39
47
|
|
|
40
48
|
|
|
@@ -88,33 +96,29 @@ class AstTamperAnalyzer:
|
|
|
88
96
|
def _check_assert_deletions(
|
|
89
97
|
cls, file_diff: FileDiff, incidents: List[TamperIncident]
|
|
90
98
|
):
|
|
91
|
-
"""
|
|
99
|
+
"""Net assertion loss across the file is a tamper signal.
|
|
92
100
|
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
Counted per file (not per hunk) so splitting a test or converting it to
|
|
102
|
+
parametrize does not look like a deletion; a net drop still counts.
|
|
95
103
|
"""
|
|
96
104
|
if file_diff.is_deleted:
|
|
97
105
|
return
|
|
106
|
+
removed_asserts: List[Tuple[int, str]] = []
|
|
107
|
+
added_count = 0
|
|
98
108
|
for hunk in file_diff.hunks:
|
|
99
|
-
cls.
|
|
109
|
+
removed_asserts.extend(cls._assert_lines(hunk.removed))
|
|
110
|
+
added_count += len(cls._assert_lines(hunk.added))
|
|
100
111
|
|
|
101
|
-
|
|
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)
|
|
112
|
+
net_loss = len(removed_asserts) - added_count
|
|
109
113
|
if net_loss <= 0:
|
|
110
114
|
return
|
|
111
|
-
for ln, line in removed_asserts[:
|
|
115
|
+
for ln, line in removed_asserts[-net_loss:]:
|
|
112
116
|
incidents.append(
|
|
113
117
|
TamperIncident(
|
|
114
118
|
kind=TamperKind.ASSERT_DELETED,
|
|
115
119
|
file_path=file_diff.target_path,
|
|
116
120
|
line_number=ln,
|
|
117
|
-
message=f"Removed {net_loss} assertion(s)
|
|
121
|
+
message=f"Removed {net_loss} assertion(s) without equivalent replacement.",
|
|
118
122
|
snippet=line.strip(),
|
|
119
123
|
)
|
|
120
124
|
)
|
|
@@ -237,31 +241,22 @@ class AstTamperAnalyzer:
|
|
|
237
241
|
fragment.real_line(getattr(node, "lineno", 1), offset)
|
|
238
242
|
)
|
|
239
243
|
|
|
240
|
-
|
|
241
|
-
|
|
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)
|
|
244
|
+
# Always scan lines too: the AST path sees one node per decorator chain,
|
|
245
|
+
# the regex catches what a partially parseable fragment hides.
|
|
254
246
|
for idx, (ln, line) in enumerate(file_diff.added_lines, start=1):
|
|
255
|
-
if idx in covered:
|
|
247
|
+
if idx in covered or ln in found_lines:
|
|
256
248
|
continue
|
|
257
249
|
stripped = line.strip()
|
|
258
250
|
if SKIP_FALLBACK_RE.match(stripped):
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
251
|
+
found_lines.add(ln)
|
|
252
|
+
|
|
253
|
+
for ln in sorted(found_lines):
|
|
254
|
+
incidents.append(
|
|
255
|
+
TamperIncident(
|
|
256
|
+
kind=TamperKind.TEST_SKIPPED,
|
|
257
|
+
file_path=file_diff.target_path,
|
|
258
|
+
line_number=ln,
|
|
259
|
+
message="Test explicitly skipped with skip/xfail/expectedFailure in PR.",
|
|
260
|
+
snippet="skip",
|
|
267
261
|
)
|
|
262
|
+
)
|
|
@@ -9,23 +9,22 @@ import subprocess
|
|
|
9
9
|
import sys
|
|
10
10
|
from pathlib import Path
|
|
11
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
12
|
from slopgate import __version__
|
|
25
13
|
from slopgate.engine import AuditEngine, Severity
|
|
26
14
|
from slopgate.reporter import AnsiColor, Reporter
|
|
27
15
|
|
|
28
|
-
|
|
16
|
+
|
|
17
|
+
def _configure_stdio() -> None:
|
|
18
|
+
"""Ensure UTF-8 output on Windows consoles (only when running as a CLI)."""
|
|
19
|
+
for stream in (sys.stdout, sys.stderr):
|
|
20
|
+
if stream and hasattr(stream, "reconfigure"):
|
|
21
|
+
try:
|
|
22
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
GITHUB_WORKFLOW_TEMPLATE = """name: SlopGate PR Integrity Check
|
|
29
28
|
|
|
30
29
|
on:
|
|
31
30
|
pull_request:
|
|
@@ -45,7 +44,7 @@ jobs:
|
|
|
45
44
|
fetch-depth: 0
|
|
46
45
|
|
|
47
46
|
- name: Run SlopGate Integrity Check
|
|
48
|
-
uses: Shinraya-Tech-Labs/slopgate@
|
|
47
|
+
uses: Shinraya-Tech-Labs/slopgate@v0
|
|
49
48
|
with:
|
|
50
49
|
fail-on: blocked
|
|
51
50
|
"""
|
|
@@ -131,9 +130,11 @@ def get_git_diff() -> str:
|
|
|
131
130
|
["git", "diff", "main...HEAD"],
|
|
132
131
|
["git", "diff", "origin/master...HEAD"],
|
|
133
132
|
["git", "diff", "master...HEAD"],
|
|
134
|
-
|
|
133
|
+
# Local runs audit the work in progress first: HEAD~1 last, so a
|
|
134
|
+
# plain `slopgate check` never reports an already-merged commit.
|
|
135
135
|
["git", "diff", "--cached"],
|
|
136
136
|
["git", "diff"],
|
|
137
|
+
["git", "diff", "HEAD~1"],
|
|
137
138
|
]
|
|
138
139
|
)
|
|
139
140
|
for cmd in cmds:
|
|
@@ -146,25 +147,64 @@ def get_git_diff() -> str:
|
|
|
146
147
|
return ""
|
|
147
148
|
|
|
148
149
|
|
|
150
|
+
def _decode_diff_bytes(data: bytes) -> str:
|
|
151
|
+
"""Decodes a saved diff, honouring the UTF-16 that PowerShell redirection adds."""
|
|
152
|
+
if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
153
|
+
return data.decode("utf-16", errors="replace")
|
|
154
|
+
return data.decode("utf-8-sig", errors="replace")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_shallow_checkout() -> bool:
|
|
158
|
+
try:
|
|
159
|
+
res = subprocess.run(
|
|
160
|
+
["git", "rev-parse", "--is-shallow-repository"],
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
check=False,
|
|
164
|
+
)
|
|
165
|
+
return res.stdout.strip() == "true"
|
|
166
|
+
except Exception:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
149
170
|
def cmd_check(args: argparse.Namespace) -> int:
|
|
150
171
|
"""Audits diff text."""
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
try:
|
|
173
|
+
if args.diff_file == "-":
|
|
174
|
+
diff_text = _decode_diff_bytes(sys.stdin.buffer.read())
|
|
175
|
+
elif args.diff_file:
|
|
176
|
+
diff_text = _decode_diff_bytes(Path(args.diff_file).read_bytes())
|
|
177
|
+
else:
|
|
178
|
+
diff_text = get_git_diff()
|
|
179
|
+
if not diff_text:
|
|
180
|
+
hint = (
|
|
181
|
+
" The checkout looks shallow; use actions/checkout with fetch-depth: 0."
|
|
182
|
+
if _is_shallow_checkout()
|
|
183
|
+
else ""
|
|
184
|
+
)
|
|
185
|
+
print(
|
|
186
|
+
"⚠️ No git diff detected. Provide a diff via --diff <file> or piped via stdin."
|
|
187
|
+
+ hint,
|
|
188
|
+
file=sys.stderr,
|
|
189
|
+
)
|
|
190
|
+
# Failing open in CI would report success on a broken checkout
|
|
191
|
+
return 1 if os.environ.get("GITHUB_ACTIONS") == "true" else 0
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
print(f"⛔ Cannot read diff: {exc}", file=sys.stderr)
|
|
194
|
+
return 2
|
|
164
195
|
|
|
165
196
|
engine = AuditEngine()
|
|
166
197
|
report = engine.audit_diff(diff_text)
|
|
167
198
|
|
|
199
|
+
if diff_text.strip() and report.metrics.files_changed == 0:
|
|
200
|
+
# An unparsed diff must never look like a clean audit
|
|
201
|
+
print(
|
|
202
|
+
"⛔ No file changes could be parsed from the diff. "
|
|
203
|
+
"Is it a git unified diff (git diff --no-color)?",
|
|
204
|
+
file=sys.stderr,
|
|
205
|
+
)
|
|
206
|
+
return 2
|
|
207
|
+
|
|
168
208
|
# Output formatting
|
|
169
209
|
if args.format == "json":
|
|
170
210
|
print(Reporter.render_json(report))
|
|
@@ -234,8 +274,10 @@ def cmd_demo(args: argparse.Namespace) -> int:
|
|
|
234
274
|
rep3 = engine.audit_diff(SAMPLE_CLEAN_DIFF)
|
|
235
275
|
print(Reporter.render_terminal(rep3))
|
|
236
276
|
|
|
277
|
+
intercepted = sum(1 for rep in (rep1, rep2) if rep.status == Severity.BLOCKED)
|
|
278
|
+
approved = sum(1 for rep in (rep1, rep2, rep3) if rep.status == Severity.INFO)
|
|
237
279
|
print(
|
|
238
|
-
f"{AnsiColor.GREEN}{AnsiColor.BOLD}✓ Simulator completed:
|
|
280
|
+
f"{AnsiColor.GREEN}{AnsiColor.BOLD}✓ Simulator completed: {intercepted} rogue AI PR(s) intercepted, {approved} clean bugfix approved.{AnsiColor.RESET}\n"
|
|
239
281
|
)
|
|
240
282
|
return 0
|
|
241
283
|
|
|
@@ -266,6 +308,7 @@ def cmd_badge(args: argparse.Namespace) -> int:
|
|
|
266
308
|
|
|
267
309
|
|
|
268
310
|
def main():
|
|
311
|
+
_configure_stdio()
|
|
269
312
|
parser = argparse.ArgumentParser(
|
|
270
313
|
prog="slopgate",
|
|
271
314
|
description="🛡️ SlopGate: AST-Based Code Integrity & PR Integrity Shield",
|
|
@@ -38,11 +38,14 @@ class FileDiff:
|
|
|
38
38
|
@property
|
|
39
39
|
def is_test_file(self) -> bool:
|
|
40
40
|
path = self.target_path.lower()
|
|
41
|
+
name = Path(path).name
|
|
41
42
|
parts = Path(path).parts
|
|
42
43
|
return (
|
|
43
|
-
any(p in ("tests", "test", "__tests__") for p in parts)
|
|
44
|
-
|
|
45
|
-
or
|
|
44
|
+
any(p in ("tests", "test", "__tests__", "spec", "specs") for p in parts)
|
|
45
|
+
# Django/Flask style: myapp/tests.py, conftest.py, *_spec.py
|
|
46
|
+
or name in ("tests.py", "test.py", "conftest.py")
|
|
47
|
+
or name.startswith(("test_", "spec_"))
|
|
48
|
+
or name.endswith(("_test.py", "_tests.py", "_spec.py"))
|
|
46
49
|
)
|
|
47
50
|
|
|
48
51
|
@property
|
|
@@ -116,8 +116,21 @@ class AuditEngine:
|
|
|
116
116
|
tampered_test = False
|
|
117
117
|
|
|
118
118
|
for f in files:
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
if f.is_test_file and f.is_deleted:
|
|
120
|
+
# Deleting the test is the cheapest way to "make it pass"
|
|
121
|
+
tampered_test = True
|
|
122
|
+
violations.append(
|
|
123
|
+
Violation(
|
|
124
|
+
rule_id="SG-TEST_FILE_DELETED",
|
|
125
|
+
severity=Severity.BLOCKED,
|
|
126
|
+
file_path=f.old_path or f.target_path,
|
|
127
|
+
line_number=1,
|
|
128
|
+
title="Test File Deleted",
|
|
129
|
+
message="PR deletes a test file instead of fixing the code under test.",
|
|
130
|
+
snippet=None,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
elif f.is_test_file:
|
|
121
134
|
has_test_change = True
|
|
122
135
|
# Run Test Tampering Inspection
|
|
123
136
|
incidents = AstTamperAnalyzer.analyze_file(f)
|
|
@@ -128,6 +141,7 @@ class AuditEngine:
|
|
|
128
141
|
if inc.kind
|
|
129
142
|
in (
|
|
130
143
|
TamperKind.ASSERT_DELETED,
|
|
144
|
+
TamperKind.ASSERT_WEAKENED,
|
|
131
145
|
TamperKind.TRY_EXCEPT_BYPASS,
|
|
132
146
|
TamperKind.TEST_SKIPPED,
|
|
133
147
|
)
|
|
@@ -45,12 +45,14 @@ def _lexical_string_lines(text: str) -> Set[int]:
|
|
|
45
45
|
class Fragment:
|
|
46
46
|
def __init__(self, file_diff: FileDiff):
|
|
47
47
|
self.text = "\n".join(line for _, line in file_diff.added_lines)
|
|
48
|
+
# A UTF-8 BOM on line 1 makes the whole fragment unparseable
|
|
49
|
+
self.text = self.text.lstrip("\ufeff")
|
|
48
50
|
self.line_map: List[int] = [ln for ln, _ in file_diff.added_lines]
|
|
49
51
|
|
|
50
52
|
@classmethod
|
|
51
53
|
def from_lines(cls, lines: List[Tuple[int, str]]) -> "Fragment":
|
|
52
54
|
fragment = cls.__new__(cls)
|
|
53
|
-
fragment.text = "\n".join(line for _, line in lines)
|
|
55
|
+
fragment.text = "\n".join(line for _, line in lines).lstrip("\ufeff")
|
|
54
56
|
fragment.line_map = [ln for ln, _ in lines]
|
|
55
57
|
return fragment
|
|
56
58
|
|
|
@@ -5,6 +5,7 @@ in Python standard library, project manifests, or local repository files.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import ast
|
|
8
|
+
import os
|
|
8
9
|
import re
|
|
9
10
|
import sys
|
|
10
11
|
from dataclasses import dataclass
|
|
@@ -37,6 +38,16 @@ def _normalize(name: str) -> str:
|
|
|
37
38
|
return name.strip().replace("-", "_").replace(".", "_").lower()
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
def _dotted(node: ast.AST) -> str:
|
|
42
|
+
"""Dotted name of a Name/Attribute chain ('typing.TYPE_CHECKING')."""
|
|
43
|
+
if isinstance(node, ast.Name):
|
|
44
|
+
return node.id
|
|
45
|
+
if isinstance(node, ast.Attribute):
|
|
46
|
+
base = _dotted(node.value)
|
|
47
|
+
return f"{base}.{node.attr}" if base else node.attr
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
|
|
40
51
|
class PhantomDetector:
|
|
41
52
|
"""Detects phantom imports and hallucinated APIs added in diffs."""
|
|
42
53
|
|
|
@@ -92,8 +103,43 @@ class PhantomDetector:
|
|
|
92
103
|
"usb": "pyusb",
|
|
93
104
|
"attr": "attrs",
|
|
94
105
|
"gi": "pygobject",
|
|
106
|
+
"rest_framework": "djangorestframework",
|
|
107
|
+
"django_filters": "django_filter",
|
|
108
|
+
"github": "pygithub",
|
|
109
|
+
"mysql": "mysqlclient",
|
|
110
|
+
"mysqldb": "mysqlclient",
|
|
111
|
+
"jose": "python_jose",
|
|
112
|
+
"slugify": "python_slugify",
|
|
113
|
+
"magic": "python_magic",
|
|
114
|
+
"docx": "python_docx",
|
|
115
|
+
"pptx": "python_pptx",
|
|
116
|
+
"fitz": "pymupdf",
|
|
117
|
+
"grpc": "grpcio",
|
|
118
|
+
"googleapiclient": "google_api_python_client",
|
|
95
119
|
}
|
|
96
120
|
|
|
121
|
+
# Vendored, virtualenv, and build-output trees are not project code.
|
|
122
|
+
IGNORED_DIRS = {
|
|
123
|
+
".git",
|
|
124
|
+
".hg",
|
|
125
|
+
".svn",
|
|
126
|
+
".venv",
|
|
127
|
+
"venv",
|
|
128
|
+
"env",
|
|
129
|
+
"node_modules",
|
|
130
|
+
"build",
|
|
131
|
+
"dist",
|
|
132
|
+
"__pycache__",
|
|
133
|
+
".tox",
|
|
134
|
+
".nox",
|
|
135
|
+
".mypy_cache",
|
|
136
|
+
".pytest_cache",
|
|
137
|
+
"site-packages",
|
|
138
|
+
"htmlcov",
|
|
139
|
+
".eggs",
|
|
140
|
+
}
|
|
141
|
+
LOCAL_SCAN_DEPTH = 3
|
|
142
|
+
|
|
97
143
|
# Common hallucinated methods on standard library objects
|
|
98
144
|
KNOWN_STDLIB_HALLUCINATIONS = {
|
|
99
145
|
("json", "loads_async"): "json has no async loads method.",
|
|
@@ -109,10 +155,49 @@ class PhantomDetector:
|
|
|
109
155
|
|
|
110
156
|
@classmethod
|
|
111
157
|
def get_declared_dependencies(cls, repo_root: Optional[Path] = None) -> Set[str]:
|
|
112
|
-
"""Scans dependency manifests and local modules to extract valid import names.
|
|
158
|
+
"""Scans dependency manifests and local modules to extract valid import names.
|
|
159
|
+
|
|
160
|
+
Manifests are read from every directory up to LOCAL_SCAN_DEPTH so
|
|
161
|
+
monorepos and `src/` layouts resolve too. The manifest is the PR head's
|
|
162
|
+
copy: shortcut: a PR that adds its own dependency declaration can
|
|
163
|
+
whitelist a package in the same diff, move to base-SHA manifests if
|
|
164
|
+
that is ever abused.
|
|
165
|
+
"""
|
|
166
|
+
repo_root = (repo_root or Path.cwd()).resolve()
|
|
113
167
|
deps: Set[str] = set()
|
|
114
|
-
repo_root = repo_root or Path.cwd()
|
|
115
168
|
|
|
169
|
+
for directory in cls._walk_dirs(repo_root):
|
|
170
|
+
cls._scan_manifests(directory, deps)
|
|
171
|
+
if directory != repo_root:
|
|
172
|
+
# Local package directory (works without __init__.py, PEP 420)
|
|
173
|
+
deps.add(directory.name.lower())
|
|
174
|
+
for entry in directory.iterdir():
|
|
175
|
+
if entry.is_file() and entry.suffix == ".py":
|
|
176
|
+
deps.add(entry.stem.lower())
|
|
177
|
+
|
|
178
|
+
deps.discard("")
|
|
179
|
+
return deps
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def _walk_dirs(cls, repo_root: Path) -> List[Path]:
|
|
183
|
+
"""Repo directories up to LOCAL_SCAN_DEPTH, skipping vendored/venv trees."""
|
|
184
|
+
dirs: List[Path] = []
|
|
185
|
+
base_depth = len(repo_root.parts)
|
|
186
|
+
for dirpath, dirnames, _ in os.walk(repo_root):
|
|
187
|
+
current = Path(dirpath)
|
|
188
|
+
if len(current.parts) - base_depth >= cls.LOCAL_SCAN_DEPTH:
|
|
189
|
+
dirnames[:] = []
|
|
190
|
+
dirnames[:] = [
|
|
191
|
+
d
|
|
192
|
+
for d in dirnames
|
|
193
|
+
if d.lower() not in cls.IGNORED_DIRS and not d.startswith(".")
|
|
194
|
+
]
|
|
195
|
+
dirs.append(current)
|
|
196
|
+
return dirs
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def _scan_manifests(cls, repo_root: Path, deps: Set[str]) -> None:
|
|
200
|
+
"""Reads every dependency manifest directly inside one directory."""
|
|
116
201
|
# 1. requirements.txt, requirements/*.txt, requirements-*.txt
|
|
117
202
|
req_files = list(repo_root.glob("*requirement*.txt")) + list(
|
|
118
203
|
repo_root.glob("requirements/*.txt")
|
|
@@ -120,14 +205,17 @@ class PhantomDetector:
|
|
|
120
205
|
for req_file in sorted(set(req_files)):
|
|
121
206
|
cls._read_requirements_file(req_file, deps)
|
|
122
207
|
|
|
123
|
-
# 2. setup.py install_requires
|
|
208
|
+
# 2. setup.py install_requires / extras_require (all quoted dist names)
|
|
124
209
|
setup_py = repo_root / "setup.py"
|
|
125
210
|
if setup_py.exists():
|
|
126
211
|
try:
|
|
127
212
|
content = setup_py.read_text(encoding="utf-8", errors="ignore")
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
213
|
+
if "install_requires" in content or "extras_require" in content:
|
|
214
|
+
for match in re.finditer(
|
|
215
|
+
r'["\']([A-Za-z0-9][A-Za-z0-9_.\-]*)(?:\[[^\]]*\])?'
|
|
216
|
+
r'(?:[<>=!~][^"\']*)?["\']',
|
|
217
|
+
content,
|
|
218
|
+
):
|
|
131
219
|
deps.add(_normalize(match.group(1)))
|
|
132
220
|
except Exception:
|
|
133
221
|
pass
|
|
@@ -183,17 +271,6 @@ class PhantomDetector:
|
|
|
183
271
|
except Exception:
|
|
184
272
|
pass
|
|
185
273
|
|
|
186
|
-
# 7. Local repo top-level packages & modules
|
|
187
|
-
for p in repo_root.glob("*.py"):
|
|
188
|
-
deps.add(p.stem.lower())
|
|
189
|
-
for pattern in ("*/__init__.py", "src/*.py", "src/*/__init__.py"):
|
|
190
|
-
for p in repo_root.glob(pattern):
|
|
191
|
-
deps.add(
|
|
192
|
-
p.stem.lower() if p.name != "__init__.py" else p.parent.name.lower()
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
return deps
|
|
196
|
-
|
|
197
274
|
@classmethod
|
|
198
275
|
def _read_requirements_file(cls, path: Path, deps: Set[str], depth: int = 0):
|
|
199
276
|
"""Reads a requirements file, following `-r` includes one level deep."""
|
|
@@ -311,9 +388,21 @@ class PhantomDetector:
|
|
|
311
388
|
@classmethod
|
|
312
389
|
def _scan_tree(cls, tree, file_diff, line_of, declared_deps: Set[str]):
|
|
313
390
|
incidents: List[PhantomIncident] = []
|
|
391
|
+
# Imports under `if TYPE_CHECKING:` never load at runtime.
|
|
392
|
+
type_only_lines: Set[int] = set()
|
|
393
|
+
for node in ast.walk(tree):
|
|
394
|
+
if isinstance(node, ast.If) and _dotted(node.test) in (
|
|
395
|
+
"TYPE_CHECKING",
|
|
396
|
+
"typing.TYPE_CHECKING",
|
|
397
|
+
):
|
|
398
|
+
end = getattr(node, "end_lineno", None) or node.lineno
|
|
399
|
+
type_only_lines.update(range(node.lineno, end + 1))
|
|
400
|
+
|
|
314
401
|
for node in ast.walk(tree):
|
|
315
402
|
# Check `import x`
|
|
316
403
|
if isinstance(node, ast.Import):
|
|
404
|
+
if getattr(node, "lineno", 0) in type_only_lines:
|
|
405
|
+
continue
|
|
317
406
|
for alias in node.names:
|
|
318
407
|
top_pkg = alias.name.split(".")[0]
|
|
319
408
|
if not cls._is_known_package(top_pkg, declared_deps):
|
|
@@ -329,6 +418,8 @@ class PhantomDetector:
|
|
|
329
418
|
|
|
330
419
|
# Check `from x import y`
|
|
331
420
|
elif isinstance(node, ast.ImportFrom):
|
|
421
|
+
if getattr(node, "lineno", 0) in type_only_lines:
|
|
422
|
+
continue
|
|
332
423
|
# Relative imports (from . import x) always resolve inside the package
|
|
333
424
|
if node.module and not node.level:
|
|
334
425
|
top_pkg = node.module.split(".")[0]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slopgate-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: AST-based code integrity, test tamper detection, and phantom symbol interception for GitHub Pull Requests.
|
|
5
5
|
Author-email: Shinraya Tech Labs <labs@shinraya.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
22
|
Requires-Python: >=3.10
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
23
24
|
License-File: LICENSE
|
|
@@ -196,7 +197,7 @@ jobs:
|
|
|
196
197
|
fetch-depth: 0
|
|
197
198
|
|
|
198
199
|
- name: Run SlopGate Shield
|
|
199
|
-
uses: Shinraya-Tech-Labs/slopgate@v0
|
|
200
|
+
uses: Shinraya-Tech-Labs/slopgate@v0
|
|
200
201
|
with:
|
|
201
202
|
fail-on: blocked # 'blocked' (fails on tamper/hallucinations) or 'warning'
|
|
202
203
|
```
|
|
@@ -99,7 +99,7 @@ def test_install_template_pins_released_action_tag(monkeypatch, capsys, tmp_path
|
|
|
99
99
|
text = (tmp_path / ".github" / "workflows" / "slopgate.yml").read_text(
|
|
100
100
|
encoding="utf-8"
|
|
101
101
|
)
|
|
102
|
-
assert
|
|
102
|
+
assert "Shinraya-Tech-Labs/slopgate@v0" in text
|
|
103
103
|
assert "pull-requests: write" not in text
|
|
104
104
|
capsys.readouterr()
|
|
105
105
|
|
|
@@ -174,3 +174,27 @@ def test_checked_out_pr_cannot_shadow_package(tmp_path):
|
|
|
174
174
|
)
|
|
175
175
|
assert not marker.exists()
|
|
176
176
|
assert result.returncode == 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_missing_diff_file_exits_2(monkeypatch, capsys):
|
|
180
|
+
code = _main(
|
|
181
|
+
monkeypatch, ["check", "--diff", str(FIXTURES / "does_not_exist.diff")]
|
|
182
|
+
)
|
|
183
|
+
assert code == 2
|
|
184
|
+
assert "Cannot read diff" in capsys.readouterr().err
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_unparseable_diff_exits_2(monkeypatch, capsys, tmp_path):
|
|
188
|
+
bad = tmp_path / "pr.diff"
|
|
189
|
+
bad.write_text("not a diff, just prose\n", encoding="utf-8")
|
|
190
|
+
assert _main(monkeypatch, ["check", "--diff", str(bad)]) == 2
|
|
191
|
+
assert "No file changes could be parsed" in capsys.readouterr().err
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_utf16_diff_is_decoded(monkeypatch, capsys, tmp_path):
|
|
195
|
+
"""PowerShell `git diff > pr.diff` writes UTF-16; it must not read as clean."""
|
|
196
|
+
text = (FIXTURES / "neutered_test.diff").read_text(encoding="utf-8")
|
|
197
|
+
path = tmp_path / "pr.diff"
|
|
198
|
+
path.write_bytes(b"\xff\xfe" + text.encode("utf-16-le"))
|
|
199
|
+
assert _main(monkeypatch, ["check", "--diff", str(path)]) == 1
|
|
200
|
+
capsys.readouterr()
|
|
@@ -19,7 +19,7 @@ def _audit(fixture_name: str):
|
|
|
19
19
|
|
|
20
20
|
def test_tampered_diff_audit():
|
|
21
21
|
report = AuditEngine().audit_diff(SAMPLE_TAMPERED_DIFF)
|
|
22
|
-
assert report.status == Severity.
|
|
22
|
+
assert report.status == Severity.BLOCKED # Assertion weakened
|
|
23
23
|
assert any(v.rule_id == "SG-ASSERT_WEAKENED" for v in report.violations)
|
|
24
24
|
# A tampered test must not count as a reproduction test
|
|
25
25
|
assert not report.repro_verified
|
|
@@ -42,7 +42,15 @@ def test_clean_diff_audit():
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def test_warning_is_not_clean():
|
|
45
|
-
|
|
45
|
+
diff_text = """diff --git a/src/app.py b/src/app.py
|
|
46
|
+
--- a/src/app.py
|
|
47
|
+
+++ b/src/app.py
|
|
48
|
+
@@ -1,2 +1,3 @@
|
|
49
|
+
def run():
|
|
50
|
+
+ log("starting")
|
|
51
|
+
return 1
|
|
52
|
+
"""
|
|
53
|
+
report = AuditEngine().audit_diff(diff_text)
|
|
46
54
|
assert report.status == Severity.WARNING
|
|
47
55
|
assert not report.is_clean
|
|
48
56
|
assert report.not_blocked
|
|
@@ -103,8 +111,8 @@ deleted file mode 100644
|
|
|
103
111
|
"""
|
|
104
112
|
report = AuditEngine().audit_diff(diff_text)
|
|
105
113
|
assert not report.repro_verified
|
|
106
|
-
assert
|
|
107
|
-
assert
|
|
114
|
+
assert report.status == Severity.BLOCKED
|
|
115
|
+
assert any(v.rule_id == "SG-TEST_FILE_DELETED" for v in report.violations)
|
|
108
116
|
|
|
109
117
|
|
|
110
118
|
def test_docs_only_diff_is_not_doc_bloat():
|
|
@@ -5,6 +5,7 @@ Tests for Phantom Symbol & Hallucinated Import Detector.
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from slopgate.diff_parser import DiffParser
|
|
8
|
+
from slopgate.engine import AuditEngine
|
|
8
9
|
from slopgate.phantom import PhantomDetector, PhantomKind
|
|
9
10
|
|
|
10
11
|
|
|
@@ -217,3 +218,87 @@ def test_repo_root_local_package_is_known(tmp_path):
|
|
|
217
218
|
deps = PhantomDetector.get_declared_dependencies(tmp_path)
|
|
218
219
|
assert "mypkg" in deps
|
|
219
220
|
assert Path(tmp_path / "mypkg").is_dir()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _audit_in(repo_root, diff_text):
|
|
224
|
+
return AuditEngine(repo_root=repo_root).audit_diff(diff_text)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _hallucinated(report):
|
|
228
|
+
return [v for v in report.violations if "HALLUCINATED" in v.rule_id]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_namespace_package_without_init_is_local(tmp_path):
|
|
232
|
+
(tmp_path / "ns" / "sub").mkdir(parents=True)
|
|
233
|
+
(tmp_path / "ns" / "sub" / "__init__.py").write_text("", encoding="utf-8")
|
|
234
|
+
(tmp_path / "ns" / "sub" / "util.py").write_text("def helper():\n return 1\n")
|
|
235
|
+
diff = """diff --git a/app/main.py b/app/main.py
|
|
236
|
+
--- a/app/main.py
|
|
237
|
+
+++ b/app/main.py
|
|
238
|
+
@@ -1,1 +1,2 @@
|
|
239
|
+
+from ns.sub.util import helper
|
|
240
|
+
"""
|
|
241
|
+
assert _hallucinated(_audit_in(tmp_path, diff)) == []
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_subproject_manifest_is_read(tmp_path):
|
|
245
|
+
(tmp_path / "services" / "api").mkdir(parents=True)
|
|
246
|
+
(tmp_path / "services" / "api" / "pyproject.toml").write_text(
|
|
247
|
+
'[project]\nname = "api"\ndependencies = ["fastapi>=0.100"]\n',
|
|
248
|
+
encoding="utf-8",
|
|
249
|
+
)
|
|
250
|
+
diff = """diff --git a/services/api/main.py b/services/api/main.py
|
|
251
|
+
--- a/services/api/main.py
|
|
252
|
+
+++ b/services/api/main.py
|
|
253
|
+
@@ -1,1 +1,2 @@
|
|
254
|
+
+from fastapi import FastAPI
|
|
255
|
+
"""
|
|
256
|
+
assert _hallucinated(_audit_in(tmp_path, diff)) == []
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_setup_py_extras_are_dependencies(tmp_path):
|
|
260
|
+
(tmp_path / "setup.py").write_text(
|
|
261
|
+
"from setuptools import setup\n"
|
|
262
|
+
"setup(\n"
|
|
263
|
+
" name='demo',\n"
|
|
264
|
+
" install_requires=['requests'],\n"
|
|
265
|
+
" extras_require={'aws': ['boto3>=1.0']},\n"
|
|
266
|
+
")\n",
|
|
267
|
+
encoding="utf-8",
|
|
268
|
+
)
|
|
269
|
+
deps = PhantomDetector.get_declared_dependencies(tmp_path)
|
|
270
|
+
assert {"requests", "boto3"} <= deps
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_import_alias_for_declared_distribution():
|
|
274
|
+
diff = """diff --git a/api/views.py b/api/views.py
|
|
275
|
+
--- a/api/views.py
|
|
276
|
+
+++ b/api/views.py
|
|
277
|
+
@@ -1,1 +1,2 @@
|
|
278
|
+
+from rest_framework.decorators import api_view
|
|
279
|
+
"""
|
|
280
|
+
assert _incidents(diff, {"djangorestframework"}) == []
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_type_checking_imports_are_not_runtime_deps():
|
|
284
|
+
diff = """diff --git a/src/models.py b/src/models.py
|
|
285
|
+
--- a/src/models.py
|
|
286
|
+
+++ b/src/models.py
|
|
287
|
+
@@ -1,1 +1,4 @@
|
|
288
|
+
+from typing import TYPE_CHECKING
|
|
289
|
+
+if TYPE_CHECKING:
|
|
290
|
+
+ import pandas
|
|
291
|
+
"""
|
|
292
|
+
assert _incidents(diff, set()) == []
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_bom_does_not_hide_the_first_line():
|
|
296
|
+
diff = (
|
|
297
|
+
"diff --git a/src/x.py b/src/x.py\n"
|
|
298
|
+
"--- a/src/x.py\n"
|
|
299
|
+
"+++ b/src/x.py\n"
|
|
300
|
+
"@@ -1,0 +1,1 @@\n"
|
|
301
|
+
"+\ufeffimport hallucinated_pkg\n"
|
|
302
|
+
)
|
|
303
|
+
incidents = _incidents(diff, set())
|
|
304
|
+
assert any(inc.symbol == "hallucinated_pkg" for inc in incidents)
|
|
@@ -22,7 +22,7 @@ def test_detect_assert_deletion():
|
|
|
22
22
|
assert any(inc.kind == TamperKind.ASSERT_DELETED for inc in _incidents(diff_text))
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
25
|
+
def test_assert_move_between_hunks_is_not_a_deletion():
|
|
26
26
|
diff_text = """diff --git a/tests/test_math.py b/tests/test_math.py
|
|
27
27
|
--- a/tests/test_math.py
|
|
28
28
|
+++ b/tests/test_math.py
|
|
@@ -33,8 +33,25 @@ def test_assert_deletion_not_masked_by_other_hunk():
|
|
|
33
33
|
+ assert add(1, 1) == 2
|
|
34
34
|
pass
|
|
35
35
|
"""
|
|
36
|
-
|
|
37
|
-
assert any(
|
|
36
|
+
# Splitting a test moves assertions between hunks; only a net loss counts
|
|
37
|
+
assert not any(
|
|
38
|
+
inc.kind == TamperKind.ASSERT_DELETED for inc in _incidents(diff_text)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_assert_net_loss_across_hunks_is_a_deletion():
|
|
43
|
+
diff_text = """diff --git a/tests/test_math.py b/tests/test_math.py
|
|
44
|
+
--- a/tests/test_math.py
|
|
45
|
+
+++ b/tests/test_math.py
|
|
46
|
+
@@ -10,3 +10,1 @@ def test_one():
|
|
47
|
+
- assert add(2, 2) == 4
|
|
48
|
+
- assert add(3, 3) == 6
|
|
49
|
+
+ pass
|
|
50
|
+
@@ -40,1 +38,2 @@ def test_two():
|
|
51
|
+
+ assert add(1, 1) == 2
|
|
52
|
+
pass
|
|
53
|
+
"""
|
|
54
|
+
assert any(inc.kind == TamperKind.ASSERT_DELETED for inc in _incidents(diff_text))
|
|
38
55
|
|
|
39
56
|
|
|
40
57
|
def test_assert_rewrite_in_same_hunk_is_not_a_deletion():
|
|
@@ -161,3 +178,27 @@ def test_incident_line_numbers_are_absolute():
|
|
|
161
178
|
"""
|
|
162
179
|
incidents = _incidents(diff_text)
|
|
163
180
|
assert [inc.line_number for inc in incidents] == [102]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_django_style_tests_module_is_detected():
|
|
184
|
+
diff_text = """diff --git a/blog/tests.py b/blog/tests.py
|
|
185
|
+
--- a/blog/tests.py
|
|
186
|
+
+++ b/blog/tests.py
|
|
187
|
+
@@ -10,2 +10,1 @@
|
|
188
|
+
- assert Post.objects.count() == 1
|
|
189
|
+
+ pass
|
|
190
|
+
"""
|
|
191
|
+
assert any(inc.kind == TamperKind.ASSERT_DELETED for inc in _incidents(diff_text))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_xfail_and_expected_failure_are_skips():
|
|
195
|
+
for marker in ('@pytest.mark.xfail(reason="flaky")', "@unittest.expectedFailure"):
|
|
196
|
+
diff_text = (
|
|
197
|
+
"diff --git a/tests/test_x.py b/tests/test_x.py\n"
|
|
198
|
+
"--- a/tests/test_x.py\n"
|
|
199
|
+
"+++ b/tests/test_x.py\n"
|
|
200
|
+
"@@ -1,1 +1,2 @@\n"
|
|
201
|
+
f"+{marker}\n"
|
|
202
|
+
" def test_x():\n"
|
|
203
|
+
)
|
|
204
|
+
assert any(inc.kind == TamperKind.TEST_SKIPPED for inc in _incidents(diff_text))
|
slopgate_cli-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to this project are documented here.
|
|
4
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
-
|
|
7
|
-
## [Unreleased]
|
|
8
|
-
|
|
9
|
-
## [0.1.0] - 2026-10-28
|
|
10
|
-
|
|
11
|
-
First public release. Python 3.10+, zero runtime dependencies.
|
|
12
|
-
|
|
13
|
-
### Added
|
|
14
|
-
|
|
15
|
-
- `slopgate check` — audits a unified git diff from a file, stdin, or the current
|
|
16
|
-
PR (`GITHUB_BASE_REF`), with `terminal`, `json`, and `github` output formats and
|
|
17
|
-
`--fail-on blocked|warning|never`.
|
|
18
|
-
- Test tamper detection: deleted assertions (per hunk), tautology/weakening swaps,
|
|
19
|
-
`try: ... except: pass` suppression, and newly added `pytest` skips — parsed
|
|
20
|
-
with `ast`, so `skipif` and comments do not trip it.
|
|
21
|
-
- Phantom symbol detection: imports missing from the stdlib, declared dependencies
|
|
22
|
-
(`pyproject.toml`, `requirements*.txt` incl. `-r` includes, `setup.py`,
|
|
23
|
-
`setup.cfg`, `Pipfile`, `environment.yml`, Poetry) and local modules; plus a
|
|
24
|
-
table of hallucinated stdlib methods.
|
|
25
|
-
- Reproduction gate: flags production changes that ship without a test change,
|
|
26
|
-
and only counts the reproduction as verified when the test change is untampered.
|
|
27
|
-
- Slop ratio: docstring/comment bloat versus executable statements added.
|
|
28
|
-
- `slopgate demo` — offline interactive simulation of three real PR scenarios.
|
|
29
|
-
- `slopgate install` — writes a pinned `.github/workflows/slopgate.yml` into a repo.
|
|
30
|
-
- `slopgate badge` — Markdown and HTML README badges.
|
|
31
|
-
- Composite GitHub Action with Step Summary output and `::error` annotations.
|
|
32
|
-
- GitHub Actions workflows: CI matrix (Python 3.10–3.13 on Linux/macOS/Windows),
|
|
33
|
-
package-integrity job (build + `twine check` + wheel smoke test), and a release
|
|
34
|
-
pipeline (build → PyPI via Trusted Publishing or `PYPI_API_TOKEN` → GitHub
|
|
35
|
-
Release) with SHA-pinned actions.
|
|
36
|
-
|
|
37
|
-
[Unreleased]: https://github.com/Shinraya-Tech-Labs/slopgate/compare/v0.1.0...HEAD
|
|
38
|
-
[0.1.0]: https://github.com/Shinraya-Tech-Labs/slopgate/releases/tag/v0.1.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|