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/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)
|
diffimpactscout/guard.py
ADDED
|
@@ -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
|