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
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""AST analysis of Python files: definitions, usages, and reference lookup.
|
|
2
|
+
|
|
3
|
+
Example: renaming a class field reports attribute usages of the old name
|
|
4
|
+
from other modules as references.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
_FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def analyze_source(src):
|
|
15
|
+
"""Parse source text and build a per-file symbol analysis.
|
|
16
|
+
|
|
17
|
+
Returns a dict with keys:
|
|
18
|
+
hash -- sha1 hex digest of the source text
|
|
19
|
+
defs -- {name: {"kind", "line", "end_line", "qname"}}
|
|
20
|
+
usages -- [{"line", "name", "kind", "ctx_qname", "ctx_kind"}]
|
|
21
|
+
Returns None when the source cannot be parsed.
|
|
22
|
+
|
|
23
|
+
defs covers functions, classes, methods, module fields and class
|
|
24
|
+
fields. usages covers name loads ("name"), attribute loads ("attr")
|
|
25
|
+
and imports ("import"). Definitions and assignment targets are never
|
|
26
|
+
recorded as usages. An attribute load records only the attribute name
|
|
27
|
+
(obj.status -> name "status", kind "attr"); the base object name is
|
|
28
|
+
not recorded so a field search matches obj.status exactly once.
|
|
29
|
+
Imports record the imported names, with dotted module names reduced
|
|
30
|
+
to their top-level part. ctx_qname is the dotted enclosing scope ("" at
|
|
31
|
+
module level) and ctx_kind is one of "module", "function", "class" or
|
|
32
|
+
"method".
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(src, bytes):
|
|
35
|
+
src = src.decode("utf-8", "replace")
|
|
36
|
+
tree = _parse(src)
|
|
37
|
+
if tree is None:
|
|
38
|
+
return None
|
|
39
|
+
defs = {}
|
|
40
|
+
usages = []
|
|
41
|
+
_walk(tree, [], defs, usages)
|
|
42
|
+
return {"hash": _content_hash(src), "defs": defs, "usages": usages}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def analyze_path(path, root, cache):
|
|
46
|
+
"""Analyze one repo file, skipping re-parse for unchanged content.
|
|
47
|
+
|
|
48
|
+
path is the repo-root-relative posix path (as git emits paths); root is
|
|
49
|
+
the absolute repo root used to resolve the file on disk. cache is a
|
|
50
|
+
SymbolCache, a plain dict, or None; cache entries are
|
|
51
|
+
{"hash": sha1 hex, "analysis": analyze_source result}. On a cache hit
|
|
52
|
+
(the stored hash matches the file's current hash) the stored analysis
|
|
53
|
+
is returned without re-parsing; otherwise the file is analyzed and the
|
|
54
|
+
entry stored under path. Returns the analysis dict, or None on a
|
|
55
|
+
read/parse failure. The cache's loaded data is mutated in place so a
|
|
56
|
+
caller can SymbolCache.save() once after scanning all files.
|
|
57
|
+
"""
|
|
58
|
+
full = path
|
|
59
|
+
if root:
|
|
60
|
+
full = os.path.join(root, path)
|
|
61
|
+
text = _read_text(full)
|
|
62
|
+
if text is None:
|
|
63
|
+
return None
|
|
64
|
+
data = _cache_data(cache)
|
|
65
|
+
entry = None
|
|
66
|
+
if data is not None:
|
|
67
|
+
entry = data.get(path)
|
|
68
|
+
if isinstance(entry, dict) and entry.get("hash") == _content_hash(text):
|
|
69
|
+
analysis = entry.get("analysis")
|
|
70
|
+
if isinstance(analysis, dict):
|
|
71
|
+
return analysis
|
|
72
|
+
analysis = analyze_source(text)
|
|
73
|
+
if analysis is None:
|
|
74
|
+
return None
|
|
75
|
+
if data is not None:
|
|
76
|
+
data[path] = {"hash": analysis["hash"], "analysis": analysis}
|
|
77
|
+
return analysis
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def find_references(analyses, names, kinds=None):
|
|
81
|
+
"""Return reference hits for the given entity names, optionally by kind.
|
|
82
|
+
|
|
83
|
+
analyses maps a repo-root-relative path to either a cache entry
|
|
84
|
+
({"hash": ..., "analysis": {...}}) or a raw analysis dict. names is an
|
|
85
|
+
iterable of entity names to search for (changed, deleted, or renamed
|
|
86
|
+
entities). kinds is an optional iterable of usage kinds ("name", "attr",
|
|
87
|
+
"import") to match; when None (default) all kinds are matched. Every
|
|
88
|
+
matching usage becomes a hit dict {"name", "path", "line", "how",
|
|
89
|
+
"ctx_qname", "ctx_kind"} sorted deterministically by (path, line, name,
|
|
90
|
+
how). Empty when nothing matches.
|
|
91
|
+
|
|
92
|
+
Caller contract:
|
|
93
|
+
changed class_field -> kinds={"attr"}
|
|
94
|
+
changed function/module_field -> kinds={"name","attr","import"}
|
|
95
|
+
changed method -> kinds={"name","attr"}
|
|
96
|
+
changed class -> kinds={"name","attr","import"}
|
|
97
|
+
"""
|
|
98
|
+
wanted = set(names)
|
|
99
|
+
kinds = None if kinds is None else set(kinds)
|
|
100
|
+
hits = []
|
|
101
|
+
for path, entry in (analyses or {}).items():
|
|
102
|
+
analysis = entry
|
|
103
|
+
if isinstance(entry, dict) and isinstance(entry.get("analysis"), dict):
|
|
104
|
+
analysis = entry["analysis"]
|
|
105
|
+
if not isinstance(analysis, dict):
|
|
106
|
+
continue
|
|
107
|
+
usages = analysis.get("usages")
|
|
108
|
+
if not isinstance(usages, list):
|
|
109
|
+
continue
|
|
110
|
+
for usage in usages:
|
|
111
|
+
name = usage.get("name")
|
|
112
|
+
if name in wanted:
|
|
113
|
+
how = usage.get("kind", "name")
|
|
114
|
+
if kinds is not None and how not in kinds:
|
|
115
|
+
continue
|
|
116
|
+
hits.append(
|
|
117
|
+
{
|
|
118
|
+
"name": name,
|
|
119
|
+
"path": path,
|
|
120
|
+
"line": usage.get("line", 0),
|
|
121
|
+
"how": how,
|
|
122
|
+
"ctx_qname": usage.get("ctx_qname", ""),
|
|
123
|
+
"ctx_kind": usage.get("ctx_kind", "module"),
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
hits.sort(key=lambda h: (h["path"], h["line"], h["name"], h["how"]))
|
|
127
|
+
return hits
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _walk(node, scope, defs, usages):
|
|
131
|
+
for child in ast.iter_child_nodes(node):
|
|
132
|
+
if isinstance(child, _FUNCS):
|
|
133
|
+
_visit_func(child, scope, defs, usages)
|
|
134
|
+
elif isinstance(child, ast.ClassDef):
|
|
135
|
+
_record_def(defs, child.name, "class", _qualname(scope, child.name), child)
|
|
136
|
+
_walk(child, scope + [("class", child.name)], defs, usages)
|
|
137
|
+
elif isinstance(child, (ast.Assign, ast.AnnAssign)):
|
|
138
|
+
_visit_assign(child, scope, defs, usages)
|
|
139
|
+
elif isinstance(child, ast.Import):
|
|
140
|
+
for alias in child.names:
|
|
141
|
+
usages.append(_usage(child, alias.name.split(".")[0], "import", scope))
|
|
142
|
+
elif isinstance(child, ast.ImportFrom):
|
|
143
|
+
for alias in child.names:
|
|
144
|
+
usages.append(_usage(child, alias.name, "import", scope))
|
|
145
|
+
elif isinstance(child, ast.Attribute):
|
|
146
|
+
_visit_attribute(child, scope, defs, usages)
|
|
147
|
+
elif isinstance(child, ast.Name):
|
|
148
|
+
if _is_load(child):
|
|
149
|
+
usages.append(_usage(child, child.id, "name", scope))
|
|
150
|
+
else:
|
|
151
|
+
_walk(child, scope, defs, usages)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _visit_func(child, scope, defs, usages):
|
|
155
|
+
kind = None
|
|
156
|
+
if not scope:
|
|
157
|
+
kind = "function"
|
|
158
|
+
elif scope[-1][0] == "class":
|
|
159
|
+
kind = "method"
|
|
160
|
+
if kind is not None:
|
|
161
|
+
_record_def(defs, child.name, kind, _qualname(scope, child.name), child)
|
|
162
|
+
_walk(child, scope + [("function", child.name)], defs, usages)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _visit_attribute(node, scope, defs, usages):
|
|
166
|
+
if _is_load(node):
|
|
167
|
+
usages.append(_usage(node, node.attr, "attr", scope))
|
|
168
|
+
value = node.value
|
|
169
|
+
if isinstance(value, ast.Name):
|
|
170
|
+
return
|
|
171
|
+
if isinstance(value, ast.Attribute):
|
|
172
|
+
_visit_attribute(value, scope, defs, usages)
|
|
173
|
+
else:
|
|
174
|
+
_walk(value, scope, defs, usages)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _visit_assign(child, scope, defs, usages):
|
|
178
|
+
kind = None
|
|
179
|
+
if not scope:
|
|
180
|
+
kind = "module_field"
|
|
181
|
+
elif scope[-1][0] == "class":
|
|
182
|
+
kind = "class_field"
|
|
183
|
+
if kind is not None:
|
|
184
|
+
for target in _assign_targets(child):
|
|
185
|
+
_record_def(defs, target, kind, _qualname(scope, target), child)
|
|
186
|
+
_walk(child, scope, defs, usages)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _record_def(defs, name, kind, qname, node):
|
|
190
|
+
line = getattr(node, "lineno", 0)
|
|
191
|
+
defs[name] = {"kind": kind, "line": line, "end_line": _end_line(node), "qname": qname}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _usage(node, name, kind, scope):
|
|
195
|
+
return {
|
|
196
|
+
"line": getattr(node, "lineno", 0),
|
|
197
|
+
"name": name,
|
|
198
|
+
"kind": kind,
|
|
199
|
+
"ctx_qname": _ctx_qname(scope),
|
|
200
|
+
"ctx_kind": _ctx_kind(scope),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _assign_targets(node):
|
|
205
|
+
if isinstance(node, ast.AnnAssign):
|
|
206
|
+
target = node.target
|
|
207
|
+
if isinstance(target, ast.Name):
|
|
208
|
+
return [target.id]
|
|
209
|
+
return []
|
|
210
|
+
targets = []
|
|
211
|
+
for target in node.targets:
|
|
212
|
+
if isinstance(target, ast.Name):
|
|
213
|
+
targets.append(target.id)
|
|
214
|
+
return targets
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _qualname(scope, name):
|
|
218
|
+
parts = [n for _kind, n in scope]
|
|
219
|
+
parts.append(name)
|
|
220
|
+
return ".".join(parts)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _ctx_qname(scope):
|
|
224
|
+
return ".".join([n for _kind, n in scope])
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _ctx_kind(scope):
|
|
228
|
+
if not scope:
|
|
229
|
+
return "module"
|
|
230
|
+
kind, _name = scope[-1]
|
|
231
|
+
if kind == "function":
|
|
232
|
+
if len(scope) >= 2 and scope[-2][0] == "class":
|
|
233
|
+
return "method"
|
|
234
|
+
return "function"
|
|
235
|
+
return "class"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _end_line(node):
|
|
239
|
+
end = getattr(node, "end_lineno", None)
|
|
240
|
+
if end is not None:
|
|
241
|
+
return end
|
|
242
|
+
best = getattr(node, "lineno", 0)
|
|
243
|
+
for child in ast.walk(node):
|
|
244
|
+
line = getattr(child, "lineno", None)
|
|
245
|
+
if line is not None and line > best:
|
|
246
|
+
best = line
|
|
247
|
+
return best
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _is_load(node):
|
|
251
|
+
return isinstance(getattr(node, "ctx", None), ast.Load)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _parse(src):
|
|
255
|
+
try:
|
|
256
|
+
return ast.parse(src)
|
|
257
|
+
except (SyntaxError, ValueError, TypeError):
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _content_hash(text):
|
|
262
|
+
return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _read_text(full):
|
|
266
|
+
try:
|
|
267
|
+
with open(full, "rb") as fh:
|
|
268
|
+
data = fh.read()
|
|
269
|
+
except (OSError, ValueError):
|
|
270
|
+
return None
|
|
271
|
+
return data.decode("utf-8", "replace")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _cache_data(cache):
|
|
275
|
+
if cache is None:
|
|
276
|
+
return None
|
|
277
|
+
if isinstance(cache, dict):
|
|
278
|
+
return cache
|
|
279
|
+
loader = getattr(cache, "load", None)
|
|
280
|
+
if callable(loader):
|
|
281
|
+
data = loader()
|
|
282
|
+
if isinstance(data, dict):
|
|
283
|
+
return data
|
|
284
|
+
return None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Render the impact report table, classify severity, and decide whether a push should be blocked.
|
|
2
|
+
|
|
3
|
+
Example: a changed view referenced from a template and a frontend service
|
|
4
|
+
gets High severity, while a reference inside the changed file gets Low.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import diffimpactscout.env as env
|
|
10
|
+
|
|
11
|
+
_HEADER = "| # | Impacted File Path | Module / Subsystem | Category | Detected Reference / Usage | Severity | Action Required |"
|
|
12
|
+
_SEPARATOR = "| --- | --- | --- | --- | --- | --- | --- |"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def classify_severity(layers, deleted_renamed, changed_paths, ref_path):
|
|
16
|
+
if deleted_renamed:
|
|
17
|
+
return "High"
|
|
18
|
+
if len(set(layers or ())) >= 2:
|
|
19
|
+
return "High"
|
|
20
|
+
if ref_path in (changed_paths or ()):
|
|
21
|
+
return "Low"
|
|
22
|
+
if not ref_path or not changed_paths:
|
|
23
|
+
return "Low"
|
|
24
|
+
return "Medium"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cell(row, key):
|
|
28
|
+
if isinstance(row, dict):
|
|
29
|
+
value = row.get(key)
|
|
30
|
+
else:
|
|
31
|
+
value = getattr(row, key, None)
|
|
32
|
+
if value is None:
|
|
33
|
+
return ""
|
|
34
|
+
return str(value).replace("|", "\\|")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _counts(rows):
|
|
38
|
+
counts = {"High": 0, "Medium": 0, "Low": 0}
|
|
39
|
+
for row in rows:
|
|
40
|
+
sev = _cell(row, "severity")
|
|
41
|
+
if sev in counts:
|
|
42
|
+
counts[sev] += 1
|
|
43
|
+
return counts
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_report(rows, unresolved, changed_count):
|
|
47
|
+
lines = []
|
|
48
|
+
lines.append("# Impact Analysis Report")
|
|
49
|
+
lines.append("")
|
|
50
|
+
lines.append(_HEADER)
|
|
51
|
+
lines.append(_SEPARATOR)
|
|
52
|
+
for i, row in enumerate(rows or [], start=1):
|
|
53
|
+
lines.append(
|
|
54
|
+
"| %d | %s | %s | %s | %s | %s | %s |"
|
|
55
|
+
% (
|
|
56
|
+
i,
|
|
57
|
+
_cell(row, "path"),
|
|
58
|
+
_cell(row, "module"),
|
|
59
|
+
_cell(row, "category"),
|
|
60
|
+
_cell(row, "ref"),
|
|
61
|
+
_cell(row, "severity"),
|
|
62
|
+
_cell(row, "action"),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
if unresolved:
|
|
66
|
+
lines.append("")
|
|
67
|
+
lines.append("## Unresolved references (manual check required)")
|
|
68
|
+
for ref in unresolved:
|
|
69
|
+
lines.append("- %s" % str(ref))
|
|
70
|
+
lines.append("")
|
|
71
|
+
counts = _counts(rows or [])
|
|
72
|
+
lines.append(
|
|
73
|
+
"Summary: %d changed file(s); High: %d, Medium: %d, Low: %d"
|
|
74
|
+
% (changed_count, counts["High"], counts["Medium"], counts["Low"])
|
|
75
|
+
)
|
|
76
|
+
return "\n".join(lines) + "\n"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def interactive_tty():
|
|
80
|
+
return sys.stdin.isatty()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def confirm(prompt_text):
|
|
84
|
+
sys.stdout.write(prompt_text + " ")
|
|
85
|
+
sys.stdout.flush()
|
|
86
|
+
answer = (sys.stdin.readline() or "").strip().lower()
|
|
87
|
+
if answer in ("", "y", "yes"):
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_true(value):
|
|
93
|
+
return env.is_true(value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _skip_requested():
|
|
97
|
+
return env.skip_requested()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def should_block(strict_env, tty):
|
|
101
|
+
if _skip_requested():
|
|
102
|
+
return (False, "skip env var set; impact check skipped")
|
|
103
|
+
if tty:
|
|
104
|
+
return (True, "interactive; prompt user to proceed")
|
|
105
|
+
if _is_true(strict_env):
|
|
106
|
+
return (True, "strict mode; blocking in non-interactive context")
|
|
107
|
+
return (False, "non-interactive; report shown as warning, commit not blocked")
|