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.
@@ -0,0 +1,186 @@
1
+ """
2
+ Git Unified Diff Parser.
3
+ Extracts modified files, hunks, line mappings, and added/removed fragments.
4
+ """
5
+
6
+ import ast
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import List, Optional, Tuple
11
+
12
+
13
+ @dataclass
14
+ class Hunk:
15
+ added: List[Tuple[int, str]] = field(default_factory=list)
16
+ removed: List[Tuple[int, str]] = field(default_factory=list)
17
+
18
+
19
+ @dataclass
20
+ class FileDiff:
21
+ old_path: Optional[str]
22
+ new_path: Optional[str]
23
+ is_deleted: bool = False
24
+ is_new: bool = False
25
+ added_lines: List[Tuple[int, str]] = field(default_factory=list)
26
+ removed_lines: List[Tuple[int, str]] = field(default_factory=list)
27
+ hunks: List[Hunk] = field(default_factory=list)
28
+ raw_diff: str = ""
29
+
30
+ @property
31
+ def target_path(self) -> str:
32
+ return self.new_path or self.old_path or "unknown"
33
+
34
+ @property
35
+ def is_python(self) -> bool:
36
+ return self.target_path.endswith(".py")
37
+
38
+ @property
39
+ def is_test_file(self) -> bool:
40
+ path = self.target_path.lower()
41
+ parts = Path(path).parts
42
+ 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")
46
+ )
47
+
48
+ @property
49
+ def is_doc_file(self) -> bool:
50
+ path = self.target_path.lower()
51
+ return path.endswith((".md", ".rst", ".txt", ".adoc", ".markdown"))
52
+
53
+
54
+ def _unquote(path: str) -> str:
55
+ """Git wraps paths with spaces or special characters in double quotes."""
56
+ quoted = len(path) >= 2 and path[0] == '"' and path[-1] == '"'
57
+ if not quoted:
58
+ return path
59
+ inner = path[1:-1]
60
+ try:
61
+ # Git uses C-style escapes; octal sequences are UTF-8 bytes.
62
+ decoded = ast.literal_eval('"' + inner + '"')
63
+ return decoded.encode("latin-1", "replace").decode("utf-8", "replace")
64
+ except (ValueError, SyntaxError, UnicodeEncodeError):
65
+ return inner
66
+
67
+
68
+ def _clean_path(path: str) -> Optional[str]:
69
+ path = _unquote(path.strip())
70
+ if path == "/dev/null":
71
+ return None
72
+ if path.startswith("a/") or path.startswith("b/"):
73
+ return path[2:]
74
+ return path
75
+
76
+
77
+ class DiffParser:
78
+ """Parses standard unified git diffs into structured FileDiff objects."""
79
+
80
+ HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
81
+
82
+ @classmethod
83
+ def _header_paths(cls, line: str) -> Tuple[Optional[str], Optional[str]]:
84
+ rest = line[len("diff --git ") :]
85
+ # Quoted tokens (spaces/C-escapes) stay intact so _unquote can decode them
86
+ parts = re.findall(r'"(?:[^"\\]|\\.)*"|\S+', rest)
87
+ if len(parts) < 2:
88
+ return None, None
89
+ return _clean_path(parts[0]), _clean_path(parts[1])
90
+
91
+ @classmethod
92
+ def parse(cls, diff_text: str) -> List[FileDiff]:
93
+ files: List[FileDiff] = []
94
+ current_file: Optional[FileDiff] = None
95
+ current_hunk: Optional[Hunk] = None
96
+ old_line = 0
97
+ new_line = 0
98
+
99
+ lines = diff_text.splitlines()
100
+ skip_combined = False
101
+ i = 0
102
+ while i < len(lines):
103
+ line = lines[i]
104
+
105
+ if line.startswith("diff --git "):
106
+ if current_file:
107
+ files.append(current_file)
108
+ old_p, new_p = cls._header_paths(line)
109
+ current_file = FileDiff(
110
+ old_path=old_p, new_path=new_p, raw_diff=line + "\n"
111
+ )
112
+ current_hunk = None
113
+ skip_combined = False
114
+ i += 1
115
+ continue
116
+
117
+ if current_file is None:
118
+ # Diff text without 'diff --git' header, try parsing '---' and '+++'
119
+ if line.startswith("--- "):
120
+ old_p = _clean_path(line[4:])
121
+ i += 1
122
+ new_p = None
123
+ if i < len(lines) and lines[i].startswith("+++ "):
124
+ new_p = _clean_path(lines[i][4:])
125
+ i += 1
126
+ current_file = FileDiff(old_path=old_p, new_path=new_p)
127
+ continue
128
+ i += 1
129
+ continue
130
+
131
+ current_file.raw_diff += line + "\n"
132
+
133
+ if line.startswith("@@@"):
134
+ # Combined (merge) diffs use two-column prefixes; parsing them as
135
+ # regular hunks would report garbage line numbers.
136
+ skip_combined = True
137
+ i += 1
138
+ continue
139
+ if skip_combined:
140
+ i += 1
141
+ continue
142
+
143
+ if line.startswith("new file mode"):
144
+ current_file.is_new = True
145
+ elif line.startswith("deleted file mode"):
146
+ current_file.is_deleted = True
147
+ elif line.startswith("--- "):
148
+ path = _clean_path(line[4:])
149
+ if path:
150
+ current_file.old_path = path
151
+ elif line.startswith("+++ "):
152
+ path = _clean_path(line[4:])
153
+ if path:
154
+ current_file.new_path = path
155
+ elif line.startswith("@@"):
156
+ m = cls.HUNK_RE.match(line)
157
+ if m:
158
+ old_line = int(m.group(1))
159
+ new_line = int(m.group(3))
160
+ current_hunk = Hunk()
161
+ current_file.hunks.append(current_hunk)
162
+ elif line.startswith("+") and not line.startswith("+++"):
163
+ if current_hunk is None:
164
+ # Fragments without hunk headers still need a bucket
165
+ current_hunk = Hunk()
166
+ current_file.hunks.append(current_hunk)
167
+ current_file.added_lines.append((new_line, line[1:]))
168
+ current_hunk.added.append((new_line, line[1:]))
169
+ new_line += 1
170
+ elif line.startswith("-") and not line.startswith("---"):
171
+ if current_hunk is None:
172
+ current_hunk = Hunk()
173
+ current_file.hunks.append(current_hunk)
174
+ current_file.removed_lines.append((old_line, line[1:]))
175
+ current_hunk.removed.append((old_line, line[1:]))
176
+ old_line += 1
177
+ elif line.startswith(" "):
178
+ old_line += 1
179
+ new_line += 1
180
+
181
+ i += 1
182
+
183
+ if current_file:
184
+ files.append(current_file)
185
+
186
+ return files
slopgate/engine.py ADDED
@@ -0,0 +1,266 @@
1
+ """
2
+ Core SlopGate Audit Engine.
3
+ Orchestrates AST tamper checks, phantom symbol detection, diff metrics,
4
+ and maintainer verdict generation.
5
+ """
6
+
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional, Set, Tuple
12
+
13
+ from slopgate.ast_tamper import AstTamperAnalyzer, TamperKind
14
+ from slopgate.diff_parser import DiffParser, FileDiff
15
+ from slopgate.phantom import PhantomDetector
16
+
17
+
18
+ class Severity(str, Enum):
19
+ INFO = "INFO"
20
+ WARNING = "WARNING"
21
+ BLOCKED = "BLOCKED"
22
+
23
+
24
+ @dataclass
25
+ class Violation:
26
+ rule_id: str
27
+ severity: Severity
28
+ file_path: str
29
+ line_number: int
30
+ title: str
31
+ message: str
32
+ snippet: Optional[str] = None
33
+
34
+
35
+ @dataclass
36
+ class DiffMetrics:
37
+ total_added: int = 0
38
+ total_removed: int = 0
39
+ code_added: int = 0
40
+ test_added: int = 0
41
+ doc_added: int = 0
42
+ files_changed: int = 0
43
+ slop_ratio: float = 0.0 # ratio of doc/comment lines to real logic lines
44
+ doc_heaviest_path: str = ""
45
+
46
+
47
+ @dataclass
48
+ class AuditReport:
49
+ status: Severity
50
+ violations: List[Violation] = field(default_factory=list)
51
+ metrics: DiffMetrics = field(default_factory=DiffMetrics)
52
+ repro_verified: bool = False
53
+ summary: str = ""
54
+
55
+ @property
56
+ def is_clean(self) -> bool:
57
+ return self.status == Severity.INFO
58
+
59
+ @property
60
+ def not_blocked(self) -> bool:
61
+ return self.status != Severity.BLOCKED
62
+
63
+
64
+ QUOTE_DELIMS = ('"""', "'''")
65
+
66
+
67
+ def classify_added_python_lines(lines: List[Tuple[int, str]]) -> Tuple[int, int]:
68
+ """Splits added Python lines into (doc/comment, executable) counts.
69
+
70
+ Tracks multi-line block strings so docstring bodies count as docs, not code.
71
+ """
72
+ doc = 0
73
+ code = 0
74
+ block: Optional[str] = None
75
+ for _, line in lines:
76
+ stripped = line.strip()
77
+ if block:
78
+ doc += 1
79
+ if block in stripped:
80
+ block = None
81
+ continue
82
+ if not stripped:
83
+ continue
84
+ if stripped.startswith("#"):
85
+ doc += 1
86
+ continue
87
+ # Drop a string prefix (r""" / f""" / rb""") before delimiter detection
88
+ literal = re.sub(r"^[rRbBuUfF]{0,2}(?=['\"])", "", stripped)
89
+ for delim in QUOTE_DELIMS:
90
+ if literal.startswith(delim):
91
+ doc += 1
92
+ if literal.count(delim) == 1:
93
+ block = delim
94
+ break
95
+ else:
96
+ code += 1
97
+ return doc, code
98
+
99
+
100
+ class AuditEngine:
101
+ """Orchestrates all SlopGate security and code integrity checks."""
102
+
103
+ def __init__(self, repo_root: Optional[Path] = None):
104
+ self.repo_root = repo_root or Path.cwd()
105
+ self.declared_deps: Set[str] = PhantomDetector.get_declared_dependencies(
106
+ self.repo_root
107
+ )
108
+
109
+ def audit_diff(self, diff_text: str) -> AuditReport:
110
+ files = DiffParser.parse(diff_text)
111
+ violations: List[Violation] = []
112
+ metrics = self._compute_metrics(files)
113
+
114
+ has_code_change = False
115
+ has_test_change = False
116
+ tampered_test = False
117
+
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:
121
+ has_test_change = True
122
+ # Run Test Tampering Inspection
123
+ incidents = AstTamperAnalyzer.analyze_file(f)
124
+ for inc in incidents:
125
+ tampered_test = True
126
+ sev = (
127
+ Severity.BLOCKED
128
+ if inc.kind
129
+ in (
130
+ TamperKind.ASSERT_DELETED,
131
+ TamperKind.TRY_EXCEPT_BYPASS,
132
+ TamperKind.TEST_SKIPPED,
133
+ )
134
+ else Severity.WARNING
135
+ )
136
+
137
+ violations.append(
138
+ Violation(
139
+ rule_id=f"SG-{inc.kind.value}",
140
+ severity=sev,
141
+ file_path=inc.file_path,
142
+ line_number=inc.line_number,
143
+ title=inc.kind.value.replace("_", " ").title(),
144
+ message=inc.message,
145
+ snippet=inc.snippet,
146
+ )
147
+ )
148
+ elif f.is_python and f.added_lines:
149
+ has_code_change = True
150
+
151
+ # Run Phantom Symbol Inspection on all Python files
152
+ if f.is_python:
153
+ phantoms = PhantomDetector.analyze_file(f, self.declared_deps)
154
+ for ph in phantoms:
155
+ violations.append(
156
+ Violation(
157
+ rule_id=f"SG-{ph.kind.value}",
158
+ severity=Severity.BLOCKED,
159
+ file_path=ph.file_path,
160
+ line_number=ph.line_number,
161
+ title=ph.kind.value.replace("_", " ").title(),
162
+ message=ph.message,
163
+ snippet=f"symbol: {ph.symbol}",
164
+ )
165
+ )
166
+
167
+ # Repro Test Check: a test file must have been touched and not tampered with
168
+ repro_verified = has_code_change and has_test_change and not tampered_test
169
+ if has_code_change and not repro_verified:
170
+ changed_prod = next(
171
+ (
172
+ f
173
+ for f in files
174
+ if f.is_python
175
+ and not f.is_test_file
176
+ and not f.is_deleted
177
+ and f.added_lines
178
+ ),
179
+ None,
180
+ )
181
+ violations.append(
182
+ Violation(
183
+ rule_id="SG-MISSING_TEST_REPRO",
184
+ severity=Severity.WARNING,
185
+ file_path=changed_prod.target_path if changed_prod else "diff",
186
+ line_number=1,
187
+ title="Missing Reproduction Test",
188
+ message="PR modifies production logic without adding a trustworthy test case.",
189
+ snippet=None,
190
+ )
191
+ )
192
+
193
+ # Slop Ratio Check (e.g. 50 lines of explanatory comments with 1 line of logic)
194
+ if (
195
+ metrics.slop_ratio > 4.0
196
+ and metrics.doc_added >= 30
197
+ and 0 < metrics.code_added < 5
198
+ ):
199
+ violations.append(
200
+ Violation(
201
+ rule_id="SG-SLOP_BLOAT",
202
+ severity=Severity.WARNING,
203
+ file_path=metrics.doc_heaviest_path or "diff",
204
+ line_number=1,
205
+ title="Disproportionate Comment/Doc Bloat",
206
+ message=f"PR contains high slop-to-logic ratio ({metrics.slop_ratio:.1f}x doc additions vs executable code).",
207
+ snippet=f"+{metrics.doc_added} doc lines vs +{metrics.code_added} code lines",
208
+ )
209
+ )
210
+
211
+ # Determine overall status
212
+ status = Severity.INFO
213
+ if any(v.severity == Severity.BLOCKED for v in violations):
214
+ status = Severity.BLOCKED
215
+ elif any(v.severity == Severity.WARNING for v in violations):
216
+ status = Severity.WARNING
217
+
218
+ summary = self._generate_summary(status, violations)
219
+ return AuditReport(
220
+ status=status,
221
+ violations=violations,
222
+ metrics=metrics,
223
+ repro_verified=repro_verified,
224
+ summary=summary,
225
+ )
226
+
227
+ def _compute_metrics(self, files: List[FileDiff]) -> DiffMetrics:
228
+ metrics = DiffMetrics(files_changed=len(files))
229
+ doc_by_file: Dict[str, int] = {}
230
+ for f in files:
231
+ added = len(f.added_lines)
232
+ removed = len(f.removed_lines)
233
+ metrics.total_added += added
234
+ metrics.total_removed += removed
235
+
236
+ if f.is_test_file:
237
+ metrics.test_added += added
238
+ elif f.is_doc_file:
239
+ metrics.doc_added += added
240
+ doc_by_file[f.target_path] = added
241
+ elif f.is_python:
242
+ doc, code = classify_added_python_lines(f.added_lines)
243
+ metrics.doc_added += doc
244
+ metrics.code_added += code
245
+ doc_by_file[f.target_path] = doc
246
+
247
+ if doc_by_file:
248
+ metrics.doc_heaviest_path = max(doc_by_file, key=lambda p: doc_by_file[p])
249
+
250
+ if metrics.code_added > 0:
251
+ metrics.slop_ratio = metrics.doc_added / metrics.code_added
252
+ else:
253
+ metrics.slop_ratio = float(metrics.doc_added)
254
+
255
+ return metrics
256
+
257
+ def _generate_summary(self, status: Severity, violations: List[Violation]) -> str:
258
+ if status == Severity.INFO and not violations:
259
+ return "✅ SlopGate Passed: All test assertions intact, no hallucinated symbols detected."
260
+
261
+ blocked_count = sum(1 for v in violations if v.severity == Severity.BLOCKED)
262
+ warning_count = sum(1 for v in violations if v.severity == Severity.WARNING)
263
+
264
+ if status == Severity.BLOCKED:
265
+ return f"⛔ SlopGate Blocked: Found {blocked_count} critical integrity violation(s) (test tampering / phantom symbols)."
266
+ return f"⚠️ SlopGate Warning: Found {warning_count} non-blocking notice(s)."
slopgate/fragment.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ Partial AST for the added lines of a diff.
3
+
4
+ Diff fragments are rarely valid modules (a hunk can start mid-function), so the
5
+ parser retries the fragment wrapped in a stub function and reports the line
6
+ offset so callers can map AST line numbers back to real file lines.
7
+ """
8
+
9
+ import ast
10
+ import textwrap
11
+ from typing import List, Optional, Set, Tuple
12
+
13
+ from slopgate.diff_parser import FileDiff
14
+
15
+ QUOTE_DELIMS = ('"""', "'''")
16
+
17
+
18
+ def _lexical_string_lines(text: str) -> Set[int]:
19
+ """Line ranges of complete triple-quoted strings in unparseable text.
20
+
21
+ Only spans with a matching close count, so a hunk starting on a docstring
22
+ *closing* line cannot swallow the rest of the hunk.
23
+ """
24
+ covered: Set[int] = set()
25
+ opened: Optional[str] = None
26
+ opened_at = 0
27
+ for lineno, line in enumerate(text.splitlines(), start=1):
28
+ for delim in QUOTE_DELIMS:
29
+ start = 0
30
+ while True:
31
+ pos = line.find(delim, start)
32
+ if pos < 0:
33
+ break
34
+ start = pos + 3
35
+ if pos > 0 and line[pos - 1] == "\\":
36
+ continue
37
+ if opened is None:
38
+ opened, opened_at = delim, lineno
39
+ elif opened == delim:
40
+ covered.update(range(opened_at, lineno + 1))
41
+ opened = None
42
+ return covered
43
+
44
+
45
+ class Fragment:
46
+ def __init__(self, file_diff: FileDiff):
47
+ self.text = "\n".join(line for _, line in file_diff.added_lines)
48
+ self.line_map: List[int] = [ln for ln, _ in file_diff.added_lines]
49
+
50
+ @classmethod
51
+ def from_lines(cls, lines: List[Tuple[int, str]]) -> "Fragment":
52
+ fragment = cls.__new__(cls)
53
+ fragment.text = "\n".join(line for _, line in lines)
54
+ fragment.line_map = [ln for ln, _ in lines]
55
+ return fragment
56
+
57
+ def real_line(self, lineno: int, offset: int = 0) -> int:
58
+ """Maps an AST line number inside the fragment to the real file line."""
59
+ idx = lineno - 1 - offset
60
+ if 0 <= idx < len(self.line_map):
61
+ return self.line_map[idx]
62
+ return self.line_map[0] if self.line_map else 1
63
+
64
+ def parse(self) -> Tuple[Optional[ast.Module], int]:
65
+ """Returns (tree, offset). offset is 1 when the stub wrapper was added."""
66
+ dedented = textwrap.dedent(self.text)
67
+ try:
68
+ return ast.parse(dedented), 0
69
+ except SyntaxError:
70
+ pass
71
+ try:
72
+ return ast.parse(
73
+ "def _fragment():\n" + textwrap.indent(dedented, " ")
74
+ ), 1
75
+ except SyntaxError:
76
+ return None, 0
77
+
78
+ def string_lines(
79
+ self, tree: Optional[ast.Module] = None, offset: Optional[int] = None
80
+ ) -> Set[int]:
81
+ """Fragment line numbers (1-based) sitting inside a multi-line string.
82
+
83
+ Raw-line fallbacks use this so docstrings and fixtures are not scanned
84
+ for API calls or assertions. Empty when the fragment does not parse.
85
+ """
86
+ if tree is None:
87
+ tree, parsed_offset = self.parse()
88
+ offset = parsed_offset
89
+ offset = offset or 0
90
+ if tree is None:
91
+ return _lexical_string_lines(self.text)
92
+
93
+ covered: Set[int] = set()
94
+ for node in ast.walk(tree):
95
+ is_str = (
96
+ isinstance(node, ast.Constant) and isinstance(node.value, str)
97
+ ) or isinstance(node, ast.JoinedStr)
98
+ begin = getattr(node, "lineno", 0) - offset
99
+ end = getattr(node, "end_lineno", begin) - offset
100
+ if is_str and end > begin:
101
+ covered.update(range(begin, end + 1))
102
+ return covered