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
lambda_watcher/db.py ADDED
@@ -0,0 +1,466 @@
1
+ """SQLite index over every archived version.
2
+
3
+ The extracted trees and ``manifest.json`` files on disk are the source of
4
+ truth; this database is a queryable index built from them, so it can always be
5
+ rebuilt with ``lambda-watcher reindex``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ import threading
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from collections.abc import Iterator
16
+ from typing import Any
17
+
18
+ SCHEMA_VERSION = 1
19
+
20
+ SCHEMA = """
21
+ PRAGMA journal_mode=WAL;
22
+ PRAGMA foreign_keys=ON;
23
+
24
+ CREATE TABLE IF NOT EXISTS meta (
25
+ key TEXT PRIMARY KEY,
26
+ value TEXT NOT NULL
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS functions (
30
+ id INTEGER PRIMARY KEY,
31
+ name TEXT NOT NULL UNIQUE,
32
+ slug TEXT NOT NULL UNIQUE,
33
+ first_seen TEXT NOT NULL,
34
+ last_seen TEXT NOT NULL,
35
+ notes TEXT
36
+ );
37
+
38
+ -- Filename patterns that map a download onto an existing function.
39
+ CREATE TABLE IF NOT EXISTS aliases (
40
+ id INTEGER PRIMARY KEY,
41
+ function_id INTEGER NOT NULL REFERENCES functions(id) ON DELETE CASCADE,
42
+ pattern TEXT NOT NULL,
43
+ is_regex INTEGER NOT NULL DEFAULT 0,
44
+ UNIQUE(function_id, pattern)
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS versions (
48
+ id INTEGER PRIMARY KEY,
49
+ function_id INTEGER NOT NULL REFERENCES functions(id) ON DELETE CASCADE,
50
+ seq INTEGER NOT NULL,
51
+ tree_hash TEXT NOT NULL,
52
+ zip_sha256 TEXT,
53
+ zip_size INTEGER,
54
+ source_name TEXT,
55
+ source_path TEXT,
56
+ source_mtime TEXT,
57
+ ingested_at TEXT NOT NULL,
58
+ dir TEXT NOT NULL,
59
+ runtime TEXT,
60
+ runtime_confidence TEXT,
61
+ handler TEXT,
62
+ file_count INTEGER NOT NULL DEFAULT 0,
63
+ total_size INTEGER NOT NULL DEFAULT 0,
64
+ code_file_count INTEGER NOT NULL DEFAULT 0,
65
+ code_size INTEGER NOT NULL DEFAULT 0,
66
+ code_lines INTEGER NOT NULL DEFAULT 0,
67
+ label TEXT,
68
+ UNIQUE(function_id, seq)
69
+ -- Deliberately no UNIQUE on (function_id, tree_hash): duplicate content is
70
+ -- caught by an explicit lookup, which `ingest --force` is allowed to skip.
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS files (
74
+ id INTEGER PRIMARY KEY,
75
+ version_id INTEGER NOT NULL REFERENCES versions(id) ON DELETE CASCADE,
76
+ path TEXT NOT NULL,
77
+ size INTEGER NOT NULL,
78
+ sha256 TEXT NOT NULL,
79
+ mode INTEGER,
80
+ is_text INTEGER NOT NULL DEFAULT 0,
81
+ is_vendor INTEGER NOT NULL DEFAULT 0,
82
+ lang TEXT,
83
+ lines INTEGER NOT NULL DEFAULT 0
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS deps (
87
+ id INTEGER PRIMARY KEY,
88
+ version_id INTEGER NOT NULL REFERENCES versions(id) ON DELETE CASCADE,
89
+ manager TEXT NOT NULL,
90
+ name TEXT NOT NULL,
91
+ version TEXT,
92
+ source TEXT,
93
+ is_declared INTEGER NOT NULL DEFAULT 1
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS env_vars (
97
+ id INTEGER PRIMARY KEY,
98
+ version_id INTEGER NOT NULL REFERENCES versions(id) ON DELETE CASCADE,
99
+ name TEXT NOT NULL,
100
+ path TEXT,
101
+ line INTEGER
102
+ );
103
+
104
+ CREATE TABLE IF NOT EXISTS services (
105
+ id INTEGER PRIMARY KEY,
106
+ version_id INTEGER NOT NULL REFERENCES versions(id) ON DELETE CASCADE,
107
+ service TEXT NOT NULL,
108
+ path TEXT,
109
+ line INTEGER
110
+ );
111
+
112
+ CREATE TABLE IF NOT EXISTS findings (
113
+ id INTEGER PRIMARY KEY,
114
+ version_id INTEGER NOT NULL REFERENCES versions(id) ON DELETE CASCADE,
115
+ kind TEXT NOT NULL,
116
+ severity TEXT NOT NULL,
117
+ path TEXT,
118
+ line INTEGER,
119
+ detail TEXT,
120
+ is_vendor INTEGER NOT NULL DEFAULT 0
121
+ );
122
+
123
+ -- Audit trail: every download seen, including ones skipped as duplicates.
124
+ CREATE TABLE IF NOT EXISTS events (
125
+ id INTEGER PRIMARY KEY,
126
+ ts TEXT NOT NULL,
127
+ kind TEXT NOT NULL,
128
+ function_id INTEGER REFERENCES functions(id) ON DELETE SET NULL,
129
+ version_id INTEGER REFERENCES versions(id) ON DELETE SET NULL,
130
+ source_path TEXT,
131
+ detail TEXT
132
+ );
133
+
134
+ -- Downloads already handled, so a restart does not re-ingest them.
135
+ CREATE TABLE IF NOT EXISTS seen_downloads (
136
+ zip_sha256 TEXT PRIMARY KEY,
137
+ first_seen TEXT NOT NULL,
138
+ last_seen TEXT NOT NULL,
139
+ times_seen INTEGER NOT NULL DEFAULT 1,
140
+ source_name TEXT
141
+ );
142
+
143
+ CREATE INDEX IF NOT EXISTS idx_files_version ON files(version_id);
144
+ CREATE INDEX IF NOT EXISTS idx_files_sha ON files(sha256);
145
+ CREATE INDEX IF NOT EXISTS idx_files_path ON files(path);
146
+ CREATE INDEX IF NOT EXISTS idx_deps_version ON deps(version_id);
147
+ CREATE INDEX IF NOT EXISTS idx_env_version ON env_vars(version_id);
148
+ CREATE INDEX IF NOT EXISTS idx_services_version ON services(version_id);
149
+ CREATE INDEX IF NOT EXISTS idx_findings_version ON findings(version_id);
150
+ CREATE INDEX IF NOT EXISTS idx_versions_function ON versions(function_id);
151
+ CREATE INDEX IF NOT EXISTS idx_versions_tree ON versions(function_id, tree_hash);
152
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
153
+ """
154
+
155
+
156
+ class _LockedConnection:
157
+ """Serialises access to one sqlite connection across threads.
158
+
159
+ The watcher ingests on a worker thread while the main thread reads, and
160
+ sqlite3 connections are single-threaded by default. Guarding every
161
+ statement with one re-entrant lock keeps a single connection (and therefore
162
+ one WAL writer) without sprinkling locks through the query methods.
163
+ """
164
+
165
+ def __init__(self, conn: sqlite3.Connection, lock: threading.RLock) -> None:
166
+ self._conn = conn
167
+ self._lock = lock
168
+
169
+ def execute(self, *args: Any, **kwargs: Any) -> sqlite3.Cursor:
170
+ with self._lock:
171
+ return self._conn.execute(*args, **kwargs)
172
+
173
+ def executemany(self, *args: Any, **kwargs: Any) -> sqlite3.Cursor:
174
+ with self._lock:
175
+ return self._conn.executemany(*args, **kwargs)
176
+
177
+ def executescript(self, *args: Any, **kwargs: Any) -> sqlite3.Cursor:
178
+ with self._lock:
179
+ return self._conn.executescript(*args, **kwargs)
180
+
181
+ def close(self) -> None:
182
+ with self._lock:
183
+ self._conn.close()
184
+
185
+ def __getattr__(self, name: str) -> Any:
186
+ return getattr(self._conn, name)
187
+
188
+
189
+ class Database:
190
+ """Thin wrapper around sqlite3 with the queries the CLI needs."""
191
+
192
+ def __init__(self, path: Path) -> None:
193
+ self.path = Path(path)
194
+ self.path.parent.mkdir(parents=True, exist_ok=True)
195
+ self._lock = threading.RLock()
196
+ raw = sqlite3.connect(
197
+ str(self.path), timeout=30, isolation_level=None, check_same_thread=False
198
+ )
199
+ raw.row_factory = sqlite3.Row
200
+ self.conn = _LockedConnection(raw, self._lock)
201
+ self.conn.executescript(SCHEMA)
202
+ self.conn.execute(
203
+ "INSERT OR REPLACE INTO meta(key, value) VALUES('schema_version', ?)",
204
+ (str(SCHEMA_VERSION),),
205
+ )
206
+
207
+ # -- lifecycle -------------------------------------------------------
208
+ def close(self) -> None:
209
+ try:
210
+ self.conn.close()
211
+ except sqlite3.Error:
212
+ pass
213
+
214
+ def __enter__(self) -> Database:
215
+ return self
216
+
217
+ def __exit__(self, *_exc: object) -> None:
218
+ self.close()
219
+
220
+ @contextmanager
221
+ def transaction(self) -> Iterator[Any]:
222
+ """Hold the connection lock for the whole BEGIN..COMMIT block."""
223
+ with self._lock:
224
+ self.conn.execute("BEGIN")
225
+ try:
226
+ yield self.conn
227
+ except Exception:
228
+ self.conn.execute("ROLLBACK")
229
+ raise
230
+ else:
231
+ self.conn.execute("COMMIT")
232
+
233
+ # -- functions -------------------------------------------------------
234
+ def get_function_by_name(self, name: str, case_insensitive: bool = True) -> sqlite3.Row | None:
235
+ if case_insensitive:
236
+ row = self.conn.execute(
237
+ "SELECT * FROM functions WHERE lower(name) = lower(?)", (name,)
238
+ ).fetchone()
239
+ else:
240
+ row = self.conn.execute("SELECT * FROM functions WHERE name = ?", (name,)).fetchone()
241
+ return row
242
+
243
+ def get_function(self, ident: str) -> sqlite3.Row | None:
244
+ """Resolve by exact name, slug, or unique case-insensitive prefix."""
245
+ row = self.get_function_by_name(ident)
246
+ if row:
247
+ return row
248
+ row = self.conn.execute("SELECT * FROM functions WHERE slug = ?", (ident,)).fetchone()
249
+ if row:
250
+ return row
251
+ rows = self.conn.execute(
252
+ "SELECT * FROM functions WHERE lower(name) LIKE lower(?) OR lower(slug) LIKE lower(?)",
253
+ (f"%{ident}%", f"%{ident}%"),
254
+ ).fetchall()
255
+ if len(rows) == 1:
256
+ return rows[0]
257
+ return None
258
+
259
+ def upsert_function(self, name: str, slug: str, now: str) -> int:
260
+ existing = self.get_function_by_name(name)
261
+ if existing:
262
+ self.conn.execute(
263
+ "UPDATE functions SET last_seen = ? WHERE id = ?", (now, existing["id"])
264
+ )
265
+ return int(existing["id"])
266
+ cur = self.conn.execute(
267
+ "INSERT INTO functions(name, slug, first_seen, last_seen) VALUES(?,?,?,?)",
268
+ (name, slug, now, now),
269
+ )
270
+ return int(cur.lastrowid)
271
+
272
+ def list_functions(self) -> list[sqlite3.Row]:
273
+ return self.conn.execute(
274
+ """
275
+ SELECT f.*,
276
+ (SELECT COUNT(*) FROM versions v WHERE v.function_id = f.id) AS version_count,
277
+ (SELECT MAX(v.seq) FROM versions v WHERE v.function_id = f.id) AS latest_seq
278
+ FROM functions f
279
+ ORDER BY f.last_seen DESC
280
+ """
281
+ ).fetchall()
282
+
283
+ def archive_totals(self) -> tuple[int, int, int]:
284
+ """Functions, versions and indexed bytes — the three numbers `lw status` shows."""
285
+ row = self.conn.execute(
286
+ """
287
+ SELECT (SELECT COUNT(*) FROM functions) AS functions,
288
+ (SELECT COUNT(*) FROM versions) AS versions,
289
+ (SELECT COALESCE(SUM(total_size), 0) FROM versions) AS bytes
290
+ """
291
+ ).fetchone()
292
+ return int(row["functions"]), int(row["versions"]), int(row["bytes"])
293
+
294
+ def rename_function(self, function_id: int, new_name: str, new_slug: str) -> None:
295
+ self.conn.execute(
296
+ "UPDATE functions SET name = ?, slug = ? WHERE id = ?", (new_name, new_slug, function_id)
297
+ )
298
+
299
+ def delete_function(self, function_id: int) -> None:
300
+ self.conn.execute("DELETE FROM functions WHERE id = ?", (function_id,))
301
+
302
+ # -- aliases ---------------------------------------------------------
303
+ def add_alias(self, function_id: int, pattern: str, is_regex: bool = False) -> None:
304
+ self.conn.execute(
305
+ "INSERT OR IGNORE INTO aliases(function_id, pattern, is_regex) VALUES(?,?,?)",
306
+ (function_id, pattern, int(is_regex)),
307
+ )
308
+
309
+ def list_aliases(self) -> list[sqlite3.Row]:
310
+ return self.conn.execute(
311
+ "SELECT a.*, f.name AS function_name FROM aliases a JOIN functions f ON f.id = a.function_id"
312
+ ).fetchall()
313
+
314
+ # -- versions --------------------------------------------------------
315
+ def next_seq(self, function_id: int) -> int:
316
+ row = self.conn.execute(
317
+ "SELECT COALESCE(MAX(seq), 0) AS m FROM versions WHERE function_id = ?", (function_id,)
318
+ ).fetchone()
319
+ return int(row["m"]) + 1
320
+
321
+ def find_version_by_tree_hash(self, function_id: int, tree_hash: str) -> sqlite3.Row | None:
322
+ """The most recent version of this function with exactly this content."""
323
+ return self.conn.execute(
324
+ "SELECT * FROM versions WHERE function_id = ? AND tree_hash = ?"
325
+ " ORDER BY seq DESC LIMIT 1",
326
+ (function_id, tree_hash),
327
+ ).fetchone()
328
+
329
+ def insert_version(self, values: dict[str, Any]) -> int:
330
+ cols = ", ".join(values)
331
+ marks = ", ".join("?" for _ in values)
332
+ cur = self.conn.execute(
333
+ f"INSERT INTO versions({cols}) VALUES({marks})", tuple(values.values())
334
+ )
335
+ return int(cur.lastrowid)
336
+
337
+ def list_versions(self, function_id: int, limit: int | None = None) -> list[sqlite3.Row]:
338
+ sql = "SELECT * FROM versions WHERE function_id = ? ORDER BY seq DESC"
339
+ if limit:
340
+ sql += f" LIMIT {int(limit)}"
341
+ return self.conn.execute(sql, (function_id,)).fetchall()
342
+
343
+ def get_version(self, function_id: int, seq: int) -> sqlite3.Row | None:
344
+ return self.conn.execute(
345
+ "SELECT * FROM versions WHERE function_id = ? AND seq = ?", (function_id, seq)
346
+ ).fetchone()
347
+
348
+ def latest_version(self, function_id: int) -> sqlite3.Row | None:
349
+ return self.conn.execute(
350
+ "SELECT * FROM versions WHERE function_id = ? ORDER BY seq DESC LIMIT 1", (function_id,)
351
+ ).fetchone()
352
+
353
+ def delete_version(self, version_id: int) -> None:
354
+ self.conn.execute("DELETE FROM versions WHERE id = ?", (version_id,))
355
+
356
+ def set_version_label(self, version_id: int, label: str | None) -> None:
357
+ self.conn.execute("UPDATE versions SET label = ? WHERE id = ?", (label, version_id))
358
+
359
+ # -- child rows ------------------------------------------------------
360
+ def bulk_insert(self, table: str, columns: list[str], rows: list[tuple]) -> None:
361
+ if not rows:
362
+ return
363
+ marks = ", ".join("?" for _ in columns)
364
+ self.conn.executemany(
365
+ f"INSERT INTO {table}({', '.join(columns)}) VALUES({marks})", rows
366
+ )
367
+
368
+ def files_for(self, version_id: int) -> list[sqlite3.Row]:
369
+ return self.conn.execute(
370
+ "SELECT * FROM files WHERE version_id = ? ORDER BY path", (version_id,)
371
+ ).fetchall()
372
+
373
+ def deps_for(self, version_id: int) -> list[sqlite3.Row]:
374
+ return self.conn.execute(
375
+ "SELECT * FROM deps WHERE version_id = ? ORDER BY manager, name", (version_id,)
376
+ ).fetchall()
377
+
378
+ def env_for(self, version_id: int) -> list[sqlite3.Row]:
379
+ return self.conn.execute(
380
+ "SELECT * FROM env_vars WHERE version_id = ? ORDER BY name", (version_id,)
381
+ ).fetchall()
382
+
383
+ def services_for(self, version_id: int) -> list[sqlite3.Row]:
384
+ return self.conn.execute(
385
+ "SELECT * FROM services WHERE version_id = ? ORDER BY service", (version_id,)
386
+ ).fetchall()
387
+
388
+ def findings_for(self, version_id: int, include_vendor: bool = False) -> list[sqlite3.Row]:
389
+ sql = "SELECT * FROM findings WHERE version_id = ?"
390
+ if not include_vendor:
391
+ sql += " AND is_vendor = 0"
392
+ sql += " ORDER BY severity, path"
393
+ return self.conn.execute(sql, (version_id,)).fetchall()
394
+
395
+ # -- events / dedup --------------------------------------------------
396
+ def log_event(
397
+ self,
398
+ kind: str,
399
+ ts: str,
400
+ function_id: int | None = None,
401
+ version_id: int | None = None,
402
+ source_path: str | None = None,
403
+ detail: Any = None,
404
+ ) -> None:
405
+ payload = detail if isinstance(detail, str) or detail is None else json.dumps(detail)
406
+ self.conn.execute(
407
+ "INSERT INTO events(ts, kind, function_id, version_id, source_path, detail)"
408
+ " VALUES(?,?,?,?,?,?)",
409
+ (ts, kind, function_id, version_id, source_path, payload),
410
+ )
411
+
412
+ def recent_events(self, limit: int = 30) -> list[sqlite3.Row]:
413
+ return self.conn.execute(
414
+ """
415
+ SELECT e.*, f.name AS function_name, v.seq AS version_seq
416
+ FROM events e
417
+ LEFT JOIN functions f ON f.id = e.function_id
418
+ LEFT JOIN versions v ON v.id = e.version_id
419
+ ORDER BY e.id DESC LIMIT ?
420
+ """,
421
+ (limit,),
422
+ ).fetchall()
423
+
424
+ def seen_download(self, zip_sha256: str) -> sqlite3.Row | None:
425
+ return self.conn.execute(
426
+ "SELECT * FROM seen_downloads WHERE zip_sha256 = ?", (zip_sha256,)
427
+ ).fetchone()
428
+
429
+ def mark_download_seen(self, zip_sha256: str, now: str, source_name: str) -> None:
430
+ self.conn.execute(
431
+ """
432
+ INSERT INTO seen_downloads(zip_sha256, first_seen, last_seen, times_seen, source_name)
433
+ VALUES(?,?,?,1,?)
434
+ ON CONFLICT(zip_sha256) DO UPDATE SET
435
+ last_seen = excluded.last_seen,
436
+ times_seen = times_seen + 1
437
+ """,
438
+ (zip_sha256, now, now, source_name),
439
+ )
440
+
441
+ # -- search ----------------------------------------------------------
442
+ def search_files(self, term: str, limit: int = 100) -> list[sqlite3.Row]:
443
+ return self.conn.execute(
444
+ """
445
+ SELECT f.name AS function_name, v.seq, fi.path, fi.size
446
+ FROM files fi
447
+ JOIN versions v ON v.id = fi.version_id
448
+ JOIN functions f ON f.id = v.function_id
449
+ WHERE fi.path LIKE ?
450
+ ORDER BY f.name, v.seq DESC LIMIT ?
451
+ """,
452
+ (f"%{term}%", limit),
453
+ ).fetchall()
454
+
455
+ def search_deps(self, term: str, limit: int = 200) -> list[sqlite3.Row]:
456
+ return self.conn.execute(
457
+ """
458
+ SELECT DISTINCT f.name AS function_name, v.seq, d.manager, d.name, d.version
459
+ FROM deps d
460
+ JOIN versions v ON v.id = d.version_id
461
+ JOIN functions f ON f.id = v.function_id
462
+ WHERE d.name LIKE ?
463
+ ORDER BY f.name, v.seq DESC LIMIT ?
464
+ """,
465
+ (f"%{term}%", limit),
466
+ ).fetchall()
@@ -0,0 +1,14 @@
1
+ """Comparing two archived versions of the same Lambda function."""
2
+
3
+ from .build import code_dir, diff_from_index
4
+ from .compare import (
5
+ DepChange,
6
+ FileChange,
7
+ VersionDiff,
8
+ compare_versions,
9
+ )
10
+
11
+ __all__ = [
12
+ "DepChange", "FileChange", "VersionDiff",
13
+ "code_dir", "compare_versions", "diff_from_index",
14
+ ]
@@ -0,0 +1,51 @@
1
+ """Assemble a :class:`VersionDiff` from the index.
2
+
3
+ ``compare_versions`` deliberately takes plain rows and two directories so it can
4
+ be tested with no store behind it. Everything that actually calls it — the CLI's
5
+ ``diff`` and ``report``, and the report the ingest pipeline renders on its own —
6
+ needs the same dozen lookups first, so they live here once instead of three
7
+ times.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .compare import VersionDiff, compare_versions
16
+
17
+ if TYPE_CHECKING: # avoids a Presentation -> Persistence
18
+ from ..config import DiffConfig # import at runtime; the checker still
19
+ from ..db import Database # gets real types
20
+ from ..store import Store
21
+
22
+
23
+ def code_dir(store: Store, version_row: Any) -> Path:
24
+ """Where one version's extracted tree lives."""
25
+ return store.resolve_version_dir(version_row["dir"]) / "code"
26
+
27
+
28
+ def diff_from_index(
29
+ db: Database,
30
+ store: Store,
31
+ diff_cfg: DiffConfig,
32
+ name: str,
33
+ a_row: Any,
34
+ b_row: Any,
35
+ include_vendor: bool | None = None,
36
+ compute_diffs: bool = True,
37
+ ) -> VersionDiff:
38
+ """Compare two archived versions, pulling every facet out of the index."""
39
+ a_id, b_id = int(a_row["id"]), int(b_row["id"])
40
+ return compare_versions(
41
+ name, int(a_row["seq"]), int(b_row["seq"]),
42
+ db.files_for(a_id), db.files_for(b_id),
43
+ code_dir(store, a_row), code_dir(store, b_row), diff_cfg,
44
+ a_deps=db.deps_for(a_id), b_deps=db.deps_for(b_id),
45
+ a_env=db.env_for(a_id), b_env=db.env_for(b_id),
46
+ a_services=db.services_for(a_id), b_services=db.services_for(b_id),
47
+ a_findings=db.findings_for(a_id), b_findings=db.findings_for(b_id),
48
+ a_meta=dict(a_row), b_meta=dict(b_row),
49
+ include_vendor=include_vendor,
50
+ compute_diffs=compute_diffs,
51
+ )