diffimpactscout 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.
- diffimpactscout/__init__.py +3 -0
- diffimpactscout/__main__.py +8 -0
- diffimpactscout/base.py +95 -0
- diffimpactscout/checks/__init__.py +20 -0
- diffimpactscout/checks/base.py +206 -0
- diffimpactscout/checks/eslint.py +164 -0
- diffimpactscout/checks/hygiene.py +104 -0
- diffimpactscout/checks/prettier.py +103 -0
- diffimpactscout/checks/repo_checks.py +95 -0
- diffimpactscout/checks/ruff.py +210 -0
- diffimpactscout/checks/syntax.py +133 -0
- diffimpactscout/cli.py +220 -0
- diffimpactscout/config.py +205 -0
- diffimpactscout/env.py +29 -0
- diffimpactscout/gitrun.py +68 -0
- diffimpactscout/guard.py +116 -0
- diffimpactscout/impact/__init__.py +1 -0
- diffimpactscout/impact/cache.py +55 -0
- diffimpactscout/impact/diff_parser.py +181 -0
- diffimpactscout/impact/impact.py +345 -0
- diffimpactscout/impact/python_analyzer.py +284 -0
- diffimpactscout/impact/reporter.py +107 -0
- diffimpactscout/impact/route_linker.py +363 -0
- diffimpactscout/launcher.py +108 -0
- diffimpactscout/profiles/django.json +14 -0
- diffimpactscout/profiles/fastapi.json +14 -0
- diffimpactscout/profiles/plain.json +8 -0
- diffimpactscout/scope.py +210 -0
- diffimpactscout-0.1.0.dist-info/METADATA +314 -0
- diffimpactscout-0.1.0.dist-info/RECORD +34 -0
- diffimpactscout-0.1.0.dist-info/WHEEL +5 -0
- diffimpactscout-0.1.0.dist-info/entry_points.txt +2 -0
- diffimpactscout-0.1.0.dist-info/licenses/LICENSE +21 -0
- diffimpactscout-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Prettier incremental check: flags files the developer changed whose formatting differs from prettier.
|
|
2
|
+
|
|
3
|
+
Example: a dev-changed .ts file that prettier would reformat produces an
|
|
4
|
+
issue and a hint to run prettier on it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from diffimpactscout.checks.base import (
|
|
11
|
+
Check,
|
|
12
|
+
CheckIssue,
|
|
13
|
+
CheckResult,
|
|
14
|
+
register,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_PRETTIER_EXTS = (".ts", ".js", ".html", ".css", ".scss", ".json")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_npx(argv, root):
|
|
21
|
+
proc = subprocess.Popen(
|
|
22
|
+
argv,
|
|
23
|
+
cwd=root,
|
|
24
|
+
stdout=subprocess.PIPE,
|
|
25
|
+
stderr=subprocess.PIPE,
|
|
26
|
+
)
|
|
27
|
+
out, err = proc.communicate()
|
|
28
|
+
return proc, out, err
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_baseline(root):
|
|
32
|
+
path = os.path.join(root, ".prettierignore_baseline")
|
|
33
|
+
try:
|
|
34
|
+
with open(path, "r") as fh:
|
|
35
|
+
content = fh.read()
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
return set()
|
|
38
|
+
entries = set()
|
|
39
|
+
for line in content.split("\n"):
|
|
40
|
+
line = line.strip()
|
|
41
|
+
if not line:
|
|
42
|
+
continue
|
|
43
|
+
if line.startswith("./"):
|
|
44
|
+
line = line[2:]
|
|
45
|
+
entries.add(line)
|
|
46
|
+
return entries
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dev_changed(context, path):
|
|
50
|
+
try:
|
|
51
|
+
return bool(
|
|
52
|
+
context.scope.contains(
|
|
53
|
+
context.anchor, path, context.from_ref, context.to_ref
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
except Exception:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@register
|
|
61
|
+
class PrettierCheck(Check):
|
|
62
|
+
id = "prettier"
|
|
63
|
+
scoped = "files"
|
|
64
|
+
|
|
65
|
+
def run(self, context, files):
|
|
66
|
+
issues = []
|
|
67
|
+
warned = []
|
|
68
|
+
baseline = _load_baseline(context.root)
|
|
69
|
+
for path in files or []:
|
|
70
|
+
if not path.endswith(_PRETTIER_EXTS):
|
|
71
|
+
continue
|
|
72
|
+
parts = path.split("/")
|
|
73
|
+
if "src" not in parts and "app" not in parts:
|
|
74
|
+
continue
|
|
75
|
+
if path in baseline and not _dev_changed(context, path):
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
proc, _out, _err = _run_npx(
|
|
79
|
+
["npx", "prettier", "--check", path],
|
|
80
|
+
context.root,
|
|
81
|
+
)
|
|
82
|
+
except OSError as exc:
|
|
83
|
+
warned.append("npx prettier unavailable: %s" % exc)
|
|
84
|
+
break
|
|
85
|
+
if proc.returncode == 0:
|
|
86
|
+
continue
|
|
87
|
+
if proc.returncode != 1:
|
|
88
|
+
warned.append(
|
|
89
|
+
"npx prettier --check %s exited with code %s"
|
|
90
|
+
% (path, proc.returncode)
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
issues.append(
|
|
94
|
+
CheckIssue(
|
|
95
|
+
path,
|
|
96
|
+
0,
|
|
97
|
+
0,
|
|
98
|
+
self.id,
|
|
99
|
+
"%s is not formatted. Fix with: npx prettier --write %s"
|
|
100
|
+
% (path, path),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return CheckResult(issues=issues, warned=warned)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Repo-wide checks for oversized files and leaked private keys.
|
|
2
|
+
|
|
3
|
+
Example: a 300 MB binary is flagged with a git-lfs hint, and any file
|
|
4
|
+
containing a private key block always blocks the push.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from diffimpactscout.checks.base import (
|
|
10
|
+
Check,
|
|
11
|
+
CheckIssue,
|
|
12
|
+
CheckResult,
|
|
13
|
+
register,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
PRIVATE_KEY_MARKERS = (
|
|
17
|
+
b"-----BEGIN RSA PRIVATE KEY-----",
|
|
18
|
+
b"-----BEGIN PRIVATE KEY-----",
|
|
19
|
+
b"-----BEGIN EC PRIVATE KEY-----",
|
|
20
|
+
b"-----BEGIN OPENSSH PRIVATE KEY-----",
|
|
21
|
+
b"-----BEGIN DSA PRIVATE KEY-----",
|
|
22
|
+
b"-----BEGIN PGP PRIVATE KEY BLOCK-----",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@register
|
|
27
|
+
class LargeFilesCheck(Check):
|
|
28
|
+
id = "repo/large-files"
|
|
29
|
+
scoped = "files"
|
|
30
|
+
max_kb = 250000
|
|
31
|
+
|
|
32
|
+
def extend_config(self, entry):
|
|
33
|
+
super(LargeFilesCheck, self).extend_config(entry)
|
|
34
|
+
max_kb = 250000
|
|
35
|
+
for arg in self.args:
|
|
36
|
+
if arg.startswith("--maxkb"):
|
|
37
|
+
if not arg.startswith("--maxkb="):
|
|
38
|
+
raise ValueError("--maxkb must be --maxkb=<positive int>")
|
|
39
|
+
value = arg[len("--maxkb="):]
|
|
40
|
+
try:
|
|
41
|
+
max_kb = int(value)
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise ValueError("invalid --maxkb value: %r" % value)
|
|
44
|
+
if max_kb <= 0:
|
|
45
|
+
raise ValueError("--maxkb must be a positive integer")
|
|
46
|
+
self.max_kb = max_kb
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def run(self, context, files):
|
|
50
|
+
issues = []
|
|
51
|
+
for path in files or []:
|
|
52
|
+
fn = os.path.join(context.root, path)
|
|
53
|
+
try:
|
|
54
|
+
if not os.path.exists(fn):
|
|
55
|
+
continue
|
|
56
|
+
size = os.path.getsize(fn)
|
|
57
|
+
except OSError:
|
|
58
|
+
continue
|
|
59
|
+
if size > self.max_kb * 1024:
|
|
60
|
+
size_kb = size // 1024
|
|
61
|
+
issues.append(
|
|
62
|
+
CheckIssue(
|
|
63
|
+
path,
|
|
64
|
+
0,
|
|
65
|
+
0,
|
|
66
|
+
self.id,
|
|
67
|
+
"file size %d kB exceeds limit of %d kB; consider git lfs"
|
|
68
|
+
% (size_kb, self.max_kb),
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return CheckResult(issues=issues)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@register
|
|
75
|
+
class PrivateKeyCheck(Check):
|
|
76
|
+
id = "repo/private-key"
|
|
77
|
+
scoped = "files"
|
|
78
|
+
always_block = True
|
|
79
|
+
|
|
80
|
+
def run(self, context, files):
|
|
81
|
+
issues = []
|
|
82
|
+
for path in files or []:
|
|
83
|
+
fn = os.path.join(context.root, path)
|
|
84
|
+
try:
|
|
85
|
+
with open(fn, "rb") as fh:
|
|
86
|
+
data = fh.read()
|
|
87
|
+
except OSError:
|
|
88
|
+
continue
|
|
89
|
+
for marker in PRIVATE_KEY_MARKERS:
|
|
90
|
+
if marker in data:
|
|
91
|
+
issues.append(
|
|
92
|
+
CheckIssue(path, 0, 0, self.id, "private key detected")
|
|
93
|
+
)
|
|
94
|
+
break
|
|
95
|
+
return CheckResult(issues=issues)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Ruff and ruff-format incremental checks scoped to the developer's changed lines.
|
|
2
|
+
|
|
3
|
+
Example: an E501 on a line the developer added blocks the push, while an
|
|
4
|
+
E501 on an untouched upstream line does not.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
from diffimpactscout.checks.base import (
|
|
12
|
+
Check,
|
|
13
|
+
CheckIssue,
|
|
14
|
+
CheckResult,
|
|
15
|
+
register,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _truncate(text, limit=30):
|
|
22
|
+
lines = text.split("\n")
|
|
23
|
+
if len(lines) <= limit:
|
|
24
|
+
return text
|
|
25
|
+
return "\n".join(lines[:limit]) + "\n... (%d more lines)" % (
|
|
26
|
+
len(lines) - limit
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _changed_lines(context, path):
|
|
31
|
+
try:
|
|
32
|
+
tracked = context.is_tracked(path)
|
|
33
|
+
changed = context.changed_lines(path)
|
|
34
|
+
except Exception:
|
|
35
|
+
return set()
|
|
36
|
+
if changed is not None:
|
|
37
|
+
return changed
|
|
38
|
+
if tracked:
|
|
39
|
+
return set()
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _filter_diff_by_lines(diff, changed):
|
|
44
|
+
if changed is None:
|
|
45
|
+
return diff
|
|
46
|
+
if not changed:
|
|
47
|
+
return ""
|
|
48
|
+
lines = diff.split("\n")
|
|
49
|
+
header = []
|
|
50
|
+
i = 0
|
|
51
|
+
while i < len(lines) and (
|
|
52
|
+
lines[i].startswith("--- ") or lines[i].startswith("+++ ")
|
|
53
|
+
):
|
|
54
|
+
header.append(lines[i])
|
|
55
|
+
i += 1
|
|
56
|
+
out = list(header)
|
|
57
|
+
body = "\n".join(lines[i:])
|
|
58
|
+
for hunk in re.split(r"(?=^@@)", body, flags=re.MULTILINE):
|
|
59
|
+
if not hunk.startswith("@@"):
|
|
60
|
+
continue
|
|
61
|
+
m = _HUNK_RE.match(hunk)
|
|
62
|
+
if not m:
|
|
63
|
+
continue
|
|
64
|
+
old_ln = int(m.group(1))
|
|
65
|
+
block = []
|
|
66
|
+
block_old_lines = []
|
|
67
|
+
found = []
|
|
68
|
+
for ln in hunk.split("\n")[1:]:
|
|
69
|
+
if ln.startswith("-"):
|
|
70
|
+
block.append(ln)
|
|
71
|
+
block_old_lines.append(old_ln)
|
|
72
|
+
old_ln += 1
|
|
73
|
+
elif ln.startswith("+"):
|
|
74
|
+
block.append(ln)
|
|
75
|
+
else:
|
|
76
|
+
if block and any(o in changed for o in block_old_lines):
|
|
77
|
+
found.extend(block)
|
|
78
|
+
block = []
|
|
79
|
+
block_old_lines = []
|
|
80
|
+
if ln.startswith(" "):
|
|
81
|
+
old_ln += 1
|
|
82
|
+
if block and any(o in changed for o in block_old_lines):
|
|
83
|
+
found.extend(block)
|
|
84
|
+
if found:
|
|
85
|
+
out.append(hunk.split("\n")[0])
|
|
86
|
+
out.extend(found)
|
|
87
|
+
if len(out) > len(header):
|
|
88
|
+
return "\n".join(out)
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run_ruff(argv, root):
|
|
93
|
+
proc = subprocess.Popen(
|
|
94
|
+
argv,
|
|
95
|
+
cwd=root,
|
|
96
|
+
stdout=subprocess.PIPE,
|
|
97
|
+
stderr=subprocess.PIPE,
|
|
98
|
+
)
|
|
99
|
+
out, err = proc.communicate()
|
|
100
|
+
return proc, out, err
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@register
|
|
104
|
+
class RuffCheck(Check):
|
|
105
|
+
id = "ruff"
|
|
106
|
+
scoped = "lines"
|
|
107
|
+
|
|
108
|
+
def run(self, context, files):
|
|
109
|
+
issues = []
|
|
110
|
+
warned = []
|
|
111
|
+
for path in files or []:
|
|
112
|
+
if not path.endswith(".py"):
|
|
113
|
+
continue
|
|
114
|
+
changed = _changed_lines(context, path)
|
|
115
|
+
if changed is not None and not changed:
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
proc, out, err = _run_ruff(
|
|
119
|
+
["ruff", "check", path, "--output-format", "json"],
|
|
120
|
+
context.root,
|
|
121
|
+
)
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
warned.append("ruff unavailable: %s" % exc)
|
|
124
|
+
break
|
|
125
|
+
if proc.returncode not in (0, 1):
|
|
126
|
+
warned.append(
|
|
127
|
+
"ruff check %s exited with code %s"
|
|
128
|
+
% (path, proc.returncode)
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
data = json.loads(out.decode("utf-8", errors="replace"))
|
|
133
|
+
except ValueError:
|
|
134
|
+
continue
|
|
135
|
+
if not isinstance(data, list):
|
|
136
|
+
continue
|
|
137
|
+
for violation in data:
|
|
138
|
+
location = violation.get("location") or {}
|
|
139
|
+
row = location.get("row")
|
|
140
|
+
if changed is not None and row not in changed:
|
|
141
|
+
continue
|
|
142
|
+
issues.append(
|
|
143
|
+
CheckIssue(
|
|
144
|
+
path,
|
|
145
|
+
row or 0,
|
|
146
|
+
location.get("column") or 0,
|
|
147
|
+
violation.get("code") or self.id,
|
|
148
|
+
violation.get("message") or "",
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return CheckResult(issues=issues, warned=warned)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@register
|
|
155
|
+
class RuffFormatCheck(Check):
|
|
156
|
+
id = "ruff-format"
|
|
157
|
+
scoped = "lines"
|
|
158
|
+
|
|
159
|
+
def run(self, context, files):
|
|
160
|
+
issues = []
|
|
161
|
+
warned = []
|
|
162
|
+
for path in files or []:
|
|
163
|
+
if not path.endswith(".py"):
|
|
164
|
+
continue
|
|
165
|
+
changed = _changed_lines(context, path)
|
|
166
|
+
if changed is not None and not changed:
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
proc, _out, _err = _run_ruff(
|
|
170
|
+
["ruff", "format", "--check", path], context.root
|
|
171
|
+
)
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
warned.append("ruff unavailable: %s" % exc)
|
|
174
|
+
break
|
|
175
|
+
if proc.returncode == 0:
|
|
176
|
+
continue
|
|
177
|
+
if proc.returncode != 1:
|
|
178
|
+
warned.append(
|
|
179
|
+
"ruff format --check %s exited with code %s"
|
|
180
|
+
% (path, proc.returncode)
|
|
181
|
+
)
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
diff_proc, out, _err = _run_ruff(
|
|
185
|
+
["ruff", "format", "--diff", path], context.root
|
|
186
|
+
)
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
warned.append("ruff unavailable: %s" % exc)
|
|
189
|
+
break
|
|
190
|
+
if diff_proc.returncode not in (0, 1):
|
|
191
|
+
warned.append(
|
|
192
|
+
"ruff format --diff %s exited with code %s"
|
|
193
|
+
% (path, diff_proc.returncode)
|
|
194
|
+
)
|
|
195
|
+
continue
|
|
196
|
+
diff = out.decode("utf-8", errors="replace")
|
|
197
|
+
kept = _filter_diff_by_lines(diff, changed)
|
|
198
|
+
if not kept:
|
|
199
|
+
continue
|
|
200
|
+
issues.append(
|
|
201
|
+
CheckIssue(
|
|
202
|
+
path,
|
|
203
|
+
0,
|
|
204
|
+
0,
|
|
205
|
+
self.id,
|
|
206
|
+
"formatting issue in changed lines:\n%s\nFix with: ruff format %s"
|
|
207
|
+
% (_truncate(kept), path),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return CheckResult(issues=issues, warned=warned)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Syntax checks for JSON, Python AST, and merge-conflict markers.
|
|
2
|
+
|
|
3
|
+
Example: a "=======" marker in a dev-changed region is flagged as a leftover
|
|
4
|
+
merge conflict, and a dev-added invalid JSON file is flagged.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from diffimpactscout.checks.base import (
|
|
12
|
+
Check,
|
|
13
|
+
CheckIssue,
|
|
14
|
+
CheckResult,
|
|
15
|
+
register,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@register
|
|
20
|
+
class JsonSyntaxCheck(Check):
|
|
21
|
+
id = "syntax/json-syntax"
|
|
22
|
+
scoped = "files"
|
|
23
|
+
|
|
24
|
+
def run(self, context, files):
|
|
25
|
+
issues = []
|
|
26
|
+
for path in files or []:
|
|
27
|
+
if not path.endswith(".json"):
|
|
28
|
+
continue
|
|
29
|
+
fn = os.path.join(context.root, path)
|
|
30
|
+
try:
|
|
31
|
+
with open(fn, "rb") as fh:
|
|
32
|
+
data = fh.read()
|
|
33
|
+
except OSError:
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
json.loads(data)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
issues.append(
|
|
39
|
+
CheckIssue(
|
|
40
|
+
path,
|
|
41
|
+
getattr(exc, "lineno", 0) or 0,
|
|
42
|
+
getattr(exc, "colno", 0) or 0,
|
|
43
|
+
self.id,
|
|
44
|
+
"invalid JSON: %s" % exc,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
return CheckResult(issues=issues)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@register
|
|
51
|
+
class AstSyntaxCheck(Check):
|
|
52
|
+
id = "syntax/ast-syntax"
|
|
53
|
+
scoped = "files"
|
|
54
|
+
|
|
55
|
+
def run(self, context, files):
|
|
56
|
+
issues = []
|
|
57
|
+
for path in files or []:
|
|
58
|
+
if not path.endswith(".py"):
|
|
59
|
+
continue
|
|
60
|
+
fn = os.path.join(context.root, path)
|
|
61
|
+
try:
|
|
62
|
+
with open(fn, "rb") as fh:
|
|
63
|
+
data = fh.read()
|
|
64
|
+
except OSError:
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
ast.parse(data.decode("utf-8-sig"), filename=path)
|
|
68
|
+
except SyntaxError as exc:
|
|
69
|
+
issues.append(
|
|
70
|
+
CheckIssue(
|
|
71
|
+
path,
|
|
72
|
+
exc.lineno or 0,
|
|
73
|
+
exc.offset or 0,
|
|
74
|
+
self.id,
|
|
75
|
+
"invalid python syntax: %s" % (exc.msg or "syntax error"),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
issues.append(
|
|
80
|
+
CheckIssue(
|
|
81
|
+
path, 0, 0, self.id, "invalid python source: %s" % exc
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return CheckResult(issues=issues)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@register
|
|
88
|
+
class MergeConflictCheck(Check):
|
|
89
|
+
id = "syntax/merge-conflict"
|
|
90
|
+
scoped = "lines"
|
|
91
|
+
markers = ("<<<<<<< ", "=======", ">>>>>>> ")
|
|
92
|
+
|
|
93
|
+
def run(self, context, files):
|
|
94
|
+
issues = []
|
|
95
|
+
for path in files or []:
|
|
96
|
+
fn = os.path.join(context.root, path)
|
|
97
|
+
try:
|
|
98
|
+
with open(fn, "rb") as fh:
|
|
99
|
+
data = fh.read()
|
|
100
|
+
except OSError:
|
|
101
|
+
continue
|
|
102
|
+
lines = data.decode("utf-8", errors="replace").splitlines()
|
|
103
|
+
changed = self._changed_lines(context, path)
|
|
104
|
+
if changed is None:
|
|
105
|
+
changed = set(range(1, len(lines) + 1))
|
|
106
|
+
if not changed:
|
|
107
|
+
continue
|
|
108
|
+
for lineno in sorted(changed):
|
|
109
|
+
if lineno < 1 or lineno > len(lines):
|
|
110
|
+
continue
|
|
111
|
+
line = lines[lineno - 1]
|
|
112
|
+
if self._is_marker(line):
|
|
113
|
+
issues.append(
|
|
114
|
+
CheckIssue(path, lineno, 0, self.id, "conflict marker found")
|
|
115
|
+
)
|
|
116
|
+
return CheckResult(issues=issues)
|
|
117
|
+
|
|
118
|
+
def _is_marker(self, line):
|
|
119
|
+
if line == "=======":
|
|
120
|
+
return True
|
|
121
|
+
return line.startswith("<<<<<<< ") or line.startswith(">>>>>>> ")
|
|
122
|
+
|
|
123
|
+
def _changed_lines(self, context, path):
|
|
124
|
+
try:
|
|
125
|
+
tracked = context.is_tracked(path)
|
|
126
|
+
changed = context.changed_lines(path)
|
|
127
|
+
except Exception:
|
|
128
|
+
return set()
|
|
129
|
+
if changed is not None:
|
|
130
|
+
return changed
|
|
131
|
+
if tracked:
|
|
132
|
+
return set()
|
|
133
|
+
return None
|