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/cli.py ADDED
@@ -0,0 +1,220 @@
1
+ """Command-line interface: init, guard, impact, check, and install-hooks subcommands."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ import diffimpactscout.config as config
9
+ import diffimpactscout.env as env
10
+ import diffimpactscout.gitrun as gitrun
11
+ import diffimpactscout.guard as guard
12
+ import diffimpactscout.launcher as launcher
13
+ import diffimpactscout.scope as scope
14
+ from diffimpactscout import __version__
15
+ from diffimpactscout.checks.base import CheckContext, make_check
16
+ from diffimpactscout.impact import impact as impact_module
17
+
18
+
19
+ def _parser():
20
+ parser = argparse.ArgumentParser(
21
+ prog="diffimpactscout",
22
+ description="Incremental pre-push guard and AST blast-radius impact analyzer.",
23
+ )
24
+ parser.add_argument("--version", action="version", version=__version__)
25
+ sub = parser.add_subparsers(dest="command")
26
+ sub.required = True
27
+
28
+ p_init = sub.add_parser("init", help="write a .diffimpactscout.json config")
29
+ p_init.add_argument(
30
+ "--profile",
31
+ choices=("plain", "django", "fastapi"),
32
+ default=config.DEFAULT_PROFILE,
33
+ )
34
+ p_init.set_defaults(func=_cmd_init)
35
+
36
+ p_guard = sub.add_parser("guard", help="run the pre-push guard checks")
37
+ p_guard.add_argument("--staged", action="store_true")
38
+ p_guard.add_argument("--all", action="store_true", dest="all_files")
39
+ p_guard.add_argument("files", nargs="*")
40
+ p_guard.set_defaults(func=_cmd_guard)
41
+
42
+ p_impact = sub.add_parser(
43
+ "impact", help="analyze the blast radius of the change-set"
44
+ )
45
+ p_impact.add_argument("--staged", action="store_true")
46
+ p_impact.add_argument("--fast", action="store_true")
47
+ p_impact.add_argument("--json", action="store_true")
48
+ p_impact.set_defaults(func=_cmd_impact)
49
+
50
+ p_check = sub.add_parser(
51
+ "check", help="run a single check by id against files or the change-set"
52
+ )
53
+ p_check.add_argument(
54
+ "check_id", metavar="CHECK_ID", help="check id, e.g. syntax/json-syntax"
55
+ )
56
+ p_check.add_argument("files", nargs="*")
57
+ p_check.set_defaults(func=_cmd_check)
58
+
59
+ p_hooks = sub.add_parser("install-hooks", help="install the pre-push git hook")
60
+ p_hooks.add_argument(
61
+ "--force",
62
+ action="store_true",
63
+ help="overwrite an existing hook that diffimpactscout did not install",
64
+ )
65
+ p_hooks.add_argument(
66
+ "--uninstall",
67
+ action="store_true",
68
+ help="remove the diffimpactscout pre-push hook",
69
+ )
70
+ p_hooks.set_defaults(func=_cmd_install_hooks)
71
+
72
+ return parser
73
+
74
+
75
+ def main(argv=None):
76
+ args = _parser().parse_args(argv)
77
+ return args.func(args)
78
+
79
+
80
+ def _require_repo():
81
+ root = gitrun.repo_root()
82
+ if root:
83
+ return root
84
+ sys.stderr.write("diffimpactscout: not a git repository (run inside a repo)\n")
85
+ return None
86
+
87
+
88
+ def _cmd_init(args):
89
+ data = config._defaults()
90
+ data = config._deep_merge(data, config.load_profile(args.profile))
91
+ path = os.path.join(os.getcwd(), config.CFG_NAME)
92
+ if os.path.exists(path):
93
+ print("diffimpactscout: %s already exists; leaving it unchanged" % path)
94
+ return 0
95
+ try:
96
+ with open(path, "w") as fh:
97
+ json.dump(data, fh, indent=2)
98
+ fh.write("\n")
99
+ except OSError as exc:
100
+ sys.stderr.write(
101
+ "diffimpactscout: cannot write %s: %s\n" % (path, exc)
102
+ )
103
+ return 1
104
+ return 0
105
+
106
+
107
+ def _cmd_guard(args):
108
+ root = _require_repo()
109
+ if root is None:
110
+ return 1
111
+ cfg = config.load_config(root)
112
+ return guard.run_guard(
113
+ root,
114
+ cfg,
115
+ staged=args.staged,
116
+ all_files=args.all_files,
117
+ files=args.files or None,
118
+ )
119
+
120
+
121
+ def _cmd_impact(args):
122
+ root = _require_repo()
123
+ if root is None:
124
+ return 1
125
+ cfg = config.load_config(root)
126
+ return impact_module.run_impact(
127
+ root,
128
+ cfg,
129
+ staged=args.staged,
130
+ fast=args.fast,
131
+ json_out=args.json,
132
+ )
133
+
134
+
135
+ def _cmd_check(args):
136
+ root = _require_repo()
137
+ if root is None:
138
+ return 1
139
+ try:
140
+ check = make_check({"id": args.check_id})
141
+ except Exception as exc:
142
+ sys.stderr.write(
143
+ "diffimpactscout: check %r invalid: %s\n" % (args.check_id, exc)
144
+ )
145
+ return 1
146
+ if check is None:
147
+ sys.stderr.write("diffimpactscout: unknown check %r\n" % args.check_id)
148
+ return 1
149
+ cfg = config.load_config(root)
150
+ dev_scope = scope.DevScope(root)
151
+ from_ref, to_ref = env.pre_commit_refs()
152
+ if args.files:
153
+ for path in args.files:
154
+ if not os.path.exists(path):
155
+ sys.stderr.write("diffimpactscout: no such file: %s\n" % path)
156
+ return 1
157
+ files = list(args.files)
158
+ anchor = None
159
+ else:
160
+ anchor = dev_scope.scope_base(from_ref, to_ref)
161
+ files = dev_scope.dev_files(anchor, from_ref, to_ref)
162
+ ctx = CheckContext(
163
+ root=root,
164
+ anchor=anchor,
165
+ from_ref=from_ref,
166
+ to_ref=to_ref,
167
+ scope=dev_scope,
168
+ config=cfg,
169
+ echo=True,
170
+ )
171
+ check_files = [] if check.scoped == "repo" else files
172
+ try:
173
+ result = check.run(ctx, check_files)
174
+ except Exception as exc:
175
+ sys.stderr.write(
176
+ "diffimpactscout: check %r failed: %s\n" % (args.check_id, exc)
177
+ )
178
+ return 1
179
+ if result is None:
180
+ sys.stderr.write(
181
+ "diffimpactscout: check %r produced no result\n" % args.check_id
182
+ )
183
+ return 1
184
+ for issue in result.issues:
185
+ print(issue.format())
186
+ for path in result.fixed:
187
+ print("[fixed] %s" % path)
188
+ for text in result.warned:
189
+ sys.stderr.write("diffimpactscout: warning: %s\n" % text)
190
+ return 1 if result.has_issues() else 0
191
+
192
+
193
+ def _cmd_install_hooks(args):
194
+ root = _require_repo()
195
+ if root is None:
196
+ return 1
197
+ if args.uninstall:
198
+ try:
199
+ removed = launcher.uninstall_hook(root)
200
+ except OSError as exc:
201
+ sys.stderr.write(
202
+ "diffimpactscout: cannot remove pre-push hook: %s\n" % exc
203
+ )
204
+ return 1
205
+ if removed:
206
+ print("pre-push hook removed")
207
+ else:
208
+ print("nothing to remove")
209
+ return 0
210
+ try:
211
+ installed = launcher.install_hook(root, force=args.force)
212
+ except OSError as exc:
213
+ sys.stderr.write(
214
+ "diffimpactscout: cannot install pre-push hook: %s\n" % exc
215
+ )
216
+ return 1
217
+ if not installed:
218
+ return 1
219
+ print("pre-push hook installed")
220
+ return 0
@@ -0,0 +1,205 @@
1
+ """Load and merge the .diffimpactscout.json config, built-in profiles, and glob-based file exclusions."""
2
+
3
+ import fnmatch
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ import diffimpactscout.gitrun as gitrun
9
+
10
+ CFG_NAME = ".diffimpactscout.json"
11
+ DEFAULT_PROFILE = "plain"
12
+ MODE_PRE_PUSH = "pre-push"
13
+
14
+ DEFAULT_IGNORE_PATHS = [
15
+ "**/node_modules/**",
16
+ "**/venv/**",
17
+ "**/.venv/**",
18
+ "**/migrations/**",
19
+ "**/staticfiles/**",
20
+ "**/dist/**",
21
+ "**/build/**",
22
+ "**/.git/**",
23
+ ]
24
+
25
+ DEFAULT_GUARD_CHECKS = [
26
+ {"id": "hygiene/mixed-line-ending"},
27
+ {"id": "hygiene/trailing-whitespace"},
28
+ {"id": "hygiene/end-of-file-fixer"},
29
+ {"id": "syntax/json-syntax"},
30
+ {"id": "syntax/ast-syntax"},
31
+ {"id": "syntax/merge-conflict"},
32
+ {"id": "repo/large-files", "args": ["--maxkb=250000"]},
33
+ {"id": "repo/private-key"},
34
+ ]
35
+
36
+
37
+ def _defaults():
38
+ return {
39
+ "version": 1,
40
+ "mode": MODE_PRE_PUSH,
41
+ "ignore_paths": list(DEFAULT_IGNORE_PATHS),
42
+ "use_gitignore": False,
43
+ "guard": {
44
+ "checks": [dict(c) for c in DEFAULT_GUARD_CHECKS],
45
+ "blocking": "warn",
46
+ },
47
+ "impact": {
48
+ "profile": DEFAULT_PROFILE,
49
+ "urls_globs": [],
50
+ "template_globs": [],
51
+ "frontend_globs": [],
52
+ "cache_file": ".impact_analysis_cache.json",
53
+ "fast_mode": False,
54
+ "threads": 4,
55
+ },
56
+ }
57
+
58
+
59
+ def _deep_merge(base, override):
60
+ if isinstance(base, dict) and isinstance(override, dict):
61
+ merged = dict(base)
62
+ for key, value in override.items():
63
+ if key in merged:
64
+ merged[key] = _deep_merge(merged[key], value)
65
+ else:
66
+ merged[key] = value
67
+ return merged
68
+ if isinstance(base, list) and isinstance(override, list):
69
+ return base + override
70
+ return override
71
+
72
+
73
+ def _fallback_profile():
74
+ return {
75
+ "impact": {
76
+ "profile": DEFAULT_PROFILE,
77
+ "urls_globs": [],
78
+ "template_globs": [],
79
+ "frontend_globs": [],
80
+ }
81
+ }
82
+
83
+
84
+ def load_profile(name):
85
+ if not isinstance(name, str):
86
+ print(
87
+ "warning: invalid profile %r; using %r" % (name, DEFAULT_PROFILE),
88
+ file=sys.stderr,
89
+ )
90
+ name = DEFAULT_PROFILE
91
+ path = os.path.join(os.path.dirname(__file__), "profiles", name + ".json")
92
+ try:
93
+ with open(path) as fh:
94
+ data = json.load(fh)
95
+ except (OSError, ValueError) as exc:
96
+ print(
97
+ "warning: profile %r unavailable (%s); using %r"
98
+ % (name, exc, DEFAULT_PROFILE),
99
+ file=sys.stderr,
100
+ )
101
+ return _fallback_profile()
102
+ if not isinstance(data, dict):
103
+ print(
104
+ "warning: profile %r is not an object; using %r"
105
+ % (name, DEFAULT_PROFILE),
106
+ file=sys.stderr,
107
+ )
108
+ return _fallback_profile()
109
+ return data
110
+
111
+
112
+ def _read_user(root):
113
+ path = os.path.join(root, CFG_NAME)
114
+ if not os.path.exists(path):
115
+ return None
116
+ try:
117
+ with open(path) as fh:
118
+ data = json.load(fh)
119
+ except (ValueError, OSError) as exc:
120
+ print("warning: %s: %s" % (path, exc), file=sys.stderr)
121
+ return None
122
+ if not isinstance(data, dict):
123
+ print("warning: %s: expected a JSON object" % path, file=sys.stderr)
124
+ return None
125
+ return data
126
+
127
+
128
+ def load_config(root):
129
+ cfg = _defaults()
130
+ user = _read_user(root)
131
+ profile = DEFAULT_PROFILE
132
+ if user is not None and isinstance(user.get("impact"), dict):
133
+ profile = user["impact"].get("profile") or DEFAULT_PROFILE
134
+ profile_data = load_profile(profile)
135
+ cfg = _deep_merge(cfg, profile_data)
136
+ if user is not None:
137
+ cfg = _deep_merge(cfg, user)
138
+ if isinstance(user.get("guard"), dict) and "checks" in user["guard"]:
139
+ if isinstance(user["guard"]["checks"], list):
140
+ cfg["guard"]["checks"] = user["guard"]["checks"]
141
+ else:
142
+ cfg["guard"]["checks"] = _defaults()["guard"]["checks"]
143
+ print("warning: guard.checks must be a list; ignoring", file=sys.stderr)
144
+ cfg["impact"]["profile"] = profile_data.get("impact", {}).get(
145
+ "profile", DEFAULT_PROFILE
146
+ )
147
+ return cfg
148
+
149
+
150
+ def _collapse_mid(pattern):
151
+ i = 0
152
+ while True:
153
+ idx = pattern.find("/**/", i)
154
+ if idx == -1:
155
+ return None
156
+ if idx > 0:
157
+ return pattern[:idx] + "/" + pattern[idx + 4:]
158
+ i = idx + 1
159
+
160
+
161
+ def _glob_variants(pattern):
162
+ out = [pattern]
163
+ lead = pattern
164
+ while lead.startswith("**/"):
165
+ lead = lead[3:]
166
+ if lead != pattern:
167
+ out.append(lead)
168
+ if pattern.endswith("/**"):
169
+ out.append(pattern[:-3])
170
+ if lead != pattern and lead.endswith("/**"):
171
+ out.append(lead[:-3])
172
+ for base in (pattern, lead):
173
+ collapsed = _collapse_mid(base)
174
+ if collapsed is not None and collapsed not in out:
175
+ out.append(collapsed)
176
+ return out
177
+
178
+
179
+ def _path_suffixes(path):
180
+ parts = path.split("/")
181
+ return ["/".join(parts[i:]) for i in range(len(parts))]
182
+
183
+
184
+ def _matches_glob(path, pattern):
185
+ if path.startswith("./"):
186
+ path = path[2:]
187
+ for variant in _glob_variants(pattern):
188
+ for candidate in _path_suffixes(path):
189
+ if fnmatch.fnmatch(candidate, variant):
190
+ return True
191
+ return False
192
+
193
+
194
+ def is_excluded(path, cfg, root):
195
+ norm = path[2:] if path.startswith("./") else path
196
+ for pattern in cfg.get("ignore_paths") or []:
197
+ if _matches_glob(norm, pattern):
198
+ return True
199
+ if cfg.get("use_gitignore"):
200
+ try:
201
+ if gitrun.git_ok(["check-ignore", "--quiet", norm], root):
202
+ return True
203
+ except Exception:
204
+ pass
205
+ return False
diffimpactscout/env.py ADDED
@@ -0,0 +1,29 @@
1
+ """Shared environment-variable parsing: skip flags, strict mode, and pre-commit push-range refs."""
2
+
3
+ import os
4
+
5
+ _FALSY = ("", "0", "false", "no", "off")
6
+
7
+
8
+ def is_true(value):
9
+ if value is None:
10
+ return False
11
+ if not isinstance(value, str):
12
+ return bool(value)
13
+ return value.strip().lower() not in _FALSY
14
+
15
+
16
+ def skip_requested():
17
+ if "IMPACT_CHECK_SKIP" in os.environ:
18
+ return True
19
+ if "DIFFIMPACTSCOUT_SKIP" in os.environ:
20
+ return True
21
+ return False
22
+
23
+
24
+ def strict_requested():
25
+ return is_true(os.environ.get("IMPACT_CHECK_STRICT"))
26
+
27
+
28
+ def pre_commit_refs():
29
+ return (os.environ.get("PRE_COMMIT_FROM_REF"), os.environ.get("PRE_COMMIT_TO_REF"))
@@ -0,0 +1,68 @@
1
+ """Thin subprocess wrapper around git: run commands, capture output, and resolve the repo root and git dir."""
2
+
3
+ import os
4
+ import subprocess
5
+
6
+
7
+ class Result(object):
8
+ def __init__(self, returncode, out_, err_):
9
+ self.returncode = returncode
10
+ self.out_ = out_
11
+ self.err_ = err_
12
+
13
+ def ok(self):
14
+ return self.returncode == 0
15
+
16
+
17
+ def git(args, cwd):
18
+ try:
19
+ proc = subprocess.Popen(
20
+ ["git"] + list(args),
21
+ cwd=cwd,
22
+ stdout=subprocess.PIPE,
23
+ stderr=subprocess.PIPE,
24
+ )
25
+ except OSError:
26
+ return Result(127, "", "")
27
+ out, err = proc.communicate()
28
+ return Result(
29
+ proc.returncode,
30
+ out.decode("utf-8", errors="replace"),
31
+ err.decode("utf-8", errors="replace"),
32
+ )
33
+
34
+
35
+ def git_ok(args, cwd):
36
+ return git(args, cwd).ok()
37
+
38
+
39
+ def git_out(args, cwd, default=""):
40
+ result = git(args, cwd)
41
+ if not result.ok():
42
+ return default
43
+ return result.out_.rstrip("\n")
44
+
45
+
46
+ def git_lines(args, cwd):
47
+ out = git_out(args, cwd)
48
+ if not out:
49
+ return []
50
+ return out.split("\n")
51
+
52
+
53
+ def git_nul(args, cwd):
54
+ out = git_out(args, cwd)
55
+ if not out:
56
+ return []
57
+ return [part for part in out.split("\0") if part]
58
+
59
+
60
+ def repo_root(cwd=None):
61
+ result = git(["rev-parse", "--show-toplevel"], cwd)
62
+ if not result.ok():
63
+ return None
64
+ return result.out_.rstrip("\n")
65
+
66
+
67
+ def git_abs_dir(root, path):
68
+ return os.path.join(root, path)
@@ -0,0 +1,116 @@
1
+ """Orchestrate the pre-push guard: run the configured checks over the developer's change-set only.
2
+
3
+ Example: a sync push that only re-checks upstream-merged files exits 0,
4
+ while a dev-added trailing space or key leak fails the guard.
5
+ """
6
+
7
+ import sys
8
+
9
+ import diffimpactscout.env as env
10
+ import diffimpactscout.gitrun as gitrun
11
+ import diffimpactscout.scope as scope
12
+ from diffimpactscout.checks.base import CheckContext, make_check
13
+
14
+ DIFF_FILTER = "ACMRT"
15
+
16
+
17
+ def _skip_requested():
18
+ return env.skip_requested()
19
+
20
+
21
+ def _guard_mode(cfg):
22
+ if env.strict_requested():
23
+ return "strict"
24
+ mode = cfg.get("guard", {}).get("blocking") or "warn"
25
+ if mode == "strict":
26
+ return "strict"
27
+ return "warn"
28
+
29
+
30
+ def _staged_files(root):
31
+ return gitrun.git_nul(
32
+ ["diff", "--cached", "--name-only", "-z", "--diff-filter=" + DIFF_FILTER],
33
+ root,
34
+ )
35
+
36
+
37
+ def _all_files(root):
38
+ return gitrun.git_nul(["ls-files", "-z"], root)
39
+
40
+
41
+ def _entry_id(entry):
42
+ if isinstance(entry, dict):
43
+ return entry.get("id")
44
+ return entry
45
+
46
+
47
+ def _warn(text):
48
+ sys.stderr.write("diffimpactscout: warning: %s\n" % text)
49
+
50
+
51
+ def run_guard(root, cfg, staged=False, all_files=False, files=None):
52
+ if _skip_requested():
53
+ return 0
54
+
55
+ from_ref, to_ref = env.pre_commit_refs()
56
+
57
+ dev_scope = scope.DevScope(root)
58
+ anchor = None
59
+
60
+ if files is not None:
61
+ file_set = [files] if isinstance(files, str) else list(files)
62
+ elif staged:
63
+ file_set = _staged_files(root)
64
+ elif all_files:
65
+ file_set = _all_files(root)
66
+ else:
67
+ anchor = dev_scope.scope_base(from_ref, to_ref)
68
+ file_set = dev_scope.dev_files(anchor, from_ref, to_ref)
69
+
70
+ mode = _guard_mode(cfg)
71
+ ctx = CheckContext(
72
+ root=root,
73
+ anchor=anchor,
74
+ from_ref=from_ref,
75
+ to_ref=to_ref,
76
+ scope=dev_scope,
77
+ config=cfg,
78
+ echo=True,
79
+ )
80
+
81
+ ran = 0
82
+ total_issues = 0
83
+ blocked = False
84
+
85
+ for entry in cfg.get("guard", {}).get("checks") or []:
86
+ check = None
87
+ try:
88
+ check = make_check(entry)
89
+ except Exception as exc:
90
+ _warn("check %r failed to build: %s" % (_entry_id(entry), exc))
91
+ continue
92
+ if check is None:
93
+ _warn("unknown or invalid check %r; skipping" % (_entry_id(entry),))
94
+ continue
95
+ ran += 1
96
+ check_files = [] if check.scoped == "repo" else file_set
97
+ try:
98
+ result = check.run(ctx, check_files)
99
+ for issue in result.issues:
100
+ print(issue.format())
101
+ total_issues += 1
102
+ for path in result.fixed:
103
+ print("[fixed] %s" % path)
104
+ for text in result.warned:
105
+ _warn(text)
106
+ should_block = check.always_block or (check.blocking and mode == "strict")
107
+ if should_block and result.has_issues():
108
+ blocked = True
109
+ except Exception as exc:
110
+ _warn("check %r failed: %s" % (check.id, exc))
111
+ continue
112
+
113
+ print(
114
+ "diffimpactscout: %d check(s), %d issue(s)" % (ran, total_issues)
115
+ )
116
+ return 1 if blocked else 0
@@ -0,0 +1 @@
1
+ """Impact analysis: detect the blast radius of changed Python symbols across the repo."""
@@ -0,0 +1,55 @@
1
+ """Per-repo symbol cache keyed by a file content hash, and never blocking on read or write errors.
2
+
3
+ Example: re-analyzing an unchanged file skips parsing and reuses the cached
4
+ analysis.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+
12
+ class SymbolCache(object):
13
+ def __init__(self, path):
14
+ self.path = path
15
+ self._data = None
16
+
17
+ def load(self):
18
+ if self._data is None:
19
+ self._data = _read_cache(self.path)
20
+ return self._data
21
+
22
+ def save(self, data):
23
+ tmp = self.path + ".tmp"
24
+ try:
25
+ parent = os.path.dirname(self.path)
26
+ if parent:
27
+ os.makedirs(parent, exist_ok=True)
28
+ with open(tmp, "w") as fh:
29
+ json.dump(data, fh)
30
+ os.replace(tmp, self.path)
31
+ except (OSError, ValueError, TypeError) as exc:
32
+ print(
33
+ "warning: could not write %s: %s" % (self.path, exc),
34
+ file=sys.stderr,
35
+ )
36
+ return
37
+ self._data = data
38
+
39
+ def prune(self, existing):
40
+ keep = set(existing)
41
+ data = self.load()
42
+ for key in [k for k in data if k not in keep]:
43
+ del data[key]
44
+ return data
45
+
46
+
47
+ def _read_cache(path):
48
+ try:
49
+ with open(path) as fh:
50
+ data = json.load(fh)
51
+ except (OSError, ValueError):
52
+ return {}
53
+ if not isinstance(data, dict):
54
+ return {}
55
+ return data