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,3 @@
1
+ """diffimpactscout: incremental pre-push guard and AST blast-radius impact analyzer."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """Module entry point: ``python -m diffimpactscout`` runs the CLI."""
2
+
3
+ import sys
4
+
5
+ from diffimpactscout.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -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"