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/diff_parser.py
ADDED
|
@@ -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
|