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.
Files changed (38) hide show
  1. lambda_watcher/__init__.py +4 -0
  2. lambda_watcher/__main__.py +4 -0
  3. lambda_watcher/analysis/__init__.py +115 -0
  4. lambda_watcher/analysis/deps.py +291 -0
  5. lambda_watcher/analysis/envvars.py +80 -0
  6. lambda_watcher/analysis/handler.py +111 -0
  7. lambda_watcher/analysis/inventory.py +118 -0
  8. lambda_watcher/analysis/runtime.py +117 -0
  9. lambda_watcher/analysis/secrets.py +178 -0
  10. lambda_watcher/analysis/services.py +76 -0
  11. lambda_watcher/cli.py +1406 -0
  12. lambda_watcher/config.py +324 -0
  13. lambda_watcher/db.py +466 -0
  14. lambda_watcher/diffing/__init__.py +14 -0
  15. lambda_watcher/diffing/build.py +51 -0
  16. lambda_watcher/diffing/compare.py +525 -0
  17. lambda_watcher/diffing/highlight.py +312 -0
  18. lambda_watcher/diffing/icons.py +132 -0
  19. lambda_watcher/diffing/intraline.py +162 -0
  20. lambda_watcher/diffing/render_html.py +697 -0
  21. lambda_watcher/diffing/render_text.py +198 -0
  22. lambda_watcher/extract.py +227 -0
  23. lambda_watcher/gitmirror.py +151 -0
  24. lambda_watcher/identify.py +201 -0
  25. lambda_watcher/ingest.py +480 -0
  26. lambda_watcher/notify.py +59 -0
  27. lambda_watcher/reindex.py +158 -0
  28. lambda_watcher/service.py +553 -0
  29. lambda_watcher/store.py +209 -0
  30. lambda_watcher/templates.py +124 -0
  31. lambda_watcher/utils.py +314 -0
  32. lambda_watcher/watcher.py +241 -0
  33. lambda_watcher-0.1.0.dist-info/METADATA +409 -0
  34. lambda_watcher-0.1.0.dist-info/RECORD +38 -0
  35. lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
  36. lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
  37. lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,697 @@
