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,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")