slopgate-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slopgate/__init__.py +17 -0
- slopgate/__main__.py +6 -0
- slopgate/ast_tamper.py +267 -0
- slopgate/cli.py +330 -0
- slopgate/diff_parser.py +186 -0
- slopgate/engine.py +266 -0
- slopgate/fragment.py +102 -0
- slopgate/phantom.py +388 -0
- slopgate/reporter.py +205 -0
- slopgate_cli-0.1.0.dist-info/METADATA +289 -0
- slopgate_cli-0.1.0.dist-info/RECORD +15 -0
- slopgate_cli-0.1.0.dist-info/WHEEL +5 -0
- slopgate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- slopgate_cli-0.1.0.dist-info/licenses/LICENSE +178 -0
- slopgate_cli-0.1.0.dist-info/top_level.txt +1 -0
slopgate/phantom.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phantom Symbol & Hallucinated Import Detector.
|
|
3
|
+
Catches packages, modules, and API calls invented by LLMs that do not exist
|
|
4
|
+
in Python standard library, project manifests, or local repository files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional, Set
|
|
14
|
+
|
|
15
|
+
from slopgate.diff_parser import FileDiff
|
|
16
|
+
from slopgate.fragment import Fragment
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PhantomKind(str, Enum):
|
|
20
|
+
HALLUCINATED_IMPORT = "HALLUCINATED_IMPORT"
|
|
21
|
+
HALLUCINATED_STDLIB_METHOD = "HALLUCINATED_STDLIB_METHOD"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PhantomIncident:
|
|
26
|
+
kind: PhantomKind
|
|
27
|
+
file_path: str
|
|
28
|
+
line_number: int
|
|
29
|
+
symbol: str
|
|
30
|
+
message: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize(name: str) -> str:
|
|
34
|
+
"""Normalizes a distribution or module name for comparison (PEP 503-ish)."""
|
|
35
|
+
name = name.split("[")[0]
|
|
36
|
+
name = re.split(r"[<>=~!;\s]", name)[0]
|
|
37
|
+
return name.strip().replace("-", "_").replace(".", "_").lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PhantomDetector:
|
|
41
|
+
"""Detects phantom imports and hallucinated APIs added in diffs."""
|
|
42
|
+
|
|
43
|
+
# Python stdlib modules set (built-in in Python 3.10+)
|
|
44
|
+
STDLIB_MODULES: Set[str] = (
|
|
45
|
+
set(sys.stdlib_module_names)
|
|
46
|
+
if hasattr(sys, "stdlib_module_names")
|
|
47
|
+
else {
|
|
48
|
+
"os",
|
|
49
|
+
"sys",
|
|
50
|
+
"re",
|
|
51
|
+
"json",
|
|
52
|
+
"ast",
|
|
53
|
+
"math",
|
|
54
|
+
"time",
|
|
55
|
+
"datetime",
|
|
56
|
+
"typing",
|
|
57
|
+
"pathlib",
|
|
58
|
+
"collections",
|
|
59
|
+
"itertools",
|
|
60
|
+
"functools",
|
|
61
|
+
"unittest",
|
|
62
|
+
"logging",
|
|
63
|
+
"urllib",
|
|
64
|
+
"http",
|
|
65
|
+
"hashlib",
|
|
66
|
+
"subprocess",
|
|
67
|
+
"shutil",
|
|
68
|
+
"tempfile",
|
|
69
|
+
"io",
|
|
70
|
+
"string",
|
|
71
|
+
"copy",
|
|
72
|
+
"socket",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Import name != distribution name for these packages.
|
|
77
|
+
# shortcut: fixed alias table covers the common offenders, extend it when a
|
|
78
|
+
# false positive is reported.
|
|
79
|
+
IMPORT_ALIASES = {
|
|
80
|
+
"yaml": "pyyaml",
|
|
81
|
+
"cv2": "opencv_python",
|
|
82
|
+
"pil": "pillow",
|
|
83
|
+
"bs4": "beautifulsoup4",
|
|
84
|
+
"skimage": "scikit_image",
|
|
85
|
+
"sklearn": "scikit_learn",
|
|
86
|
+
"dateutil": "python_dateutil",
|
|
87
|
+
"dotenv": "python_dotenv",
|
|
88
|
+
"jwt": "pyjwt",
|
|
89
|
+
"serial": "pyserial",
|
|
90
|
+
"openssl": "pyopenssl",
|
|
91
|
+
"crypto": "pycryptodome",
|
|
92
|
+
"usb": "pyusb",
|
|
93
|
+
"attr": "attrs",
|
|
94
|
+
"gi": "pygobject",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Common hallucinated methods on standard library objects
|
|
98
|
+
KNOWN_STDLIB_HALLUCINATIONS = {
|
|
99
|
+
("json", "loads_async"): "json has no async loads method.",
|
|
100
|
+
("json", "parse"): "json uses .loads(), not .parse().",
|
|
101
|
+
("os", "mkdir_p"): "os uses os.makedirs(..., exist_ok=True).",
|
|
102
|
+
("re", "match_all"): "re uses .findall() or .finditer(), not .match_all().",
|
|
103
|
+
("time", "sleep_async"): "time has no sleep_async; use asyncio.sleep().",
|
|
104
|
+
(
|
|
105
|
+
"sys",
|
|
106
|
+
"print",
|
|
107
|
+
): "sys has no print method; use builtin print() or sys.stdout.write().",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def get_declared_dependencies(cls, repo_root: Optional[Path] = None) -> Set[str]:
|
|
112
|
+
"""Scans dependency manifests and local modules to extract valid import names."""
|
|
113
|
+
deps: Set[str] = set()
|
|
114
|
+
repo_root = repo_root or Path.cwd()
|
|
115
|
+
|
|
116
|
+
# 1. requirements.txt, requirements/*.txt, requirements-*.txt
|
|
117
|
+
req_files = list(repo_root.glob("*requirement*.txt")) + list(
|
|
118
|
+
repo_root.glob("requirements/*.txt")
|
|
119
|
+
)
|
|
120
|
+
for req_file in sorted(set(req_files)):
|
|
121
|
+
cls._read_requirements_file(req_file, deps)
|
|
122
|
+
|
|
123
|
+
# 2. setup.py install_requires=[...]
|
|
124
|
+
setup_py = repo_root / "setup.py"
|
|
125
|
+
if setup_py.exists():
|
|
126
|
+
try:
|
|
127
|
+
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)):
|
|
131
|
+
deps.add(_normalize(match.group(1)))
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
# 3. setup.cfg [options] install_requires
|
|
136
|
+
setup_cfg = repo_root / "setup.cfg"
|
|
137
|
+
if setup_cfg.exists():
|
|
138
|
+
try:
|
|
139
|
+
content = setup_cfg.read_text(encoding="utf-8", errors="ignore")
|
|
140
|
+
block = re.search(
|
|
141
|
+
r"(?ms)^install_requires\s*=\s*\n(.*?)(?=^\S|\Z)", content
|
|
142
|
+
)
|
|
143
|
+
if block:
|
|
144
|
+
cls._read_requirement_lines(block.group(1), deps)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# 4. environment.yml (conda)
|
|
149
|
+
for env_file in (repo_root / "environment.yml", repo_root / "environment.yaml"):
|
|
150
|
+
if not env_file.exists():
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
content = env_file.read_text(encoding="utf-8", errors="ignore")
|
|
154
|
+
for line in content.splitlines():
|
|
155
|
+
stripped = line.strip()
|
|
156
|
+
if stripped.startswith("- ") and "::" not in stripped:
|
|
157
|
+
deps.add(_normalize(stripped[2:]))
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# 5. Pipfile [packages] / [dev-packages]
|
|
162
|
+
pipfile = repo_root / "Pipfile"
|
|
163
|
+
if pipfile.exists():
|
|
164
|
+
try:
|
|
165
|
+
content = pipfile.read_text(encoding="utf-8", errors="ignore")
|
|
166
|
+
section = ""
|
|
167
|
+
for line in content.splitlines():
|
|
168
|
+
stripped = line.strip()
|
|
169
|
+
if stripped.startswith("["):
|
|
170
|
+
section = stripped.strip("[]").lower()
|
|
171
|
+
elif section in ("packages", "dev-packages") and "=" in stripped:
|
|
172
|
+
deps.add(_normalize(stripped.split("=")[0].strip(' "')))
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# 6. pyproject.toml: only dependency sections, not every quoted string
|
|
177
|
+
pyproject = repo_root / "pyproject.toml"
|
|
178
|
+
if pyproject.exists():
|
|
179
|
+
try:
|
|
180
|
+
cls._read_pyproject(
|
|
181
|
+
pyproject.read_text(encoding="utf-8", errors="ignore"), deps
|
|
182
|
+
)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
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
|
+
@classmethod
|
|
198
|
+
def _read_requirements_file(cls, path: Path, deps: Set[str], depth: int = 0):
|
|
199
|
+
"""Reads a requirements file, following `-r` includes one level deep."""
|
|
200
|
+
if depth > 5:
|
|
201
|
+
return
|
|
202
|
+
try:
|
|
203
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
204
|
+
except Exception:
|
|
205
|
+
return
|
|
206
|
+
for line in text.splitlines():
|
|
207
|
+
stripped = line.strip().split("#")[0].strip()
|
|
208
|
+
if not stripped:
|
|
209
|
+
continue
|
|
210
|
+
if stripped.startswith(("-r ", "--requirement ")):
|
|
211
|
+
included = stripped.split(None, 1)[1].strip().strip("'\"")
|
|
212
|
+
cls._read_requirements_file(path.parent / included, deps, depth + 1)
|
|
213
|
+
continue
|
|
214
|
+
if stripped.startswith("-") or "://" in stripped:
|
|
215
|
+
continue
|
|
216
|
+
deps.add(_normalize(stripped))
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def _read_requirement_lines(cls, text: str, deps: Set[str]):
|
|
220
|
+
for line in text.splitlines():
|
|
221
|
+
line = line.strip().split("#")[0]
|
|
222
|
+
if not line or line.startswith(("-", "http", "git+", "--")):
|
|
223
|
+
continue
|
|
224
|
+
pkg = _normalize(line)
|
|
225
|
+
if pkg:
|
|
226
|
+
deps.add(pkg)
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def _read_pyproject(cls, content: str, deps: Set[str]):
|
|
230
|
+
section = ""
|
|
231
|
+
in_deps = False
|
|
232
|
+
for line in content.splitlines():
|
|
233
|
+
stripped = line.strip()
|
|
234
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
235
|
+
section = stripped.strip("[]").strip()
|
|
236
|
+
in_deps = False
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
poetry_deps = section.startswith(
|
|
240
|
+
(
|
|
241
|
+
"tool.poetry.dependencies",
|
|
242
|
+
"tool.poetry.dev-dependencies",
|
|
243
|
+
"tool.poetry.group",
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
dependency_section = (
|
|
247
|
+
section
|
|
248
|
+
in (
|
|
249
|
+
"project.optional-dependencies",
|
|
250
|
+
"dependency-groups",
|
|
251
|
+
"build-system",
|
|
252
|
+
)
|
|
253
|
+
or poetry_deps
|
|
254
|
+
)
|
|
255
|
+
if re.match(r"^dependencies\s*=\s*\[", stripped):
|
|
256
|
+
in_deps = True
|
|
257
|
+
|
|
258
|
+
# Poetry declares packages as bare keys: requests = "^2.31"
|
|
259
|
+
if poetry_deps and "=" in stripped and not stripped.startswith("#"):
|
|
260
|
+
key = stripped.split("=")[0].strip().strip("\"'")
|
|
261
|
+
if key and key != "python":
|
|
262
|
+
deps.add(_normalize(key))
|
|
263
|
+
|
|
264
|
+
if in_deps or dependency_section:
|
|
265
|
+
for match in re.finditer(r'["\']([A-Za-z0-9_.\-]+)', stripped):
|
|
266
|
+
deps.add(_normalize(match.group(1)))
|
|
267
|
+
|
|
268
|
+
if in_deps and "]" in stripped:
|
|
269
|
+
in_deps = False
|
|
270
|
+
|
|
271
|
+
@classmethod
|
|
272
|
+
def analyze_file(
|
|
273
|
+
cls, file_diff: FileDiff, declared_deps: Set[str]
|
|
274
|
+
) -> List[PhantomIncident]:
|
|
275
|
+
incidents: List[PhantomIncident] = []
|
|
276
|
+
if not file_diff.is_python or file_diff.is_deleted:
|
|
277
|
+
return incidents
|
|
278
|
+
|
|
279
|
+
fragment = Fragment(file_diff)
|
|
280
|
+
if not fragment.text.strip():
|
|
281
|
+
return incidents
|
|
282
|
+
|
|
283
|
+
tree, offset = fragment.parse()
|
|
284
|
+
if tree is not None:
|
|
285
|
+
return cls._scan_tree(
|
|
286
|
+
tree,
|
|
287
|
+
file_diff,
|
|
288
|
+
lambda lineno: fragment.real_line(lineno, offset),
|
|
289
|
+
declared_deps,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Unparseable fragment (a hunk can start mid-expression): scan each added
|
|
293
|
+
# line on its own. Single-line strings parse as expressions, not calls.
|
|
294
|
+
# shortcut: statement-shaped prose inside a docstring of an unparseable
|
|
295
|
+
# hunk can still be flagged; upgrade to tokenize-based skipping if reported.
|
|
296
|
+
covered = fragment.string_lines()
|
|
297
|
+
for idx, (ln, line) in enumerate(file_diff.added_lines, start=1):
|
|
298
|
+
if idx in covered:
|
|
299
|
+
continue
|
|
300
|
+
try:
|
|
301
|
+
line_tree = ast.parse(line.strip())
|
|
302
|
+
except SyntaxError:
|
|
303
|
+
continue
|
|
304
|
+
incidents.extend(
|
|
305
|
+
cls._scan_tree(
|
|
306
|
+
line_tree, file_diff, lambda _lineno, ln=ln: ln, declared_deps
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
return incidents
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
def _scan_tree(cls, tree, file_diff, line_of, declared_deps: Set[str]):
|
|
313
|
+
incidents: List[PhantomIncident] = []
|
|
314
|
+
for node in ast.walk(tree):
|
|
315
|
+
# Check `import x`
|
|
316
|
+
if isinstance(node, ast.Import):
|
|
317
|
+
for alias in node.names:
|
|
318
|
+
top_pkg = alias.name.split(".")[0]
|
|
319
|
+
if not cls._is_known_package(top_pkg, declared_deps):
|
|
320
|
+
incidents.append(
|
|
321
|
+
PhantomIncident(
|
|
322
|
+
kind=PhantomKind.HALLUCINATED_IMPORT,
|
|
323
|
+
file_path=file_diff.target_path,
|
|
324
|
+
line_number=line_of(getattr(node, "lineno", 1)),
|
|
325
|
+
symbol=alias.name,
|
|
326
|
+
message=f"Package '{alias.name}' is not in stdlib, project dependencies, or local modules.",
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Check `from x import y`
|
|
331
|
+
elif isinstance(node, ast.ImportFrom):
|
|
332
|
+
# Relative imports (from . import x) always resolve inside the package
|
|
333
|
+
if node.module and not node.level:
|
|
334
|
+
top_pkg = node.module.split(".")[0]
|
|
335
|
+
if not cls._is_known_package(top_pkg, declared_deps):
|
|
336
|
+
incidents.append(
|
|
337
|
+
PhantomIncident(
|
|
338
|
+
kind=PhantomKind.HALLUCINATED_IMPORT,
|
|
339
|
+
file_path=file_diff.target_path,
|
|
340
|
+
line_number=line_of(getattr(node, "lineno", 1)),
|
|
341
|
+
symbol=node.module,
|
|
342
|
+
message=f"Module '{node.module}' is not in stdlib, project dependencies, or local modules.",
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Check hallucinated stdlib method calls (e.g. json.loads_async)
|
|
347
|
+
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
348
|
+
if isinstance(node.func.value, ast.Name):
|
|
349
|
+
mod_name = node.func.value.id
|
|
350
|
+
attr_name = node.func.attr
|
|
351
|
+
key = (mod_name, attr_name)
|
|
352
|
+
if key in cls.KNOWN_STDLIB_HALLUCINATIONS:
|
|
353
|
+
incidents.append(
|
|
354
|
+
PhantomIncident(
|
|
355
|
+
kind=PhantomKind.HALLUCINATED_STDLIB_METHOD,
|
|
356
|
+
file_path=file_diff.target_path,
|
|
357
|
+
line_number=line_of(getattr(node, "lineno", 1)),
|
|
358
|
+
symbol=f"{mod_name}.{attr_name}",
|
|
359
|
+
message=cls.KNOWN_STDLIB_HALLUCINATIONS[key],
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return incidents
|
|
364
|
+
|
|
365
|
+
@classmethod
|
|
366
|
+
def _is_known_package(cls, pkg_name: str, declared_deps: Set[str]) -> bool:
|
|
367
|
+
pkg_clean = _normalize(pkg_name)
|
|
368
|
+
if pkg_clean in cls.STDLIB_MODULES:
|
|
369
|
+
return True
|
|
370
|
+
if pkg_clean in declared_deps:
|
|
371
|
+
return True
|
|
372
|
+
alias = cls.IMPORT_ALIASES.get(pkg_clean)
|
|
373
|
+
if alias and alias in declared_deps:
|
|
374
|
+
return True
|
|
375
|
+
# Namespaced distributions: import `google.cloud.x` is served by
|
|
376
|
+
# `google-cloud-storage` (normalized prefix match).
|
|
377
|
+
if any(dep.startswith(pkg_clean + "_") for dep in declared_deps):
|
|
378
|
+
return True
|
|
379
|
+
# Common build / test tooling usually injected by CI
|
|
380
|
+
if pkg_clean in {
|
|
381
|
+
"pytest",
|
|
382
|
+
"unittest",
|
|
383
|
+
"setuptools",
|
|
384
|
+
"wheel",
|
|
385
|
+
"typing_extensions",
|
|
386
|
+
}:
|
|
387
|
+
return True
|
|
388
|
+
return False
|
slopgate/reporter.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-Channel Output Formatter.
|
|
3
|
+
Outputs human-friendly ANSI terminal displays, GitHub Step Summaries,
|
|
4
|
+
PR review comments, and structured JSON.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from slopgate.engine import AuditReport, Severity
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _color(code: str) -> str:
|
|
15
|
+
"""Disables ANSI when piping output or when NO_COLOR is set."""
|
|
16
|
+
if os.environ.get("NO_COLOR"):
|
|
17
|
+
return ""
|
|
18
|
+
stream = sys.stdout
|
|
19
|
+
return code if stream is not None and stream.isatty() else ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AnsiColor:
|
|
23
|
+
RESET = _color("\033[0m")
|
|
24
|
+
BOLD = _color("\033[1m")
|
|
25
|
+
RED = _color("\033[31m")
|
|
26
|
+
GREEN = _color("\033[32m")
|
|
27
|
+
YELLOW = _color("\033[33m")
|
|
28
|
+
CYAN = _color("\033[36m")
|
|
29
|
+
GRAY = _color("\033[90m")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _escape_data(value: str) -> str:
|
|
33
|
+
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _escape_property(value: str) -> str:
|
|
37
|
+
return _escape_data(value).replace(":", "%3A").replace(",", "%2C")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Reporter:
|
|
41
|
+
"""Formats AuditReport for CLI, GitHub Actions, and JSON pipelines."""
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def render_terminal(cls, report: AuditReport) -> str:
|
|
45
|
+
lines = []
|
|
46
|
+
# Header Box
|
|
47
|
+
lines.append(
|
|
48
|
+
f"{AnsiColor.CYAN}======================================================={AnsiColor.RESET}"
|
|
49
|
+
)
|
|
50
|
+
lines.append(
|
|
51
|
+
f"{AnsiColor.BOLD}{AnsiColor.CYAN} 🛡️ SLOPGATE — Code & PR Integrity Shield {AnsiColor.RESET}"
|
|
52
|
+
)
|
|
53
|
+
lines.append(
|
|
54
|
+
f"{AnsiColor.CYAN}======================================================={AnsiColor.RESET}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Metrics
|
|
58
|
+
m = report.metrics
|
|
59
|
+
lines.append(
|
|
60
|
+
f" {AnsiColor.GRAY}Diff:{AnsiColor.RESET} {m.files_changed} files | "
|
|
61
|
+
f"{AnsiColor.GREEN}+{m.total_added}{AnsiColor.RESET} / "
|
|
62
|
+
f"{AnsiColor.RED}-{m.total_removed}{AnsiColor.RESET} | "
|
|
63
|
+
f"Code: +{m.code_added} | Tests: +{m.test_added} | Docs: +{m.doc_added}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Status
|
|
67
|
+
if report.status == Severity.BLOCKED:
|
|
68
|
+
status_badge = f"{AnsiColor.BOLD}{AnsiColor.RED}[BLOCKED]{AnsiColor.RESET}"
|
|
69
|
+
elif report.status == Severity.WARNING:
|
|
70
|
+
status_badge = (
|
|
71
|
+
f"{AnsiColor.BOLD}{AnsiColor.YELLOW}[WARNING]{AnsiColor.RESET}"
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
status_badge = f"{AnsiColor.BOLD}{AnsiColor.GREEN}[PASSED]{AnsiColor.RESET}"
|
|
75
|
+
|
|
76
|
+
lines.append(f" Status: {status_badge} {report.summary}")
|
|
77
|
+
lines.append("")
|
|
78
|
+
|
|
79
|
+
# Violations Table
|
|
80
|
+
if report.violations:
|
|
81
|
+
lines.append(f"{AnsiColor.BOLD}Violations Detected:{AnsiColor.RESET}")
|
|
82
|
+
for v in report.violations:
|
|
83
|
+
color = (
|
|
84
|
+
AnsiColor.RED
|
|
85
|
+
if v.severity == Severity.BLOCKED
|
|
86
|
+
else AnsiColor.YELLOW
|
|
87
|
+
)
|
|
88
|
+
lines.append(
|
|
89
|
+
f" • {color}[{v.rule_id}]{AnsiColor.RESET} {AnsiColor.BOLD}{v.title}{AnsiColor.RESET}"
|
|
90
|
+
)
|
|
91
|
+
lines.append(f" Location: {v.file_path}:{v.line_number}")
|
|
92
|
+
lines.append(f" Details: {v.message}")
|
|
93
|
+
if v.snippet:
|
|
94
|
+
lines.append(
|
|
95
|
+
f" Snippet: {AnsiColor.GRAY}{v.snippet}{AnsiColor.RESET}"
|
|
96
|
+
)
|
|
97
|
+
lines.append("")
|
|
98
|
+
else:
|
|
99
|
+
lines.append(
|
|
100
|
+
f" {AnsiColor.GREEN}✨ Zero integrity violations found. Clean PR!{AnsiColor.RESET}\n"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
lines.append(
|
|
104
|
+
f"{AnsiColor.CYAN}-------------------------------------------------------{AnsiColor.RESET}"
|
|
105
|
+
)
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def render_github_markdown(cls, report: AuditReport) -> str:
|
|
110
|
+
"""Formats markdown for GitHub Step Summary and PR comments."""
|
|
111
|
+
if report.status == Severity.BLOCKED:
|
|
112
|
+
badge = ""
|
|
113
|
+
callout = "> [!CAUTION]\n> **PR Blocked by SlopGate**: Critical code/test integrity violations detected."
|
|
114
|
+
elif report.status == Severity.WARNING:
|
|
115
|
+
badge = ""
|
|
116
|
+
callout = "> [!WARNING]\n> **SlopGate Warning**: Non-critical suggestions detected."
|
|
117
|
+
else:
|
|
118
|
+
badge = ""
|
|
119
|
+
callout = "> [!NOTE]\n> **SlopGate Audit Passed**: All test assertions intact and symbols verified."
|
|
120
|
+
|
|
121
|
+
lines = [
|
|
122
|
+
f"# {badge}",
|
|
123
|
+
"",
|
|
124
|
+
callout,
|
|
125
|
+
"",
|
|
126
|
+
"### 📊 Diff Metrics",
|
|
127
|
+
"| Metric | Value |",
|
|
128
|
+
"| --- | --- |",
|
|
129
|
+
f"| **Files Changed** | `{report.metrics.files_changed}` |",
|
|
130
|
+
f"| **Logic Added** | `+{report.metrics.code_added}` lines |",
|
|
131
|
+
f"| **Tests Added** | `+{report.metrics.test_added}` lines |",
|
|
132
|
+
f"| **Docs / Comments** | `+{report.metrics.doc_added}` lines |",
|
|
133
|
+
f"| **Repro Test Verified** | `{'✅ Yes' if report.repro_verified else '⚠️ No'}` |",
|
|
134
|
+
"",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
if report.violations:
|
|
138
|
+
lines.extend(
|
|
139
|
+
[
|
|
140
|
+
"### 🔍 Integrity Findings",
|
|
141
|
+
"| Severity | Rule | File & Line | Message |",
|
|
142
|
+
"| :---: | :---: | :--- | :--- |",
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
for v in report.violations:
|
|
146
|
+
sev_icon = (
|
|
147
|
+
"⛔ `BLOCKED`" if v.severity == Severity.BLOCKED else "⚠️ `WARNING`"
|
|
148
|
+
)
|
|
149
|
+
lines.append(
|
|
150
|
+
f"| {sev_icon} | `{v.rule_id}` | `{v.file_path}:{v.line_number}` | {v.message} |"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
lines.append("")
|
|
154
|
+
lines.append("---")
|
|
155
|
+
lines.append(
|
|
156
|
+
"*Protected by [SlopGate](https://github.com/Shinraya-Tech-Labs/slopgate) — The AST Code Integrity Shield for Open Source.*"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return "\n".join(lines)
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def render_github_annotations(cls, report: AuditReport) -> str:
|
|
163
|
+
"""Formats GitHub Actions workflow command annotations (::error:: and ::warning::)."""
|
|
164
|
+
lines = []
|
|
165
|
+
for v in report.violations:
|
|
166
|
+
cmd = "error" if v.severity == Severity.BLOCKED else "warning"
|
|
167
|
+
props = (
|
|
168
|
+
f"file={_escape_property(v.file_path)},"
|
|
169
|
+
f"line={v.line_number},"
|
|
170
|
+
f"title={_escape_property(v.title)}"
|
|
171
|
+
)
|
|
172
|
+
lines.append(f"::{cmd} {props}::{_escape_data(v.message)}")
|
|
173
|
+
return "\n".join(lines)
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def render_json(cls, report: AuditReport) -> str:
|
|
177
|
+
data = {
|
|
178
|
+
"status": report.status.value,
|
|
179
|
+
"is_clean": report.is_clean,
|
|
180
|
+
"not_blocked": report.not_blocked,
|
|
181
|
+
"summary": report.summary,
|
|
182
|
+
"repro_verified": report.repro_verified,
|
|
183
|
+
"metrics": {
|
|
184
|
+
"files_changed": report.metrics.files_changed,
|
|
185
|
+
"total_added": report.metrics.total_added,
|
|
186
|
+
"total_removed": report.metrics.total_removed,
|
|
187
|
+
"code_added": report.metrics.code_added,
|
|
188
|
+
"test_added": report.metrics.test_added,
|
|
189
|
+
"doc_added": report.metrics.doc_added,
|
|
190
|
+
"slop_ratio": round(report.metrics.slop_ratio, 2),
|
|
191
|
+
},
|
|
192
|
+
"violations": [
|
|
193
|
+
{
|
|
194
|
+
"rule_id": v.rule_id,
|
|
195
|
+
"severity": v.severity.value,
|
|
196
|
+
"file_path": v.file_path,
|
|
197
|
+
"line_number": v.line_number,
|
|
198
|
+
"title": v.title,
|
|
199
|
+
"message": v.message,
|
|
200
|
+
"snippet": v.snippet,
|
|
201
|
+
}
|
|
202
|
+
for v in report.violations
|
|
203
|
+
],
|
|
204
|
+
}
|
|
205
|
+
return json.dumps(data, indent=2)
|