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.
Files changed (34) hide show
  1. slopgate_cli-0.1.1/CHANGELOG.md +80 -0
  2. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/PKG-INFO +3 -2
  3. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/README.md +1 -1
  4. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/pyproject.toml +2 -1
  5. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/__init__.py +1 -1
  6. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/ast_tamper.py +35 -40
  7. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/cli.py +72 -29
  8. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/diff_parser.py +6 -3
  9. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/engine.py +16 -2
  10. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/fragment.py +3 -1
  11. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/phantom.py +108 -17
  12. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/PKG-INFO +3 -2
  13. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/test_cli.py +25 -1
  14. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/test_engine.py +12 -4
  15. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/test_phantom.py +85 -0
  16. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/test_tamper.py +44 -3
  17. slopgate_cli-0.1.0/CHANGELOG.md +0 -38
  18. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/CONTRIBUTING.md +0 -0
  19. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/LICENSE +0 -0
  20. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/MANIFEST.in +0 -0
  21. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/SECURITY.md +0 -0
  22. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/setup.cfg +0 -0
  23. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/__main__.py +0 -0
  24. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate/reporter.py +0 -0
  25. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/SOURCES.txt +0 -0
  26. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/dependency_links.txt +0 -0
  27. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/entry_points.txt +0 -0
  28. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/requires.txt +0 -0
  29. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/slopgate_cli.egg-info/top_level.txt +0 -0
  30. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/fixtures/genuine_bugfix.diff +0 -0
  31. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/fixtures/neutered_test.diff +0 -0
  32. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/fixtures/phantom_import.diff +0 -0
  33. {slopgate_cli-0.1.0 → slopgate_cli-0.1.1}/tests/fixtures/slop_doc_bloat.diff +0 -0
  34. {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.0
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.1.0
200
+ uses: Shinraya-Tech-Labs/slopgate@v0
200
201
  with:
201
202
  fail-on: blocked # 'blocked' (fails on tamper/hallucinations) or 'warning'
202
203
  ```
@@ -168,7 +168,7 @@ jobs:
168
168
  fetch-depth: 0
169
169
 
170
170
  - name: Run SlopGate Shield
171
- uses: Shinraya-Tech-Labs/slopgate@v0.1.0
171
+ uses: Shinraya-Tech-Labs/slopgate@v0
172
172
  with:
173
173
  fail-on: blocked # 'blocked' (fails on tamper/hallucinations) or 'warning'
174
174
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slopgate-cli"
7
- version = "0.1.0"
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.0"
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, Hunk
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 = {"pytest.skip", "pytest.mark.skip", "unittest.skip"}
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|pytest\.skip)\b"
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
- """A hunk that drops assertions without replacing them is a tamper signal.
99
+ """Net assertion loss across the file is a tamper signal.
92
100
 
93
- Comparing per hunk (not per file) stops an unrelated new assertion in a
94
- different test from masking a deletion.
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._check_hunk_assertions(file_diff, hunk, incidents)
109
+ removed_asserts.extend(cls._assert_lines(hunk.removed))
110
+ added_count += len(cls._assert_lines(hunk.added))
100
111
 
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)
112
+ net_loss = len(removed_asserts) - added_count
109
113
  if net_loss <= 0:
110
114
  return
111
- for ln, line in removed_asserts[:net_loss]:
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) in this hunk without equivalent replacement.",
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
- 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)
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
- 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
- )
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
- GITHUB_WORKFLOW_TEMPLATE = f"""name: SlopGate PR Integrity Check
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@v{__version__}
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
- ["git", "diff", "HEAD~1"],
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
- 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
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: 2 rogue AI PRs intercepted, 1 clean bugfix approved.{AnsiColor.RESET}\n"
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
- or Path(path).name.startswith("test_")
45
- or Path(path).name.endswith("_test.py")
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
- # A deleted test file is not a reproduction test
120
- if f.is_test_file and not f.is_deleted:
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
- block = re.search(r"install_requires\s*=\s*\[(.*?)\]", content, re.S)
129
- if block:
130
- for match in re.finditer(r'["\']([^"\']+)["\']', block.group(1)):
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.0
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.1.0
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 f"Shinraya-Tech-Labs/slopgate@v{cli.__version__}" in text
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.WARNING # Assertion weakened
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
- report = AuditEngine().audit_diff(SAMPLE_TAMPERED_DIFF)
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 any(v.rule_id == "SG-MISSING_TEST_REPRO" for v in report.violations)
107
- assert report.status != Severity.INFO
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 test_assert_deletion_not_masked_by_other_hunk():
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
- incidents = _incidents(diff_text)
37
- assert any(inc.kind == TamperKind.ASSERT_DELETED for inc in incidents)
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))
@@ -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