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.
@@ -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