lambda-watcher 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.
- lambda_watcher/__init__.py +4 -0
- lambda_watcher/__main__.py +4 -0
- lambda_watcher/analysis/__init__.py +115 -0
- lambda_watcher/analysis/deps.py +291 -0
- lambda_watcher/analysis/envvars.py +80 -0
- lambda_watcher/analysis/handler.py +111 -0
- lambda_watcher/analysis/inventory.py +118 -0
- lambda_watcher/analysis/runtime.py +117 -0
- lambda_watcher/analysis/secrets.py +178 -0
- lambda_watcher/analysis/services.py +76 -0
- lambda_watcher/cli.py +1406 -0
- lambda_watcher/config.py +324 -0
- lambda_watcher/db.py +466 -0
- lambda_watcher/diffing/__init__.py +14 -0
- lambda_watcher/diffing/build.py +51 -0
- lambda_watcher/diffing/compare.py +525 -0
- lambda_watcher/diffing/highlight.py +312 -0
- lambda_watcher/diffing/icons.py +132 -0
- lambda_watcher/diffing/intraline.py +162 -0
- lambda_watcher/diffing/render_html.py +697 -0
- lambda_watcher/diffing/render_text.py +198 -0
- lambda_watcher/extract.py +227 -0
- lambda_watcher/gitmirror.py +151 -0
- lambda_watcher/identify.py +201 -0
- lambda_watcher/ingest.py +480 -0
- lambda_watcher/notify.py +59 -0
- lambda_watcher/reindex.py +158 -0
- lambda_watcher/service.py +553 -0
- lambda_watcher/store.py +209 -0
- lambda_watcher/templates.py +124 -0
- lambda_watcher/utils.py +314 -0
- lambda_watcher/watcher.py +241 -0
- lambda_watcher-0.1.0.dist-info/METADATA +409 -0
- lambda_watcher-0.1.0.dist-info/RECORD +38 -0
- lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
- lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
- lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
- lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Compute a structured diff between two archived versions.
|
|
2
|
+
|
|
3
|
+
The output is deliberately layered, because "what changed" is rarely a
|
|
4
|
+
question about lines of text:
|
|
5
|
+
|
|
6
|
+
* the headline (runtime, handler, size, file counts)
|
|
7
|
+
* dependency changes, which explain most of the file churn in a zip
|
|
8
|
+
* environment variables and AWS services the code now needs
|
|
9
|
+
* new security findings
|
|
10
|
+
* and only then the per-file line diffs, first-party code first
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import difflib
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..config import DiffConfig
|
|
22
|
+
from ..utils import matches_any, read_text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FileRecord:
|
|
27
|
+
"""One file as recorded in the index."""
|
|
28
|
+
|
|
29
|
+
path: str
|
|
30
|
+
size: int
|
|
31
|
+
sha256: str
|
|
32
|
+
is_text: bool
|
|
33
|
+
is_vendor: bool
|
|
34
|
+
lang: str
|
|
35
|
+
lines: int
|
|
36
|
+
mode: int = 0o644
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_row(cls, row: Any) -> FileRecord:
|
|
40
|
+
return cls(
|
|
41
|
+
path=row["path"], size=int(row["size"]), sha256=row["sha256"],
|
|
42
|
+
is_text=bool(row["is_text"]), is_vendor=bool(row["is_vendor"]),
|
|
43
|
+
lang=row["lang"] or "text", lines=int(row["lines"]), mode=int(row["mode"] or 0o644),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class FileChange:
|
|
49
|
+
kind: str # added | removed | modified | renamed | mode-changed
|
|
50
|
+
path: str
|
|
51
|
+
old_path: str | None = None
|
|
52
|
+
old: FileRecord | None = None
|
|
53
|
+
new: FileRecord | None = None
|
|
54
|
+
diff_lines: list[str] = field(default_factory=list)
|
|
55
|
+
added_lines: int = 0
|
|
56
|
+
removed_lines: int = 0
|
|
57
|
+
truncated: bool = False
|
|
58
|
+
binary: bool = False
|
|
59
|
+
skipped_reason: str | None = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_vendor(self) -> bool:
|
|
63
|
+
record = self.new or self.old
|
|
64
|
+
return bool(record and record.is_vendor)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def size_delta(self) -> int:
|
|
68
|
+
return (self.new.size if self.new else 0) - (self.old.size if self.old else 0)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def lang(self) -> str:
|
|
72
|
+
record = self.new or self.old
|
|
73
|
+
return record.lang if record else "text"
|
|
74
|
+
|
|
75
|
+
def as_dict(self) -> dict:
|
|
76
|
+
return {
|
|
77
|
+
"kind": self.kind,
|
|
78
|
+
"path": self.path,
|
|
79
|
+
"old_path": self.old_path,
|
|
80
|
+
"added_lines": self.added_lines,
|
|
81
|
+
"removed_lines": self.removed_lines,
|
|
82
|
+
"size_delta": self.size_delta,
|
|
83
|
+
"binary": self.binary,
|
|
84
|
+
"truncated": self.truncated,
|
|
85
|
+
"is_vendor": self.is_vendor,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class DepChange:
|
|
91
|
+
kind: str # added | removed | changed
|
|
92
|
+
manager: str
|
|
93
|
+
name: str
|
|
94
|
+
old_version: str | None = None
|
|
95
|
+
new_version: str | None = None
|
|
96
|
+
is_declared: bool = False
|
|
97
|
+
|
|
98
|
+
def as_dict(self) -> dict:
|
|
99
|
+
return {
|
|
100
|
+
"kind": self.kind, "manager": self.manager, "name": self.name,
|
|
101
|
+
"old_version": self.old_version, "new_version": self.new_version,
|
|
102
|
+
"is_declared": self.is_declared,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class VersionDiff:
|
|
108
|
+
function_name: str
|
|
109
|
+
a_seq: int
|
|
110
|
+
b_seq: int
|
|
111
|
+
a_meta: dict[str, Any] = field(default_factory=dict)
|
|
112
|
+
b_meta: dict[str, Any] = field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
#: The two version directories the comparison read. Renderers that want to
|
|
115
|
+
#: colour a file need the file, not just the lines the diff quoted from it —
|
|
116
|
+
#: a docstring is only a docstring if you can see where it opened.
|
|
117
|
+
a_root: Path | None = None
|
|
118
|
+
b_root: Path | None = None
|
|
119
|
+
|
|
120
|
+
files: list[FileChange] = field(default_factory=list)
|
|
121
|
+
vendor_files_changed: int = 0
|
|
122
|
+
unchanged_files: int = 0
|
|
123
|
+
|
|
124
|
+
deps: list[DepChange] = field(default_factory=list)
|
|
125
|
+
env_added: list[str] = field(default_factory=list)
|
|
126
|
+
env_removed: list[str] = field(default_factory=list)
|
|
127
|
+
services_added: list[str] = field(default_factory=list)
|
|
128
|
+
services_removed: list[str] = field(default_factory=list)
|
|
129
|
+
findings_new: list[dict] = field(default_factory=list)
|
|
130
|
+
findings_fixed: list[dict] = field(default_factory=list)
|
|
131
|
+
|
|
132
|
+
runtime_change: tuple[str, str] | None = None
|
|
133
|
+
handler_change: tuple[str | None, str | None] | None = None
|
|
134
|
+
#: False when the caller asked for a summary only, so line counts are unknown
|
|
135
|
+
#: rather than zero.
|
|
136
|
+
diffs_computed: bool = True
|
|
137
|
+
|
|
138
|
+
# -- summary helpers -------------------------------------------------
|
|
139
|
+
def counts(self) -> dict[str, int]:
|
|
140
|
+
counts = {"added": 0, "removed": 0, "modified": 0, "renamed": 0, "mode-changed": 0}
|
|
141
|
+
for change in self.files:
|
|
142
|
+
counts[change.kind] = counts.get(change.kind, 0) + 1
|
|
143
|
+
return counts
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def total_added_lines(self) -> int:
|
|
147
|
+
return sum(c.added_lines for c in self.files)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def total_removed_lines(self) -> int:
|
|
151
|
+
return sum(c.removed_lines for c in self.files)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_empty(self) -> bool:
|
|
155
|
+
return not (
|
|
156
|
+
self.files or self.deps or self.env_added or self.env_removed
|
|
157
|
+
or self.services_added or self.services_removed or self.runtime_change
|
|
158
|
+
or self.handler_change or self.vendor_files_changed
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def headline(self) -> str:
|
|
162
|
+
counts = self.counts()
|
|
163
|
+
parts = [f"{counts[k]} {k}" for k in ("added", "removed", "modified", "renamed") if counts[k]]
|
|
164
|
+
if self.vendor_files_changed:
|
|
165
|
+
parts.append(f"{self.vendor_files_changed} vendored")
|
|
166
|
+
if not parts:
|
|
167
|
+
return "no file changes"
|
|
168
|
+
return ", ".join(parts)
|
|
169
|
+
|
|
170
|
+
def impact_line(self) -> str:
|
|
171
|
+
"""What the file counts cannot say: lines moved, and what a deploy now needs.
|
|
172
|
+
|
|
173
|
+
``headline`` answers "how much changed"; this answers "what does that
|
|
174
|
+
mean for me". Empty when there is nothing of the sort to report, so
|
|
175
|
+
callers can leave the line out rather than print a blank one.
|
|
176
|
+
"""
|
|
177
|
+
parts: list[str] = []
|
|
178
|
+
if self.diffs_computed and (self.total_added_lines or self.total_removed_lines):
|
|
179
|
+
parts.append(f"+{self.total_added_lines}/-{self.total_removed_lines} lines")
|
|
180
|
+
arrivals = [
|
|
181
|
+
f"{count} {noun}{'s' if count != 1 else ''}"
|
|
182
|
+
for count, noun in (
|
|
183
|
+
(len(self.env_added), "env var"),
|
|
184
|
+
(len(self.services_added), "AWS service"),
|
|
185
|
+
(len(self.findings_new), "secret"),
|
|
186
|
+
)
|
|
187
|
+
if count
|
|
188
|
+
]
|
|
189
|
+
if arrivals:
|
|
190
|
+
parts.append("new: " + ", ".join(arrivals))
|
|
191
|
+
return " · ".join(parts)
|
|
192
|
+
|
|
193
|
+
def summary_line(self) -> str:
|
|
194
|
+
"""Both halves at once, for somewhere with room for one line and no more."""
|
|
195
|
+
impact = self.impact_line()
|
|
196
|
+
return f"{self.headline()} · {impact}" if impact else self.headline()
|
|
197
|
+
|
|
198
|
+
def as_dict(self) -> dict:
|
|
199
|
+
return {
|
|
200
|
+
"function": self.function_name,
|
|
201
|
+
"from": self.a_seq,
|
|
202
|
+
"to": self.b_seq,
|
|
203
|
+
"counts": self.counts(),
|
|
204
|
+
"lines": {"added": self.total_added_lines, "removed": self.total_removed_lines},
|
|
205
|
+
"vendor_files_changed": self.vendor_files_changed,
|
|
206
|
+
"files": [c.as_dict() for c in self.files],
|
|
207
|
+
"dependencies": [d.as_dict() for d in self.deps],
|
|
208
|
+
"env_vars": {"added": self.env_added, "removed": self.env_removed},
|
|
209
|
+
"services": {"added": self.services_added, "removed": self.services_removed},
|
|
210
|
+
"runtime_change": self.runtime_change,
|
|
211
|
+
"handler_change": self.handler_change,
|
|
212
|
+
"findings_new": self.findings_new,
|
|
213
|
+
"findings_fixed": self.findings_fixed,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _index(records: Iterable[FileRecord]) -> dict[str, FileRecord]:
|
|
218
|
+
return {r.path: r for r in records}
|
|
219
|
+
|
|
220
|
+
# Pairing every candidate against every other is quadratic, so cap the work.
|
|
221
|
+
_MAX_RENAME_CANDIDATES = 150
|
|
222
|
+
_RENAME_THRESHOLD = 0.55
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _similarity_renames(
|
|
226
|
+
added: list[str],
|
|
227
|
+
removed: list[str],
|
|
228
|
+
old: dict[str, FileRecord],
|
|
229
|
+
new: dict[str, FileRecord],
|
|
230
|
+
old_root: Path,
|
|
231
|
+
new_root: Path,
|
|
232
|
+
cfg: DiffConfig,
|
|
233
|
+
) -> dict[str, str]:
|
|
234
|
+
"""Pair up added/removed files that look like the same file, moved and edited."""
|
|
235
|
+
if not added or not removed:
|
|
236
|
+
return {}
|
|
237
|
+
if len(added) > _MAX_RENAME_CANDIDATES or len(removed) > _MAX_RENAME_CANDIDATES:
|
|
238
|
+
return {}
|
|
239
|
+
|
|
240
|
+
max_bytes = cfg.max_diff_file_kb * 1024
|
|
241
|
+
cache: dict[tuple[str, str], list[str] | None] = {}
|
|
242
|
+
|
|
243
|
+
def lines_of(root: Path, record: FileRecord, side: str) -> list[str] | None:
|
|
244
|
+
key = (side, record.path)
|
|
245
|
+
if key not in cache:
|
|
246
|
+
if not record.is_text or record.size > max_bytes:
|
|
247
|
+
cache[key] = None
|
|
248
|
+
else:
|
|
249
|
+
text = read_text(root / record.path)
|
|
250
|
+
cache[key] = text.splitlines() if text is not None else None
|
|
251
|
+
return cache[key]
|
|
252
|
+
|
|
253
|
+
pairs: list[tuple[float, str, str]] = []
|
|
254
|
+
for new_path in added:
|
|
255
|
+
new_record = new[new_path]
|
|
256
|
+
new_lines = lines_of(new_root, new_record, "new")
|
|
257
|
+
if not new_lines:
|
|
258
|
+
continue
|
|
259
|
+
new_base = new_path.rsplit("/", 1)[-1]
|
|
260
|
+
for old_path in removed:
|
|
261
|
+
old_record = old[old_path]
|
|
262
|
+
if old_record.lang != new_record.lang:
|
|
263
|
+
continue
|
|
264
|
+
# Sizes an order of magnitude apart are not the same file.
|
|
265
|
+
if not (0.2 <= (new_record.size + 1) / (old_record.size + 1) <= 5):
|
|
266
|
+
continue
|
|
267
|
+
old_lines = lines_of(old_root, old_record, "old")
|
|
268
|
+
if not old_lines:
|
|
269
|
+
continue
|
|
270
|
+
matcher = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
|
|
271
|
+
if matcher.quick_ratio() < _RENAME_THRESHOLD:
|
|
272
|
+
continue
|
|
273
|
+
ratio = matcher.ratio()
|
|
274
|
+
if ratio < _RENAME_THRESHOLD:
|
|
275
|
+
continue
|
|
276
|
+
# A matching filename is strong corroboration for a plain move.
|
|
277
|
+
if old_path.rsplit("/", 1)[-1] == new_base:
|
|
278
|
+
ratio = min(1.0, ratio + 0.15)
|
|
279
|
+
pairs.append((ratio, new_path, old_path))
|
|
280
|
+
|
|
281
|
+
# Greedily take the strongest pairings first, one use per file.
|
|
282
|
+
pairs.sort(reverse=True)
|
|
283
|
+
used_old: set[str] = set()
|
|
284
|
+
used_new: set[str] = set()
|
|
285
|
+
matches: dict[str, str] = {}
|
|
286
|
+
for _ratio, new_path, old_path in pairs:
|
|
287
|
+
if new_path in used_new or old_path in used_old:
|
|
288
|
+
continue
|
|
289
|
+
matches[new_path] = old_path
|
|
290
|
+
used_new.add(new_path)
|
|
291
|
+
used_old.add(old_path)
|
|
292
|
+
return matches
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _unified_diff(
|
|
297
|
+
old_root: Path, new_root: Path, old: FileRecord | None, new: FileRecord | None, cfg: DiffConfig
|
|
298
|
+
) -> tuple[list[str], int, int, bool, str | None]:
|
|
299
|
+
"""Line diff for one file. Returns (lines, added, removed, truncated, skip_reason)."""
|
|
300
|
+
max_bytes = cfg.max_diff_file_kb * 1024
|
|
301
|
+
for record in (old, new):
|
|
302
|
+
if record is None:
|
|
303
|
+
continue
|
|
304
|
+
if not record.is_text:
|
|
305
|
+
return [], 0, 0, False, "binary"
|
|
306
|
+
if record.size > max_bytes:
|
|
307
|
+
return [], 0, 0, False, f"file larger than {cfg.max_diff_file_kb} KB"
|
|
308
|
+
|
|
309
|
+
old_text = read_text(old_root / old.path) if old else ""
|
|
310
|
+
new_text = read_text(new_root / new.path) if new else ""
|
|
311
|
+
if old_text is None or new_text is None:
|
|
312
|
+
return [], 0, 0, False, "not decodable as text"
|
|
313
|
+
|
|
314
|
+
old_lines = old_text.splitlines(keepends=True)
|
|
315
|
+
new_lines = new_text.splitlines(keepends=True)
|
|
316
|
+
diff = list(
|
|
317
|
+
difflib.unified_diff(
|
|
318
|
+
old_lines,
|
|
319
|
+
new_lines,
|
|
320
|
+
fromfile=f"a/{old.path}" if old else "/dev/null",
|
|
321
|
+
tofile=f"b/{new.path}" if new else "/dev/null",
|
|
322
|
+
n=cfg.context_lines,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
added = sum(1 for line in diff if line.startswith("+") and not line.startswith("+++"))
|
|
326
|
+
removed = sum(1 for line in diff if line.startswith("-") and not line.startswith("---"))
|
|
327
|
+
|
|
328
|
+
truncated = False
|
|
329
|
+
if len(diff) > cfg.max_diff_lines:
|
|
330
|
+
diff = diff[: cfg.max_diff_lines]
|
|
331
|
+
truncated = True
|
|
332
|
+
return [line.rstrip("\n") for line in diff], added, removed, truncated, None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _diff_deps(old_rows: list[Any], new_rows: list[Any]) -> list[DepChange]:
|
|
336
|
+
def key(row: Any) -> tuple[str, str, int]:
|
|
337
|
+
return (row["manager"], row["name"].lower(), int(row["is_declared"]))
|
|
338
|
+
|
|
339
|
+
old_map = {key(r): r for r in old_rows}
|
|
340
|
+
new_map = {key(r): r for r in new_rows}
|
|
341
|
+
changes: list[DepChange] = []
|
|
342
|
+
|
|
343
|
+
for k, row in new_map.items():
|
|
344
|
+
previous = old_map.get(k)
|
|
345
|
+
if previous is None:
|
|
346
|
+
changes.append(
|
|
347
|
+
DepChange("added", row["manager"], row["name"], None, row["version"], bool(k[2]))
|
|
348
|
+
)
|
|
349
|
+
elif (previous["version"] or "") != (row["version"] or ""):
|
|
350
|
+
changes.append(
|
|
351
|
+
DepChange("changed", row["manager"], row["name"], previous["version"],
|
|
352
|
+
row["version"], bool(k[2]))
|
|
353
|
+
)
|
|
354
|
+
for k, row in old_map.items():
|
|
355
|
+
if k not in new_map:
|
|
356
|
+
changes.append(
|
|
357
|
+
DepChange("removed", row["manager"], row["name"], row["version"], None, bool(k[2]))
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# A package usually appears twice - once declared in a manifest, once
|
|
361
|
+
# installed in the zip. When both tell the same story, show one row.
|
|
362
|
+
collapsed: list[DepChange] = []
|
|
363
|
+
by_identity: dict[tuple[str, str, str, str | None, str | None], list[DepChange]] = {}
|
|
364
|
+
for change in changes:
|
|
365
|
+
key = (change.manager, change.name.lower(), change.kind, change.old_version, change.new_version)
|
|
366
|
+
by_identity.setdefault(key, []).append(change)
|
|
367
|
+
for group in by_identity.values():
|
|
368
|
+
if len(group) > 1:
|
|
369
|
+
# Keep the installed row: it is what actually shipped.
|
|
370
|
+
installed = next((c for c in group if not c.is_declared), group[0])
|
|
371
|
+
collapsed.append(installed)
|
|
372
|
+
else:
|
|
373
|
+
collapsed.append(group[0])
|
|
374
|
+
|
|
375
|
+
order = {"added": 0, "changed": 1, "removed": 2}
|
|
376
|
+
collapsed.sort(key=lambda c: (order[c.kind], c.manager, c.name.lower()))
|
|
377
|
+
return collapsed
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _finding_key(row: Any) -> tuple:
|
|
381
|
+
return (row["kind"], row["path"], row["detail"])
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def compare_versions(
|
|
385
|
+
function_name: str,
|
|
386
|
+
a_seq: int,
|
|
387
|
+
b_seq: int,
|
|
388
|
+
a_files: list[Any],
|
|
389
|
+
b_files: list[Any],
|
|
390
|
+
a_root: Path,
|
|
391
|
+
b_root: Path,
|
|
392
|
+
cfg: DiffConfig,
|
|
393
|
+
a_deps: list[Any] | None = None,
|
|
394
|
+
b_deps: list[Any] | None = None,
|
|
395
|
+
a_env: list[Any] | None = None,
|
|
396
|
+
b_env: list[Any] | None = None,
|
|
397
|
+
a_services: list[Any] | None = None,
|
|
398
|
+
b_services: list[Any] | None = None,
|
|
399
|
+
a_findings: list[Any] | None = None,
|
|
400
|
+
b_findings: list[Any] | None = None,
|
|
401
|
+
a_meta: dict | None = None,
|
|
402
|
+
b_meta: dict | None = None,
|
|
403
|
+
include_vendor: bool | None = None,
|
|
404
|
+
compute_diffs: bool = True,
|
|
405
|
+
) -> VersionDiff:
|
|
406
|
+
"""Compare two indexed versions, reading file contents from disk."""
|
|
407
|
+
show_vendor = (not cfg.ignore_vendor) if include_vendor is None else include_vendor
|
|
408
|
+
|
|
409
|
+
old = _index(FileRecord.from_row(r) for r in a_files)
|
|
410
|
+
new = _index(FileRecord.from_row(r) for r in b_files)
|
|
411
|
+
|
|
412
|
+
result = VersionDiff(function_name, a_seq, b_seq, a_meta or {}, b_meta or {}, a_root, b_root)
|
|
413
|
+
result.diffs_computed = compute_diffs
|
|
414
|
+
|
|
415
|
+
added_paths = [p for p in new if p not in old]
|
|
416
|
+
removed_paths = [p for p in old if p not in new]
|
|
417
|
+
common_paths = [p for p in new if p in old]
|
|
418
|
+
|
|
419
|
+
# Rename detection: identical content under a different path.
|
|
420
|
+
removed_by_hash: dict[str, list[str]] = {}
|
|
421
|
+
for path in removed_paths:
|
|
422
|
+
removed_by_hash.setdefault(old[path].sha256, []).append(path)
|
|
423
|
+
|
|
424
|
+
renames: dict[str, str] = {} # new path -> old path
|
|
425
|
+
for path in added_paths:
|
|
426
|
+
bucket = removed_by_hash.get(new[path].sha256)
|
|
427
|
+
if bucket:
|
|
428
|
+
renames[path] = bucket.pop(0)
|
|
429
|
+
|
|
430
|
+
# Files that moved *and* changed are the interesting case: a rename plus an
|
|
431
|
+
# edit reads far better than an unrelated add and delete.
|
|
432
|
+
leftover_added = [p for p in added_paths if p not in renames]
|
|
433
|
+
leftover_removed = [p for p in removed_paths if p not in set(renames.values())]
|
|
434
|
+
renames.update(
|
|
435
|
+
_similarity_renames(leftover_added, leftover_removed, old, new, a_root, b_root, cfg)
|
|
436
|
+
)
|
|
437
|
+
renamed_old = set(renames.values())
|
|
438
|
+
|
|
439
|
+
changes: list[FileChange] = []
|
|
440
|
+
|
|
441
|
+
for path in sorted(added_paths):
|
|
442
|
+
record = new[path]
|
|
443
|
+
if path in renames:
|
|
444
|
+
changes.append(
|
|
445
|
+
FileChange("renamed", path, renames[path], old[renames[path]], record)
|
|
446
|
+
)
|
|
447
|
+
continue
|
|
448
|
+
if record.is_vendor and not show_vendor:
|
|
449
|
+
result.vendor_files_changed += 1
|
|
450
|
+
continue
|
|
451
|
+
changes.append(FileChange("added", path, None, None, record))
|
|
452
|
+
|
|
453
|
+
for path in sorted(removed_paths):
|
|
454
|
+
if path in renamed_old:
|
|
455
|
+
continue
|
|
456
|
+
record = old[path]
|
|
457
|
+
if record.is_vendor and not show_vendor:
|
|
458
|
+
result.vendor_files_changed += 1
|
|
459
|
+
continue
|
|
460
|
+
changes.append(FileChange("removed", path, None, record, None))
|
|
461
|
+
|
|
462
|
+
for path in sorted(common_paths):
|
|
463
|
+
before, after = old[path], new[path]
|
|
464
|
+
if before.sha256 == after.sha256:
|
|
465
|
+
if before.mode != after.mode:
|
|
466
|
+
changes.append(FileChange("mode-changed", path, None, before, after))
|
|
467
|
+
else:
|
|
468
|
+
result.unchanged_files += 1
|
|
469
|
+
continue
|
|
470
|
+
if after.is_vendor and not show_vendor:
|
|
471
|
+
result.vendor_files_changed += 1
|
|
472
|
+
continue
|
|
473
|
+
changes.append(FileChange("modified", path, None, before, after))
|
|
474
|
+
|
|
475
|
+
# Fill in the line diffs.
|
|
476
|
+
if compute_diffs:
|
|
477
|
+
for change in changes:
|
|
478
|
+
if change.kind == "mode-changed":
|
|
479
|
+
continue
|
|
480
|
+
if matches_any(change.path, cfg.ignore_globs):
|
|
481
|
+
change.skipped_reason = "ignored by config"
|
|
482
|
+
continue
|
|
483
|
+
lines, plus, minus, truncated, reason = _unified_diff(
|
|
484
|
+
a_root, b_root, change.old, change.new, cfg
|
|
485
|
+
)
|
|
486
|
+
change.diff_lines = lines
|
|
487
|
+
change.added_lines = plus
|
|
488
|
+
change.removed_lines = minus
|
|
489
|
+
change.truncated = truncated
|
|
490
|
+
change.skipped_reason = reason
|
|
491
|
+
change.binary = reason == "binary"
|
|
492
|
+
|
|
493
|
+
# First-party code first, then vendored; alphabetical within each group.
|
|
494
|
+
kind_order = {"modified": 0, "added": 1, "renamed": 2, "removed": 3, "mode-changed": 4}
|
|
495
|
+
changes.sort(key=lambda c: (c.is_vendor, kind_order.get(c.kind, 9), c.path))
|
|
496
|
+
result.files = changes
|
|
497
|
+
|
|
498
|
+
if a_deps is not None and b_deps is not None:
|
|
499
|
+
result.deps = _diff_deps(a_deps, b_deps)
|
|
500
|
+
|
|
501
|
+
if a_env is not None and b_env is not None:
|
|
502
|
+
old_env = {r["name"] for r in a_env}
|
|
503
|
+
new_env = {r["name"] for r in b_env}
|
|
504
|
+
result.env_added = sorted(new_env - old_env)
|
|
505
|
+
result.env_removed = sorted(old_env - new_env)
|
|
506
|
+
|
|
507
|
+
if a_services is not None and b_services is not None:
|
|
508
|
+
old_services = {r["service"] for r in a_services}
|
|
509
|
+
new_services = {r["service"] for r in b_services}
|
|
510
|
+
result.services_added = sorted(new_services - old_services)
|
|
511
|
+
result.services_removed = sorted(old_services - new_services)
|
|
512
|
+
|
|
513
|
+
if a_findings is not None and b_findings is not None:
|
|
514
|
+
old_keys = {_finding_key(r) for r in a_findings}
|
|
515
|
+
new_keys = {_finding_key(r) for r in b_findings}
|
|
516
|
+
result.findings_new = [dict(r) for r in b_findings if _finding_key(r) not in old_keys]
|
|
517
|
+
result.findings_fixed = [dict(r) for r in a_findings if _finding_key(r) not in new_keys]
|
|
518
|
+
|
|
519
|
+
if a_meta and b_meta:
|
|
520
|
+
if a_meta.get("runtime") != b_meta.get("runtime"):
|
|
521
|
+
result.runtime_change = (a_meta.get("runtime") or "?", b_meta.get("runtime") or "?")
|
|
522
|
+
if a_meta.get("handler") != b_meta.get("handler"):
|
|
523
|
+
result.handler_change = (a_meta.get("handler"), b_meta.get("handler"))
|
|
524
|
+
|
|
525
|
+
return result
|