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/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 = "![SlopGate Status](https://img.shields.io/badge/SlopGate-BLOCKED-red?style=for-the-badge)"
113
+ callout = "> [!CAUTION]\n> **PR Blocked by SlopGate**: Critical code/test integrity violations detected."
114
+ elif report.status == Severity.WARNING:
115
+ badge = "![SlopGate Status](https://img.shields.io/badge/SlopGate-WARNING-yellow?style=for-the-badge)"
116
+ callout = "> [!WARNING]\n> **SlopGate Warning**: Non-critical suggestions detected."
117
+ else:
118
+ badge = "![SlopGate Status](https://img.shields.io/badge/SlopGate-PASSED-brightgreen?style=for-the-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)