1
+ """Self-contained HTML report for a version diff.
2
+
3
+ No JavaScript frameworks, no CDN, no network: one file you can open, keep,
4
+ attach to a change ticket, or send to a colleague.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ import re
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from ..utils import format_ts, human_size, read_text, rename_label, signed
17
+ from . import icons, intraline
18
+ from .compare import FileChange, VersionDiff
19
+ from .highlight import highlight, highlight_lines, language_of
20
+
21
+ _HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
22
+
23
+ ICON_CSS = icons.css()
24
+
25
+ #: A file's lines as ``(source, highlighted)`` pairs — see `_paint`.
26
+ _Painted = list[tuple[str, str]] | None
27
+
28
+ CSS = """
29
+ :root {
30
+ color-scheme: light dark;
31
+ --bg: #ffffff; --panel: #fafbfc; --sunken: #f1f4f7; --border: #e2e6eb;
32
+ --rule: #eceff3; --text: #12161b; --muted: #5b6672; --faint: #8a939f;
33
+ --accent: #0a58ca; --accent-wash: #eaf1fd; --accent-edge: #cfe0fa;
34
+ --add-bg: #e9f7ee; --add-word: #b4ecc6; --add-gutter: #d5efdd; --add-fg: #0a6634;
35
+ --del-bg: #fdedef; --del-word: #f8c4cb; --del-gutter: #f7d5da; --del-fg: #96162a;
36
+ --warn-bg: #f7ecd2; --warn-fg: #6b4a06;
37
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
38
+ /* Syntax tokens, One Light. Numbers and constants share a colour on purpose:
39
+ both are literal values, and the eye reads them as the same thing. */
40
+ --tk-c: #8b8f97; --tk-k: #a626a4; --tk-s: #50a14f; --tk-n: #986801;
41
+ --tk-t: #986801; --tk-f: #4078f2; --tk-y: #0184bc;
42
+ }
43
+ @media (prefers-color-scheme: dark) {
44
+ :root {
45
+ --bg: #0d1117; --panel: #12171e; --sunken: #1a212a; --border: #262d36;
46
+ --rule: #1e242c; --text: #e3e9ef; --muted: #96a0ac; --faint: #6e7885;
47
+ --accent: #6cb0ff; --accent-wash: #16253c; --accent-edge: #294869;
48
+ --add-bg: #0e2417; --add-word: #1f5c34; --add-gutter: #14311f; --add-fg: #6ddb92;
49
+ --del-bg: #2a1319; --del-word: #6d2029; --del-gutter: #3b181f; --del-fg: #ff949e;
50
+ --warn-bg: #3a2d10; --warn-fg: #e6c169;
51
+ --tk-c: #7f848e; --tk-k: #c678dd; --tk-s: #98c379; --tk-n: #d19a66;
52
+ --tk-t: #d19a66; --tk-f: #61afef; --tk-y: #56b6c2;
53
+ }
54
+ }
55
+ * { box-sizing: border-box; }
56
+ body {
57
+ margin: 0; background: var(--bg); color: var(--text);
58
+ font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
59
+ -webkit-font-smoothing: antialiased;
60
+ }
61
+ a { color: var(--accent); }
62
+ .wrap { max-width: 1140px; margin: 0 auto; padding: 32px 24px 96px; }
63
+
64
+ /* ---- header ---------------------------------------------------------- */
65
+ header.top { padding-bottom: 18px; margin-bottom: 24px; border-bottom: 1px solid var(--border); }
66
+ h1 { font-size: 22px; font-weight: 650; margin: 0; letter-spacing: -0.015em;
67
+ display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
68
+ h1 .ver { font-family: var(--mono); font-size: 14px; font-weight: 600; letter-spacing: 0;
69
+ color: var(--accent); font-variant-numeric: tabular-nums;
70
+ background: var(--accent-wash); border: 1px solid var(--accent-edge);
71
+ border-radius: 5px; padding: 2px 8px; white-space: nowrap; }
72
+ h1 .ver .arrow { color: var(--faint); padding: 0 5px; font-weight: 400; }
73
+ .sub { color: var(--muted); font-size: 13px; }
74
+ header.top .sub { margin-top: 9px; }
75
+
76
+ /* A heading that carries a hairline to the end of the measure: the sections
77
+ read as sections without a box drawn round each one. */
78
+ h2 { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: .07em;
79
+ color: var(--muted); margin: 32px 0 12px; display: flex; align-items: center; gap: 12px; }
80
+ h2::after { content: ""; flex: 1; height: 1px; background: var(--rule); }
81
+
82
+ /* ---- summary rail ---------------------------------------------------- */
83
+ /* One panel divided by hairlines rather than six floating cards. These numbers
84
+ are meant to be read across, and separate boxes put a gutter between every
85
+ pair of them. */
86
+ .stats { display: flex; flex-wrap: wrap; border: 1px solid var(--border); border-radius: 10px;
87
+ background: var(--panel); overflow: hidden; }
88
+ .stat { flex: 1 1 150px; padding: 11px 16px; border-left: 1px solid var(--border); min-width: 0; }
89
+ .stat:first-child { border-left: none; }
90
+ .stat .v { font-size: 19px; font-weight: 620; letter-spacing: -0.02em;
91
+ font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 7px; }
92
+ .stat .v .delta { font-size: 12px; font-weight: 500; color: var(--muted);
93
+ letter-spacing: 0; white-space: nowrap; }
94
+ .stat .k { color: var(--muted); font-size: 12px; margin-top: 1px; }
95
+ .stat .k .hint { color: var(--faint); }
96
+ .add { color: var(--add-fg); } .del { color: var(--del-fg); }
97
+
98
+ /* ---- tables ---------------------------------------------------------- */
99
+ .scroll { overflow-x: auto; }
100
+ table.grid { border-collapse: collapse; font-size: 13px; width: 100%; }
101
+ table.grid th { text-align: left; color: var(--faint); font-weight: 600; font-size: 11px;
102
+ text-transform: uppercase; letter-spacing: .06em; padding: 0 20px 6px 0;
103
+ border-bottom: 1px solid var(--border); white-space: nowrap; }
104
+ table.grid td { padding: 7px 20px 7px 0; border-bottom: 1px solid var(--rule);
105
+ vertical-align: top; white-space: nowrap; }
106
+ table.grid th:last-child, table.grid td:last-child { padding-right: 0; width: 100%;
107
+ white-space: normal; }
108
+ table.grid tr:last-child td { border-bottom: none; }
109
+ table.grid tbody tr:hover td { background: var(--panel); }
110
+ table.grid td.label { width: 210px; color: var(--muted); }
111
+ .mono { font-family: var(--mono); font-size: 12.5px; }
112
+ .num { font-variant-numeric: tabular-nums; text-align: right; }
113
+
114
+ /* ---- labels ---------------------------------------------------------- */
115
+ /* Two different things wear a label here, so they are drawn differently: a
116
+ `chip` classifies a row, a `tok` *is* a name out of the code. */
117
+ .chip { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10.5px;
118
+ font-weight: 650; text-transform: uppercase; letter-spacing: .04em; white-space: nowrap;
119
+ background: var(--sunken); color: var(--muted); }
120
+ .chip.added { background: var(--add-gutter); color: var(--add-fg); }
121
+ .chip.removed { background: var(--del-gutter); color: var(--del-fg); }
122
+ .chip.modified { background: var(--accent-wash); color: var(--accent); }
123
+ .chip.high { background: var(--del-gutter); color: var(--del-fg); }
124
+ .chip.medium { background: var(--warn-bg); color: var(--warn-fg); }
125
+ .tok { display: inline-block; font-family: var(--mono); font-size: 12px; padding: 1px 7px;
126
+ border-radius: 4px; background: var(--sunken); border: 1px solid var(--border); }
127
+ .tok.added { background: var(--add-bg); border-color: var(--add-gutter); color: var(--add-fg); }
128
+ .tok.removed { background: var(--del-bg); border-color: var(--del-gutter); color: var(--del-fg); }
129
+
130
+ /* ---- toolbar --------------------------------------------------------- */
131
+ .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;
132
+ position: sticky; top: 0; background: var(--bg); padding: 10px 0; z-index: 5;
133
+ box-shadow: 0 1px 0 var(--border); }
134
+ .toolbar #shown-count { margin-left: auto; white-space: nowrap; flex: 0 0 auto; color: var(--faint); }
135
+ .toolbar input[type=search] { flex: 1 1 220px; min-width: 140px; padding: 6px 10px; border-radius: 6px;
136
+ border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 13px; }
137
+ .toolbar input[type=search]:focus { outline: none; border-color: var(--accent); }
138
+ .toolbar label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px;
139
+ align-items: center; cursor: pointer; user-select: none; }
140
+ .toolbar button { padding: 6px 11px; border-radius: 6px; border: 1px solid var(--border);
141
+ background: var(--panel); color: var(--muted); font-size: 13px; cursor: pointer; }
142
+ .toolbar button:hover { border-color: var(--faint); color: var(--text); }
143
+
144
+ /* ---- one file -------------------------------------------------------- */
145
+ details.file { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px;
146
+ background: var(--panel); }
147
+ details.file[open] { background: var(--bg); }
148
+ details.file > summary { cursor: pointer; padding: 8px 12px; display: flex; gap: 10px;
149
+ align-items: center; list-style: none; font-size: 13px; border-radius: 7px; }
150
+ details.file > summary::-webkit-details-marker { display: none; }
151
+ details.file > summary:hover { background: var(--panel); }
152
+ /* The header stays put while a long file scrolls past, so the path is still
153
+ readable eighty lines into its own diff. */
154
+ details.file[open] > summary { position: sticky; top: var(--toolbar-h, 54px); z-index: 3;
155
+ background: var(--panel);
156
+ border-bottom: 1px solid var(--border); border-radius: 7px 7px 0 0; }
157
+ summary .path { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
158
+ summary .path .p { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
159
+ /* The tint hugs the changed part exactly — padding here would open a gap in
160
+ the middle of a path and read as though the name contained a space. */
161
+ summary .path .ren { background: var(--sunken); border-radius: 3px; }
162
+ summary .path .was { color: var(--faint); }
163
+ summary .stat-line { font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap;
164
+ display: flex; gap: 8px; align-items: baseline; color: var(--faint); }
165
+
166
+ /* ---- the diff itself ------------------------------------------------- */
167
+ .diff { overflow-x: auto; border-top: 1px solid var(--border); }
168
+ details.file[open] > .diff { border-top: none; }
169
+ .diff table { border-collapse: collapse; width: 100%; font-family: var(--mono);
170
+ font-size: 12.5px; line-height: 1.5; }
171
+ .diff td { padding: 0 8px; white-space: pre; vertical-align: top; }
172
+ .diff td.ln { width: 1%; min-width: 40px; padding: 0 8px; text-align: right; color: var(--faint);
173
+ user-select: none; background: var(--panel); font-variant-numeric: tabular-nums; }
174
+ .diff td.ln + td.ln { border-right: 1px solid var(--border); }
175
+ /* The sign lives in its own unselectable cell so that copying a block of the
176
+ diff yields the code, not code with markers glued on. It doubles as the
177
+ spine marking how far an added or removed run reaches. */
178
+ .diff td.mark { width: 1%; padding: 0 4px 0 6px; text-align: center; color: var(--faint);
179
+ user-select: none; border-left: 2px solid transparent; }
180
+ .diff td.code { padding-left: 4px; width: 100%; }
181
+ .diff tr.add td.code, .diff tr.add td.mark { background: var(--add-bg); }
182
+ .diff tr.add td.mark { color: var(--add-fg); border-left-color: var(--add-fg); }
183
+ .diff tr.add td.ln { background: var(--add-gutter); color: var(--add-fg); }
184
+ .diff tr.del td.code, .diff tr.del td.mark { background: var(--del-bg); }
185
+ .diff tr.del td.mark { color: var(--del-fg); border-left-color: var(--del-fg); }
186
+ .diff tr.del td.ln { background: var(--del-gutter); color: var(--del-fg); }
187
+ .diff tr.hunk td { background: var(--sunken); color: var(--faint); font-size: 11.5px;
188
+ padding: 4px 10px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
189
+ .diff tr.hunk:first-child td { border-top: none; }
190
+ /* Which words of the line actually changed. This sits on top of the row wash,
191
+ so it has to be a step stronger than it in both themes. */
192
+ .wd { border-radius: 3px; }
193
+ tr.add .wd { background: var(--add-word); }
194
+ tr.del .wd { background: var(--del-word); }
195
+ .tk-c { color: var(--tk-c); font-style: italic; }
196
+ .tk-k { color: var(--tk-k); }
197
+ .tk-s { color: var(--tk-s); }
198
+ .tk-n { color: var(--tk-n); }
199
+ .tk-t { color: var(--tk-t); }
200
+ .tk-f { color: var(--tk-f); }
201
+ .tk-y { color: var(--tk-y); }
202
+ .note { color: var(--muted); font-size: 12.5px; padding: 10px 12px; border-top: 1px solid var(--border); }
203
+ .note code { font-family: var(--mono); font-size: 12px; background: var(--sunken);
204
+ padding: 1px 5px; border-radius: 4px; }
205
+ .empty { color: var(--muted); padding: 16px 0; }
206
+ footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border);
207
+ color: var(--faint); font-size: 12px; line-height: 1.7; }
208
+ .hidden { display: none !important; }
209
+ """
210
+
211
+ JS = """
212
+ (function () {
213
+ var search = document.getElementById('filter');
214
+ var vendorToggle = document.getElementById('vendor');
215
+ var files = Array.prototype.slice.call(document.querySelectorAll('details.file'));
216
+
217
+ function apply() {
218
+ var term = (search.value || '').toLowerCase();
219
+ var showVendor = vendorToggle ? vendorToggle.checked : true;
220
+ var shown = 0;
221
+ files.forEach(function (el) {
222
+ var path = (el.getAttribute('data-path') || '').toLowerCase();
223
+ var isVendor = el.getAttribute('data-vendor') === '1';
224
+ var ok = (!term || path.indexOf(term) !== -1) && (showVendor || !isVendor);
225
+ el.classList.toggle('hidden', !ok);
226
+ if (ok) shown++;
227
+ });
228
+ var counter = document.getElementById('shown-count');
229
+ if (counter) counter.textContent = shown + ' of ' + files.length + ' files shown';
230
+ }
231
+
232
+ if (search) search.addEventListener('input', apply);
233
+ if (vendorToggle) vendorToggle.addEventListener('change', apply);
234
+
235
+ var toolbar = document.querySelector('.toolbar');
236
+ function measure() {
237
+ if (!toolbar) return;
238
+ document.documentElement.style.setProperty('--toolbar-h', toolbar.offsetHeight + 'px');
239
+ }
240
+ window.addEventListener('resize', measure);
241
+ measure();
242
+
243
+ var expand = document.getElementById('expand-all');
244
+ var collapse = document.getElementById('collapse-all');
245
+ if (expand) expand.addEventListener('click', function () {
246
+ files.forEach(function (f) { if (!f.classList.contains('hidden')) f.open = true; });
247
+ });
248
+ if (collapse) collapse.addEventListener('click', function () {
249
+ files.forEach(function (f) { f.open = false; });
250
+ });
251
+ apply();
252
+ })();
253
+ """
254
+
255
+
256
+ def _esc(value: Any) -> str:
257
+ return html.escape("" if value is None else str(value), quote=True)
258
+
259
+
260
+ @dataclass
261
+ class _Row:
262
+ css: str
263
+ old_no: str
264
+ new_no: str
265
+ text: str
266
+
267
+
268
+ def _diff_rows(change: FileChange) -> list[_Row]:
269
+ """Turn unified-diff lines into numbered rows."""
270
+ rows: list[_Row] = []
271
+ old_no = new_no = 0
272
+ for line in change.diff_lines:
273
+ if line.startswith("+++") or line.startswith("---"):
274
+ continue
275
+ hunk = _HUNK.match(line)
276
+ if hunk:
277
+ old_no = int(hunk.group(1))
278
+ new_no = int(hunk.group(3))
279
+ rows.append(_Row("hunk", "", "", line))
280
+ continue
281
+ if line.startswith("+"):
282
+ rows.append(_Row("add", "", str(new_no), line[1:]))
283
+ new_no += 1
284
+ elif line.startswith("-"):
285
+ rows.append(_Row("del", str(old_no), "", line[1:]))
286
+ old_no += 1
287
+ elif line.startswith("\\"):
288
+ rows.append(_Row("meta", "", "", line))
289
+ else:
290
+ rows.append(_Row("ctx", str(old_no), str(new_no), line[1:] if line else ""))
291
+ old_no += 1
292
+ new_no += 1
293
+ return rows
294
+
295
+
296
+ def _intraline(rows: list[_Row]) -> dict[int, list[tuple[int, int]]]:
297
+ """Character ranges worth marking, keyed by the row they belong to.
298
+
299
+ Only a removed run with an added run immediately after it is considered:
300
+ that is what a rewrite looks like in a unified diff. An added block with
301
+ nothing before it is a line arriving, not a line changing, and has no
302
+ counterpart to compare it against.
303
+ """
304
+ spans: dict[int, list[tuple[int, int]]] = {}
305
+ at = 0
306
+ while at < len(rows):
307
+ if rows[at].css != "del":
308
+ at += 1
309
+ continue
310
+ start = at
311
+ while at < len(rows) and rows[at].css == "del":
312
+ at += 1
313
+ middle = at
314
+ while at < len(rows) and rows[at].css == "add":
315
+ at += 1
316
+ removed = [r.text for r in rows[start:middle]]
317
+ added = [r.text for r in rows[middle:at]]
318
+ for old_i, new_i in intraline.pair_rows(removed, added):
319
+ before, after = intraline.word_diff(removed[old_i], added[new_i])
320
+ if before or after:
321
+ spans[start + old_i] = before
322
+ spans[middle + new_i] = after
323
+ return spans
324
+
325
+
326
+ def _paint(root: Path | None, path: str | None, lang: str) -> list[tuple[str, str]] | None:
327
+ """One file as ``(source line, highlighted line)`` pairs, or None if unreadable.
328
+
329
+ The plain half is kept so the caller can prove a row and the line it is about
330
+ to borrow colour from are the same text.
331
+ """
332
+ if root is None or not path:
333
+ return None
334
+ text = read_text(root / path)
335
+ if text is None:
336
+ return None
337
+ # strict: `highlight_lines` promises one entry per line. If that ever broke,
338
+ # every row below would borrow colour from its neighbour — loud beats subtle.
339
+ return list(zip(text.split("\n"), highlight_lines(text, lang), strict=True))
340
+
341
+
342
+ def _row_code(row: _Row, lang: str, old: _Painted, new: _Painted) -> str:
343
+ """The code cell for one diff row, coloured with the whole file in view.
344
+
345
+ A removed line belongs to the older version, an added or context line to the
346
+ newer one — which is why both sides are painted. Where the file cannot be
347
+ read, or the line the diff quoted is not the line sitting at that number any
348
+ more, this falls back to colouring the row on its own: worse colour on that
349
+ row, never colour borrowed from the wrong line.
350
+ """
351
+ if row.css == "meta": # `` is diff's note, not the file's
352
+ return _esc(row.text)
353
+ painted, number = (old, row.old_no) if row.css == "del" else (new, row.new_no)
354
+ if painted and number:
355
+ index = int(number) - 1
356
+ if 0 <= index < len(painted):
357
+ source, coloured = painted[index]
358
+ if source == row.text:
359
+ return coloured
360
+ return highlight(row.text, lang)
361
+
362
+
363
+ def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path | None = None) -> str:
364
+ lang = language_of(change.path, change.lang)
365
+ # A rename is one file, not two. Written out in full twice, the two paths
366
+ # are near-identical and the reader has to diff 90 characters by eye to
367
+ # find the part that moved; so only that part is written twice.
368
+ title = _esc(change.path)
369
+ if change.kind == "renamed" and change.old_path:
370
+ head, was, now, tail = rename_label(change.old_path, change.path)
371
+ title = (
372
+ f'{_esc(head)}<span class="ren"><span class="was">{_esc(was)}</span>'
373
+ f' → {_esc(now)}</span>{_esc(tail)}'
374
+ )
375
+ stat = ""
376
+ if change.added_lines:
377
+ stat += f'<span class="add">+{change.added_lines}</span>'
378
+ if change.removed_lines:
379
+ stat += f'<span class="del">−{change.removed_lines}</span>'
380
+ if change.size_delta:
381
+ stat += f"<span>{signed(change.size_delta)} B</span>"
382
+
383
+ parts = [
384
+ f'<details class="file" data-path="{_esc(change.path)} {_esc(change.old_path or "")}" '
385
+ f'data-vendor="{1 if change.is_vendor else 0}">',
386
+ "<summary>",
387
+ f'<span class="chip {_esc(change.kind)}">{_esc(change.kind)}</span>',
388
+ f'<span class="path">{icons.file_icon(change.path, lang)}'
389
+ f'<span class="p">{title}</span></span>',
390
+ f'<span class="stat-line">{stat}</span>',
391
+ "</summary>",
392
+ ]
393
+
394
+ if change.diff_lines:
395
+ old = _paint(a_root, change.old_path or change.path, lang) if change.old else None
396
+ new = _paint(b_root, change.path, lang) if change.new else None
397
+ rows = _diff_rows(change)
398
+ marks = _intraline(rows)
399
+ parts.append('<div class="diff"><table>')
400
+ for index, row in enumerate(rows):
401
+ if row.css == "hunk":
402
+ parts.append(f'<tr class="hunk"><td colspan="4">{_esc(row.text)}</td></tr>')
403
+ continue
404
+ css = f' class="{row.css}"' if row.css in ("add", "del") else ""
405
+ mark = {"add": "+", "del": "\u2212"}.get(row.css, "")
406
+ code = intraline.mark(_row_code(row, lang, old, new), marks.get(index, []))
407
+ parts.append(
408
+ f"<tr{css}>"
409
+ f'<td class="ln">{row.old_no}</td>'
410
+ f'<td class="ln">{row.new_no}</td>'
411
+ f'<td class="mark">{mark}</td>'
412
+ f'<td class="code">{code}</td>'
413
+ "</tr>"
414
+ )
415
+ parts.append("</table></div>")
416
+ if change.truncated:
417
+ parts.append('<div class="note">Diff truncated — raise <code>diff.max_diff_lines</code> to see the rest.</div>')
418
+ else:
419
+ reason = change.skipped_reason or (
420
+ "file is empty" if change.kind in {"added", "removed"} else "no textual change"
421
+ )
422
+ old_size = change.old.size if change.old else 0
423
+ new_size = change.new.size if change.new else 0
424
+ parts.append(
425
+ f'<div class="note">No line diff shown ({_esc(reason)}). '
426
+ f"{human_size(old_size)} → {human_size(new_size)}.</div>"
427
+ )
428
+
429
+ parts.append("</details>")
430
+ return "\n".join(parts)
431
+
432
+
433
+ def _stats(diff: VersionDiff) -> str:
434
+ """The handful of numbers worth reading before opening a single file."""
435
+ counts = diff.counts()
436
+ size_a = diff.a_meta.get("total_size", 0)
437
+ size_b = diff.b_meta.get("total_size", 0)
438
+ lines = (
439
+ f'<span class="add">+{diff.total_added_lines}</span>'
440
+ f'<span class="del">\u2212{diff.total_removed_lines}</span>'
441
+ if diff.diffs_computed
442
+ else '<span class="delta">not computed</span>'
443
+ )
444
+
445
+ # (value markup, label, what rides beside the value, what rides after the
446
+ # label). Both asides stay on their own line's baseline, so every cell of
447
+ # the rail is exactly two lines tall whatever it has to say.
448
+ stats: list[tuple[str, str, str, str]] = [
449
+ (str(sum(counts.values())), "files changed", "", ""),
450
+ (lines, "lines", "", ""),
451
+ (str(len(diff.deps)), "dependencies", "", ""),
452
+ (_esc(human_size(size_b)), "package size", f"{signed(size_b - size_a)} B", ""),
453
+ ]
454
+ if diff.findings_new:
455
+ stats.append((str(len(diff.findings_new)), "new findings", "", ""))
456
+ if diff.vendor_files_changed:
457
+ stats.append((str(diff.vendor_files_changed), "vendored files", "", "not shown"))
458
+
459
+ cells: list[str] = []
460
+ for value, label, delta, hint in stats:
461
+ beside = f'<span class="delta">{_esc(delta)}</span>' if delta else ""
462
+ after = f' <span class="hint">{_esc(hint)}</span>' if hint else ""
463
+ cells.append(
464
+ f'<div class="stat"><div class="v">{value}{beside}</div>'
465
+ f'<div class="k">{_esc(label)}{after}</div></div>'
466
+ )
467
+ return f'<div class="stats">{"".join(cells)}</div>'
468
+
469
+
470
+ def _dep_table(diff: VersionDiff) -> str:
471
+ if not diff.deps:
472
+ return ""
473
+ rows = []
474
+ for change in diff.deps:
475
+ rows.append(
476
+ "<tr>"
477
+ f'<td><span class="chip {_esc(change.kind)}">{_esc(change.kind)}</span></td>'
478
+ f'<td class="mono">{_esc(change.name)}</td>'
479
+ f'<td class="mono del">{_esc(change.old_version or "—")}</td>'
480
+ f'<td class="mono add">{_esc(change.new_version or "—")}</td>'
481
+ f"<td>{_esc(change.manager)}</td>"
482
+ f'<td>{"declared" if change.is_declared else "installed"}</td>'
483
+ "</tr>"
484
+ )
485
+ return (
486
+ "<h2>Dependencies</h2><div class='scroll'><table class='grid'>"
487
+ "<thead><tr><th></th><th>package</th><th>from</th><th>to</th>"
488
+ "<th>manager</th><th>origin</th></tr></thead>"
489
+ f"<tbody>{''.join(rows)}</tbody></table></div>"
490
+ )
491
+
492
+
493
+ def _context_section(diff: VersionDiff) -> str:
494
+ blocks: list[str] = []
495
+
496
+ def listing(label: str, values: list[str], css: str, hint: str = "") -> str:
497
+ # These are identifiers out of the function's own code, so they are set
498
+ # in the same face the diff sets them in — not as prose in a sentence.
499
+ if not values:
500
+ return ""
501
+ chips = " ".join(f'<span class="tok {css}">{_esc(v)}</span>' for v in values)
502
+ hint_html = f'<div class="sub" style="margin-top:6px">{_esc(hint)}</div>' if hint else ""
503
+ return f"<tr><td class='label'>{_esc(label)}</td><td>{chips}{hint_html}</td></tr>"
504
+
505
+ rows = "".join(
506
+ [
507
+ listing(
508
+ "Environment variables added", diff.env_added, "added",
509
+ "These must exist in the function's environment configuration before you deploy.",
510
+ ),
511
+ listing("Environment variables removed", diff.env_removed, "removed"),
512
+ listing(
513
+ "AWS services added", diff.services_added, "added",
514
+ "The execution role may need new IAM permissions.",
515
+ ),
516
+ listing("AWS services removed", diff.services_removed, "removed"),
517
+ ]
518
+ )
519
+ if rows:
520
+ blocks.append(
521
+ f"<h2>Configuration impact</h2><table class='grid'><tbody>{rows}</tbody></table>"
522
+ )
523
+
524
+ if diff.runtime_change or diff.handler_change:
525
+ entries = []
526
+ if diff.runtime_change:
527
+ entries.append(
528
+ f"<tr><td class='label'>Runtime</td><td class='mono'>"
529
+ f"{_esc(diff.runtime_change[0])} → {_esc(diff.runtime_change[1])}</td></tr>"
530
+ )
531
+ if diff.handler_change:
532
+ before, after = diff.handler_change
533
+ entries.append(
534
+ f"<tr><td class='label'>Handler</td><td class='mono'>"
535
+ f"{_esc(before or '?')} → {_esc(after or '?')}</td></tr>"
536
+ )
537
+ blocks.append(f"<h2>Entry point</h2><table class='grid'><tbody>{''.join(entries)}</tbody></table>")
538
+
539
+ return "".join(blocks)
540
+
541
+
542
+ def _findings_section(diff: VersionDiff) -> str:
543
+ if not diff.findings_new and not diff.findings_fixed:
544
+ return ""
545
+ rows = []
546
+ for finding in diff.findings_new:
547
+ rows.append(
548
+ "<tr>"
549
+ f'<td><span class="chip {_esc(finding["severity"])}">{_esc(finding["severity"])}</span></td>'
550
+ f'<td>{_esc(finding["kind"])}</td>'
551
+ f'<td class="mono">{_esc(finding["path"])}:{_esc(finding["line"])}</td>'
552
+ f'<td class="mono">{_esc(finding["detail"])}</td>'
553
+ "</tr>"
554
+ )
555
+ resolved = (
556
+ f'<div class="sub" style="margin-top:8px">{len(diff.findings_fixed)} finding(s) '
557
+ "present in the older version are gone.</div>"
558
+ if diff.findings_fixed
559
+ else ""
560
+ )
561
+ table = (
562
+ "<div class='scroll'><table class='grid'><thead><tr><th>severity</th><th>kind</th>"
563
+ f"<th>where</th><th>detail</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div>"
564
+ if rows
565
+ else ""
566
+ )
567
+ return f"<h2>New findings</h2>{table}{resolved}"
568
+
569
+
570
+ def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
571
+ """Render the full report as a single HTML document."""
572
+ title = f"{diff.function_name} · v{diff.a_seq:04d} → v{diff.b_seq:04d}"
573
+ a_when = format_ts(diff.a_meta.get("ingested_at"))
574
+ b_when = format_ts(diff.b_meta.get("ingested_at"))
575
+
576
+ file_blocks = "\n".join(_render_file(c, diff.a_root, diff.b_root) for c in diff.files)
577
+ if not diff.files:
578
+ file_blocks = '<div class="empty">No file-level changes between these versions.</div>'
579
+
580
+ vendor_toggle = (
581
+ '<label><input type="checkbox" id="vendor" checked> show vendored files</label>'
582
+ if any(c.is_vendor for c in diff.files)
583
+ else ""
584
+ )
585
+
586
+ return f"""<!DOCTYPE html>
587
+ <html lang="en">
588
+ <head>
589
+ <meta charset="utf-8">
590
+ <meta name="viewport" content="width=device-width, initial-scale=1">
591
+ <title>{_esc(title)}</title>
592
+ <style>{CSS}{ICON_CSS}</style>
593
+ </head>
594
+ <body>
595
+ {icons.sprite()}
596
+ <div class="wrap">
597
+ <header class="top">
598
+ <h1>{_esc(diff.function_name)}
599
+ <span class="ver">v{diff.a_seq:04d}<span class="arrow">→</span>v{diff.b_seq:04d}</span></h1>
600
+ <div class="sub">
601
+ {_esc(diff.headline())} · v{diff.a_seq:04d} archived {_esc(a_when)},
602
+ v{diff.b_seq:04d} archived {_esc(b_when)}
603
+ </div>
604
+ </header>
605
+
606
+ {_stats(diff)}
607
+ {_dep_table(diff)}
608
+ {_context_section(diff)}
609
+ {_findings_section(diff)}
610
+
611
+ <h2>File changes</h2>
612
+ <div class="toolbar">
613
+ <input type="search" id="filter" placeholder="Filter by path…" autocomplete="off">
614
+ {vendor_toggle}
615
+ <button type="button" id="expand-all">Expand all</button>
616
+ <button type="button" id="collapse-all">Collapse all</button>
617
+ <span class="sub" id="shown-count"></span>
618
+ </div>
619
+ {file_blocks}
620
+
621
+ <footer>
622
+ Generated by {_esc(generated_by)} on {_esc(datetime.now().strftime('%Y-%m-%d %H:%M'))}.
623
+ Content hashes ignore zip timestamps, so re-downloading unchanged code does not create a new version.
624
+ </footer>
625
+ </div>
626
+ <script>{JS}</script>
627
+ </body>
628
+ </html>
629
+ """
630
+
631
+
632
+ def write_html(diff: VersionDiff, path: Path, generated_by: str = "lambda-watcher") -> Path:
633
+ path.parent.mkdir(parents=True, exist_ok=True)
634
+ path.write_text(render_html(diff, generated_by), encoding="utf-8")
635
+ return path
636
+
637
+
638
+ def render_timeline(
639
+ function_name: str,
640
+ versions: list[dict[str, Any]],
641
+ generated_by: str = "lambda-watcher",
642
+ ) -> str:
643
+ """Index page: every archived version of one function, newest first.
644
+
645
+ ``versions`` entries carry the per-version stats plus ``diff_href`` /
646
+ ``diff_summary`` describing the step from the previous version.
647
+ """
648
+ rows: list[str] = []
649
+ for entry in versions:
650
+ seq = entry["seq"]
651
+ href = entry.get("diff_href")
652
+ step = (
653
+ f'<a href="{_esc(href)}">{_esc(entry.get("diff_summary") or "view diff")}</a>'
654
+ if href
655
+ else '<span class="sub">first version</span>'
656
+ )
657
+ label = f' <span class="chip">{_esc(entry["label"])}</span>' if entry.get("label") else ""
658
+ rows.append(
659
+ "<tr>"
660
+ f'<td class="mono"><strong>v{seq:04d}</strong>{label}</td>'
661
+ f'<td>{_esc(format_ts(entry.get("ingested_at")))}</td>'
662
+ f'<td class="mono">{_esc(entry.get("runtime") or "?")}</td>'
663
+ f'<td class="mono">{_esc(entry.get("handler") or "?")}</td>'
664
+ f'<td class="num">{entry.get("file_count", 0):,}</td>'
665
+ f'<td class="num">{_esc(human_size(entry.get("total_size", 0)))}</td>'
666
+ f'<td class="mono sub">{_esc(str(entry.get("source_name") or ""))}</td>'
667
+ f"<td>{step}</td>"
668
+ "</tr>"
669
+ )
670
+
671
+ return f"""<!DOCTYPE html>
672
+ <html lang="en">
673
+ <head>
674
+ <meta charset="utf-8">
675
+ <meta name="viewport" content="width=device-width, initial-scale=1">
676
+ <title>{_esc(function_name)} · version history</title>
677
+ <style>{CSS}</style>
678
+ </head>
679
+ <body>
680
+ <div class="wrap">
681
+ <header class="top">
682
+ <h1>{_esc(function_name)}</h1>
683
+ <div class="sub">{len(versions)} archived version(s) · newest first</div>
684
+ </header>
685
+ <div class="scroll"><table class="grid">
686
+ <thead><tr>
687
+ <th>version</th><th>archived</th><th>runtime</th><th>handler</th>
688
+ <th class="num">files</th><th class="num">size</th>
689
+ <th>downloaded as</th><th>change from previous</th>
690
+ </tr></thead>
691
+ <tbody>{''.join(rows)}</tbody>
692
+ </table></div>
693
+ <footer>Generated by {_esc(generated_by)} on {_esc(datetime.now().strftime('%Y-%m-%d %H:%M'))}.</footer>
694
+ </div>
695
+ </body>
696
+ </html>
697
+ """