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
diffimpactscout/base.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Resolve the change base the developer's diff is measured against (upstream master, upstream sprint, or the default origin branch).
|
|
2
|
+
|
|
3
|
+
Example: when the repo has an upstream/sprint/12 ref, diffs are measured
|
|
4
|
+
against it instead of origin/master.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import diffimpactscout.gitrun as gitrun
|
|
8
|
+
|
|
9
|
+
MASTER_REF = "refs/remotes/upstream/master"
|
|
10
|
+
SPRINT_PREFIX = "sprint/"
|
|
11
|
+
DEFAULT_MAX_SPRINT_CANDIDATES = 10
|
|
12
|
+
REMOTE_FALLBACKS = ("main", "master", "develop")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_change_base(root, limit=DEFAULT_MAX_SPRINT_CANDIDATES):
|
|
16
|
+
"""Return the remote ref closest to HEAD, or None.
|
|
17
|
+
|
|
18
|
+
`limit` caps how many `sprint/*` refs are considered: only the
|
|
19
|
+
`limit` (default 10) most-recent ones by committer date, unlike the
|
|
20
|
+
bash original dev_base.sh which considered every sprint branch.
|
|
21
|
+
"""
|
|
22
|
+
for remote in _remote_order(root):
|
|
23
|
+
ref = _resolve_remote(root, remote, limit)
|
|
24
|
+
if ref is not None:
|
|
25
|
+
return ref
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _remote_order(root):
|
|
30
|
+
names = set()
|
|
31
|
+
for name in gitrun.git_lines(["remote"], root):
|
|
32
|
+
if name:
|
|
33
|
+
names.add(name)
|
|
34
|
+
for ref in gitrun.git_lines(
|
|
35
|
+
["for-each-ref", "--format=%(refname)", "refs/remotes/"], root
|
|
36
|
+
):
|
|
37
|
+
parts = ref.split("/")
|
|
38
|
+
if len(parts) >= 3:
|
|
39
|
+
names.add(parts[2])
|
|
40
|
+
ordered = []
|
|
41
|
+
for name in ("upstream", "origin"):
|
|
42
|
+
if name in names:
|
|
43
|
+
ordered.append(name)
|
|
44
|
+
for name in sorted(names):
|
|
45
|
+
if name not in ordered:
|
|
46
|
+
ordered.append(name)
|
|
47
|
+
return ordered
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_remote(root, remote, limit):
|
|
51
|
+
if remote == "upstream":
|
|
52
|
+
return _closest_tree(root, _upstream_candidates(root, limit))
|
|
53
|
+
short = gitrun.git_out(
|
|
54
|
+
["symbolic-ref", "--short", "refs/remotes/%s/HEAD" % remote], root
|
|
55
|
+
)
|
|
56
|
+
if short.startswith(remote + "/"):
|
|
57
|
+
ref = "refs/remotes/" + short
|
|
58
|
+
if gitrun.git_ok(["rev-parse", "--verify", ref], root):
|
|
59
|
+
return ref
|
|
60
|
+
for branch in REMOTE_FALLBACKS:
|
|
61
|
+
ref = "refs/remotes/%s/%s" % (remote, branch)
|
|
62
|
+
if gitrun.git_ok(["rev-parse", "--verify", ref], root):
|
|
63
|
+
return ref
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _upstream_candidates(root, limit):
|
|
68
|
+
candidates = [MASTER_REF]
|
|
69
|
+
pattern = "refs/remotes/upstream/%s" % SPRINT_PREFIX
|
|
70
|
+
sprints = gitrun.git_lines(
|
|
71
|
+
["for-each-ref", "--sort=-committerdate", "--format=%(refname)", pattern], root
|
|
72
|
+
)
|
|
73
|
+
candidates.extend(sprints[:limit])
|
|
74
|
+
return candidates
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _closest_tree(root, candidates):
|
|
78
|
+
best = None
|
|
79
|
+
best_count = None
|
|
80
|
+
for ref in candidates:
|
|
81
|
+
if not ref:
|
|
82
|
+
continue
|
|
83
|
+
if not gitrun.git_ok(["rev-parse", "--verify", ref], root):
|
|
84
|
+
continue
|
|
85
|
+
count = len(
|
|
86
|
+
gitrun.git_nul(
|
|
87
|
+
["diff", "--name-only", "-z", "--diff-filter=ACMRT", ref, "HEAD"], root
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
if best_count is None or count < best_count:
|
|
91
|
+
best = ref
|
|
92
|
+
best_count = count
|
|
93
|
+
if count == 0:
|
|
94
|
+
break
|
|
95
|
+
return best
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Checks package: registry of all guard checks and the make_check factory."""
|
|
2
|
+
|
|
3
|
+
from diffimpactscout.checks import base
|
|
4
|
+
from diffimpactscout.checks.base import (
|
|
5
|
+
REGISTRY,
|
|
6
|
+
Check,
|
|
7
|
+
CheckContext,
|
|
8
|
+
CheckIssue,
|
|
9
|
+
CheckResult,
|
|
10
|
+
ExternalCheck,
|
|
11
|
+
make_check,
|
|
12
|
+
register,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
import diffimpactscout.checks.hygiene
|
|
16
|
+
import diffimpactscout.checks.syntax
|
|
17
|
+
import diffimpactscout.checks.repo_checks
|
|
18
|
+
import diffimpactscout.checks.ruff
|
|
19
|
+
import diffimpactscout.checks.eslint
|
|
20
|
+
import diffimpactscout.checks.prettier
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Check framework: registers checks, defines the check context and results, and turns config entries into runnable checks.
|
|
2
|
+
|
|
3
|
+
Example: a check entry with type external runs a given command per file and
|
|
4
|
+
reports its output as issues.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
import diffimpactscout.env as env
|
|
10
|
+
|
|
11
|
+
REGISTRY = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Check(object):
|
|
15
|
+
id = None
|
|
16
|
+
scoped = "files"
|
|
17
|
+
blocking = True
|
|
18
|
+
always_block = False
|
|
19
|
+
|
|
20
|
+
def __init__(self, id=None, scoped=None, blocking=None, always_block=None):
|
|
21
|
+
if id is not None:
|
|
22
|
+
self.id = id
|
|
23
|
+
if scoped is not None:
|
|
24
|
+
self.scoped = scoped
|
|
25
|
+
if blocking is not None:
|
|
26
|
+
self.blocking = blocking
|
|
27
|
+
if always_block is not None:
|
|
28
|
+
self.always_block = always_block
|
|
29
|
+
if getattr(self, "id", None) is None:
|
|
30
|
+
raise ValueError("Check subclass must define an id")
|
|
31
|
+
self.args = []
|
|
32
|
+
|
|
33
|
+
def extend_config(self, entry):
|
|
34
|
+
if isinstance(entry, dict):
|
|
35
|
+
args = entry.get("args")
|
|
36
|
+
if args:
|
|
37
|
+
self.args = list(self.args) + list(args)
|
|
38
|
+
blocking = entry.get("blocking")
|
|
39
|
+
if blocking is not None:
|
|
40
|
+
self.blocking = env.is_true(blocking)
|
|
41
|
+
always_block = entry.get("always_block")
|
|
42
|
+
if always_block is not None:
|
|
43
|
+
self.always_block = env.is_true(always_block)
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def run(self, context, files):
|
|
47
|
+
raise NotImplementedError("Check subclasses must implement run()")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ExternalCheck(Check):
|
|
51
|
+
scoped = "files"
|
|
52
|
+
|
|
53
|
+
def __init__(self, entry):
|
|
54
|
+
if not isinstance(entry, dict):
|
|
55
|
+
entry = {}
|
|
56
|
+
command = list(entry.get("command") or [])
|
|
57
|
+
if not command:
|
|
58
|
+
raise ValueError("external check requires a command")
|
|
59
|
+
cid = entry.get("id")
|
|
60
|
+
if not cid:
|
|
61
|
+
cid = "external:" + command[0]
|
|
62
|
+
super(ExternalCheck, self).__init__(
|
|
63
|
+
id=cid,
|
|
64
|
+
scoped=entry.get("scoped") or "files",
|
|
65
|
+
blocking=entry.get("blocking"),
|
|
66
|
+
)
|
|
67
|
+
self.command = command
|
|
68
|
+
self.always_block = env.is_true(entry.get("always_block"))
|
|
69
|
+
|
|
70
|
+
def _argv_for(self, path):
|
|
71
|
+
has_placeholder = any("{file}" in token for token in self.command)
|
|
72
|
+
argv = [token.replace("{file}", path) for token in self.command]
|
|
73
|
+
if not has_placeholder:
|
|
74
|
+
argv.append(path)
|
|
75
|
+
return argv
|
|
76
|
+
|
|
77
|
+
def run(self, context, files):
|
|
78
|
+
issues = []
|
|
79
|
+
fixed = []
|
|
80
|
+
skipped = []
|
|
81
|
+
warned = []
|
|
82
|
+
if self.scoped == "repo":
|
|
83
|
+
try:
|
|
84
|
+
proc = subprocess.Popen(
|
|
85
|
+
list(self.command),
|
|
86
|
+
cwd=context.root,
|
|
87
|
+
stdout=subprocess.PIPE,
|
|
88
|
+
stderr=subprocess.PIPE,
|
|
89
|
+
)
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
warned.append("command %r unavailable: %s" % (self.command[0], exc))
|
|
92
|
+
return CheckResult(
|
|
93
|
+
issues=issues, fixed=fixed, skipped=skipped, warned=warned
|
|
94
|
+
)
|
|
95
|
+
out, err = proc.communicate()
|
|
96
|
+
if proc.returncode != 0:
|
|
97
|
+
output = (out or err).decode("utf-8", errors="replace").strip()
|
|
98
|
+
if not output:
|
|
99
|
+
output = "exit code %s" % proc.returncode
|
|
100
|
+
issues.append(CheckIssue(".", 0, 0, self.id, output))
|
|
101
|
+
return CheckResult(issues=issues, fixed=fixed, skipped=skipped, warned=warned)
|
|
102
|
+
for path in files or []:
|
|
103
|
+
argv = self._argv_for(path)
|
|
104
|
+
try:
|
|
105
|
+
proc = subprocess.Popen(
|
|
106
|
+
argv,
|
|
107
|
+
cwd=context.root,
|
|
108
|
+
stdout=subprocess.PIPE,
|
|
109
|
+
stderr=subprocess.PIPE,
|
|
110
|
+
)
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
warned.append("command %r unavailable: %s" % (self.command[0], exc))
|
|
113
|
+
break
|
|
114
|
+
out, err = proc.communicate()
|
|
115
|
+
if proc.returncode != 0:
|
|
116
|
+
output = (out or err).decode("utf-8", errors="replace").strip()
|
|
117
|
+
if not output:
|
|
118
|
+
output = "exit code %s" % proc.returncode
|
|
119
|
+
issues.append(CheckIssue(path, 0, 0, self.id, output))
|
|
120
|
+
return CheckResult(issues=issues, fixed=fixed, skipped=skipped, warned=warned)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class CheckContext(object):
|
|
124
|
+
def __init__(self, root, anchor, from_ref, to_ref, scope, config=None, echo=True):
|
|
125
|
+
self.root = root
|
|
126
|
+
self.anchor = anchor
|
|
127
|
+
self.from_ref = from_ref
|
|
128
|
+
self.to_ref = to_ref
|
|
129
|
+
self.scope = scope
|
|
130
|
+
self.config = config if config is not None else {}
|
|
131
|
+
self.echo = echo
|
|
132
|
+
|
|
133
|
+
def changed_lines(self, path):
|
|
134
|
+
"""Changed line numbers for path, or None when the path is untracked (whole file) or scope lookup failed; pair with is_tracked()."""
|
|
135
|
+
try:
|
|
136
|
+
return self.scope.changed_lines(
|
|
137
|
+
self.anchor, path, self.from_ref, self.to_ref
|
|
138
|
+
)
|
|
139
|
+
except Exception:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def is_tracked(self, path):
|
|
143
|
+
try:
|
|
144
|
+
return bool(self.scope.is_tracked(path))
|
|
145
|
+
except Exception:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def note(self, text):
|
|
149
|
+
if self.echo:
|
|
150
|
+
print(text)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class CheckIssue(object):
|
|
154
|
+
def __init__(self, path, line, column, code, message):
|
|
155
|
+
self.path = path
|
|
156
|
+
self.line = line
|
|
157
|
+
self.column = column
|
|
158
|
+
self.code = code
|
|
159
|
+
self.message = message
|
|
160
|
+
|
|
161
|
+
def format(self):
|
|
162
|
+
return "%s:%s:%s: %s %s" % (
|
|
163
|
+
self.path,
|
|
164
|
+
self.line,
|
|
165
|
+
self.column,
|
|
166
|
+
self.code,
|
|
167
|
+
self.message,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class CheckResult(object):
|
|
172
|
+
def __init__(self, issues=None, fixed=None, skipped=None, warned=None):
|
|
173
|
+
self.issues = list(issues) if issues else []
|
|
174
|
+
self.fixed = list(fixed) if fixed else []
|
|
175
|
+
self.skipped = list(skipped) if skipped else []
|
|
176
|
+
self.warned = list(warned) if warned else []
|
|
177
|
+
|
|
178
|
+
def ok(self):
|
|
179
|
+
return not self.issues
|
|
180
|
+
|
|
181
|
+
def has_issues(self):
|
|
182
|
+
return bool(self.issues)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def register(cls):
|
|
186
|
+
if not getattr(cls, "id", None):
|
|
187
|
+
raise ValueError("registered check class must define an id")
|
|
188
|
+
REGISTRY[cls.id] = cls
|
|
189
|
+
return cls
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def make_check(entry):
|
|
193
|
+
if not isinstance(entry, dict):
|
|
194
|
+
return None
|
|
195
|
+
if entry.get("type") == "external":
|
|
196
|
+
try:
|
|
197
|
+
return ExternalCheck(entry)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return None
|
|
200
|
+
cls = REGISTRY.get(entry.get("id"))
|
|
201
|
+
if cls is None:
|
|
202
|
+
return None
|
|
203
|
+
try:
|
|
204
|
+
return cls().extend_config(entry)
|
|
205
|
+
except Exception:
|
|
206
|
+
return None
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""ESLint incremental check: reports only violations on lines the developer changed, and honors a saved baseline.
|
|
2
|
+
|
|
3
|
+
Example: a no-unused-vars error on a line the developer added fails the
|
|
4
|
+
guard, while the same error on an untouched upstream line is skipped.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
from diffimpactscout.checks.base import (
|
|
12
|
+
Check,
|
|
13
|
+
CheckIssue,
|
|
14
|
+
CheckResult,
|
|
15
|
+
register,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize(path):
|
|
20
|
+
if path.startswith("./"):
|
|
21
|
+
return path[2:]
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _changed_lines(context, path):
|
|
26
|
+
try:
|
|
27
|
+
tracked = context.is_tracked(path)
|
|
28
|
+
changed = context.changed_lines(path)
|
|
29
|
+
except Exception:
|
|
30
|
+
return set()
|
|
31
|
+
if changed is not None:
|
|
32
|
+
return changed
|
|
33
|
+
if tracked:
|
|
34
|
+
return set()
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_baseline(root):
|
|
39
|
+
path = os.path.join(root, ".eslint_baseline.json")
|
|
40
|
+
try:
|
|
41
|
+
with open(path, "r") as fh:
|
|
42
|
+
data = json.load(fh)
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
return []
|
|
45
|
+
if isinstance(data, dict) and "results" in data:
|
|
46
|
+
data = data.get("results")
|
|
47
|
+
if isinstance(data, dict):
|
|
48
|
+
entries = []
|
|
49
|
+
for key, values in data.items():
|
|
50
|
+
if isinstance(values, list):
|
|
51
|
+
entries.append((_normalize(key), set(values)))
|
|
52
|
+
return entries
|
|
53
|
+
if not isinstance(data, list):
|
|
54
|
+
return []
|
|
55
|
+
entries = []
|
|
56
|
+
for item in data:
|
|
57
|
+
if not isinstance(item, dict):
|
|
58
|
+
continue
|
|
59
|
+
keys = set()
|
|
60
|
+
for message in item.get("messages") or []:
|
|
61
|
+
line = message.get("line")
|
|
62
|
+
column = message.get("column")
|
|
63
|
+
rule_id = message.get("ruleId")
|
|
64
|
+
if line and column and rule_id:
|
|
65
|
+
keys.add("%s:%s:%s" % (line, column, rule_id))
|
|
66
|
+
file_path = item.get("filePath")
|
|
67
|
+
if file_path:
|
|
68
|
+
entries.append((file_path, keys))
|
|
69
|
+
return entries
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _path_matches(file_path, candidate):
|
|
73
|
+
if file_path == candidate:
|
|
74
|
+
return True
|
|
75
|
+
if file_path.endswith(os.sep + candidate):
|
|
76
|
+
return True
|
|
77
|
+
if file_path.endswith("/" + candidate):
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _baseline_keys(baseline, root, path):
|
|
83
|
+
candidates = (path, _normalize(path), os.path.join(root, path))
|
|
84
|
+
for file_path, keys in baseline:
|
|
85
|
+
for candidate in candidates:
|
|
86
|
+
if _path_matches(file_path, candidate):
|
|
87
|
+
return keys
|
|
88
|
+
return set()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _run_npx(argv, root):
|
|
92
|
+
proc = subprocess.Popen(
|
|
93
|
+
argv,
|
|
94
|
+
cwd=root,
|
|
95
|
+
stdout=subprocess.PIPE,
|
|
96
|
+
stderr=subprocess.PIPE,
|
|
97
|
+
)
|
|
98
|
+
out, err = proc.communicate()
|
|
99
|
+
return proc, out, err
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@register
|
|
103
|
+
class EslintCheck(Check):
|
|
104
|
+
id = "eslint"
|
|
105
|
+
scoped = "lines"
|
|
106
|
+
|
|
107
|
+
def run(self, context, files):
|
|
108
|
+
issues = []
|
|
109
|
+
warned = []
|
|
110
|
+
baseline = _load_baseline(context.root)
|
|
111
|
+
for path in files or []:
|
|
112
|
+
changed = _changed_lines(context, path)
|
|
113
|
+
if changed is not None and not changed:
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
proc, out, _err = _run_npx(
|
|
117
|
+
["npx", "eslint", path, "--format", "json"],
|
|
118
|
+
context.root,
|
|
119
|
+
)
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
warned.append("npx eslint unavailable: %s" % exc)
|
|
122
|
+
break
|
|
123
|
+
if proc.returncode not in (0, 1):
|
|
124
|
+
warned.append(
|
|
125
|
+
"npx eslint %s exited with code %s"
|
|
126
|
+
% (path, proc.returncode)
|
|
127
|
+
)
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
data = json.loads(out.decode("utf-8", errors="replace"))
|
|
131
|
+
except ValueError:
|
|
132
|
+
continue
|
|
133
|
+
if isinstance(data, dict):
|
|
134
|
+
data = data.get("results")
|
|
135
|
+
if not isinstance(data, list):
|
|
136
|
+
continue
|
|
137
|
+
baseline_keys = _baseline_keys(baseline, context.root, path)
|
|
138
|
+
for file_result in data:
|
|
139
|
+
if not isinstance(file_result, dict):
|
|
140
|
+
continue
|
|
141
|
+
for message in file_result.get("messages") or []:
|
|
142
|
+
line = message.get("line")
|
|
143
|
+
column = message.get("column")
|
|
144
|
+
rule_id = message.get("ruleId")
|
|
145
|
+
if changed is not None and line not in changed:
|
|
146
|
+
continue
|
|
147
|
+
if (
|
|
148
|
+
line
|
|
149
|
+
and column
|
|
150
|
+
and rule_id
|
|
151
|
+
and "%s:%s:%s" % (line, column, rule_id)
|
|
152
|
+
in baseline_keys
|
|
153
|
+
):
|
|
154
|
+
continue
|
|
155
|
+
issues.append(
|
|
156
|
+
CheckIssue(
|
|
157
|
+
path,
|
|
158
|
+
line or 0,
|
|
159
|
+
column or 0,
|
|
160
|
+
rule_id or self.id,
|
|
161
|
+
message.get("message") or "",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return CheckResult(issues=issues, warned=warned)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Auto-fixing checks for line endings, trailing whitespace, and a missing final newline.
|
|
2
|
+
|
|
3
|
+
Example: a dev-changed file whose line ends with a trailing space is
|
|
4
|
+
rewritten without it, while an upstream file with the same flaw is left
|
|
5
|
+
alone.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from diffimpactscout.checks.base import (
|
|
11
|
+
Check,
|
|
12
|
+
CheckResult,
|
|
13
|
+
register,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fix_content(fixer, data):
|
|
18
|
+
if fixer == 'mixed-line-ending':
|
|
19
|
+
if b'\r' not in data:
|
|
20
|
+
return None
|
|
21
|
+
return data.replace(b'\r\n', b'\n').replace(b'\r', b'\n')
|
|
22
|
+
if fixer == 'end-of-file-fixer':
|
|
23
|
+
if not data:
|
|
24
|
+
return None
|
|
25
|
+
strip = data.rstrip(b'\n')
|
|
26
|
+
if data == strip:
|
|
27
|
+
return data + b'\n'
|
|
28
|
+
if len(data) - len(strip) > 1:
|
|
29
|
+
return strip + b'\n'
|
|
30
|
+
return None
|
|
31
|
+
if fixer == 'trailing-whitespace':
|
|
32
|
+
lines = data.split(b'\n')
|
|
33
|
+
new_lines = [line.rstrip(b' \t') for line in lines]
|
|
34
|
+
changed = new_lines != lines
|
|
35
|
+
while new_lines and not new_lines[-1]:
|
|
36
|
+
new_lines.pop()
|
|
37
|
+
changed = True
|
|
38
|
+
out = b'\n'.join(new_lines)
|
|
39
|
+
if out:
|
|
40
|
+
out += b'\n'
|
|
41
|
+
return out if out != data and changed else None
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _HygieneCheck(Check):
|
|
46
|
+
scoped = "files"
|
|
47
|
+
fixer = None
|
|
48
|
+
|
|
49
|
+
def run(self, context, files):
|
|
50
|
+
fixed = []
|
|
51
|
+
warned = []
|
|
52
|
+
for path in files or []:
|
|
53
|
+
fn = os.path.join(context.root, path)
|
|
54
|
+
try:
|
|
55
|
+
with open(fn, 'rb') as fh:
|
|
56
|
+
data = fh.read()
|
|
57
|
+
except OSError:
|
|
58
|
+
continue
|
|
59
|
+
new = fix_content(self.fixer, data)
|
|
60
|
+
if new is not None:
|
|
61
|
+
try:
|
|
62
|
+
with open(fn, 'wb') as fh:
|
|
63
|
+
written = fh.write(new)
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
warned.append("could not write %s: %s" % (path, exc))
|
|
66
|
+
continue
|
|
67
|
+
if written != len(new):
|
|
68
|
+
warned.append(
|
|
69
|
+
"partial write to %s (%d of %d bytes)"
|
|
70
|
+
% (path, written, len(new))
|
|
71
|
+
)
|
|
72
|
+
continue
|
|
73
|
+
fixed.append(path)
|
|
74
|
+
return CheckResult(fixed=fixed, warned=warned)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@register
|
|
78
|
+
class MixedLineEndingCheck(_HygieneCheck):
|
|
79
|
+
id = "hygiene/mixed-line-ending"
|
|
80
|
+
fixer = "mixed-line-ending"
|
|
81
|
+
|
|
82
|
+
def extend_config(self, entry):
|
|
83
|
+
super(MixedLineEndingCheck, self).extend_config(entry)
|
|
84
|
+
mode = "lf"
|
|
85
|
+
for arg in self.args:
|
|
86
|
+
if arg.startswith("--fix="):
|
|
87
|
+
mode = arg[len("--fix="):]
|
|
88
|
+
if mode != "lf":
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"mixed-line-ending only supports --fix=lf (got --fix=%s)" % mode
|
|
91
|
+
)
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@register
|
|
96
|
+
class TrailingWhitespaceCheck(_HygieneCheck):
|
|
97
|
+
id = "hygiene/trailing-whitespace"
|
|
98
|
+
fixer = "trailing-whitespace"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@register
|
|
102
|
+
class EndOfFileFixerCheck(_HygieneCheck):
|
|
103
|
+
id = "hygiene/end-of-file-fixer"
|
|
104
|
+
fixer = "end-of-file-fixer"
|