codevanta 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 (65) hide show
  1. codevanta/__init__.py +1 -0
  2. codevanta/analysis/__init__.py +1 -0
  3. codevanta/analysis/binary_diff.py +1168 -0
  4. codevanta/analysis/callgraph.py +542 -0
  5. codevanta/analysis/cfg.py +1201 -0
  6. codevanta/analysis/dataflow.py +762 -0
  7. codevanta/analysis/debugger.py +289 -0
  8. codevanta/analysis/debuginfo.py +304 -0
  9. codevanta/analysis/decompile.py +1094 -0
  10. codevanta/analysis/decompilers/__init__.py +250 -0
  11. codevanta/analysis/decompilers/angr_backend.py +251 -0
  12. codevanta/analysis/decompilers/base.py +278 -0
  13. codevanta/analysis/decompilers/ghidra_backend.py +316 -0
  14. codevanta/analysis/dynamic.py +756 -0
  15. codevanta/analysis/emulate.py +1349 -0
  16. codevanta/analysis/evidence_bundle.py +736 -0
  17. codevanta/analysis/function_hash.py +562 -0
  18. codevanta/analysis/graph_export.py +441 -0
  19. codevanta/analysis/html_view.py +695 -0
  20. codevanta/analysis/jump_tables.py +546 -0
  21. codevanta/analysis/solver.py +715 -0
  22. codevanta/analysis/symbolic.py +1809 -0
  23. codevanta/analysis/trace.py +646 -0
  24. codevanta/analysis/type_propagation.py +1055 -0
  25. codevanta/analysis/type_recovery.py +1214 -0
  26. codevanta/analysis/vtables.py +572 -0
  27. codevanta/analysis/xrefs.py +434 -0
  28. codevanta/api/__init__.py +1 -0
  29. codevanta/cli/__init__.py +1 -0
  30. codevanta/cli/main.py +3038 -0
  31. codevanta/core/__init__.py +31 -0
  32. codevanta/core/disassembler.py +121 -0
  33. codevanta/core/entropy.py +237 -0
  34. codevanta/core/functions.py +176 -0
  35. codevanta/core/loader.py +1351 -0
  36. codevanta/core/report.py +306 -0
  37. codevanta/core/strings.py +109 -0
  38. codevanta/evidence/__init__.py +7 -0
  39. codevanta/evidence/confidence.py +36 -0
  40. codevanta/evidence/models.py +43 -0
  41. codevanta/evidence/provenance.py +37 -0
  42. codevanta/gui/__init__.py +46 -0
  43. codevanta/gui/layout.py +1384 -0
  44. codevanta/gui/server.py +1305 -0
  45. codevanta/ir/__init__.py +6 -0
  46. codevanta/ir/adapters/__init__.py +31 -0
  47. codevanta/ir/base.py +31 -0
  48. codevanta/ir/instruction.py +42 -0
  49. codevanta/mcp_server/__init__.py +5 -0
  50. codevanta/mcp_server/server.py +710 -0
  51. codevanta/mcp_server/tools.py +1364 -0
  52. codevanta/plugins/__init__.py +748 -0
  53. codevanta/setup/__init__.py +28 -0
  54. codevanta/setup/apply.py +22 -0
  55. codevanta/setup/clients.py +285 -0
  56. codevanta/setup/plan.py +79 -0
  57. codevanta/setup/verify.py +43 -0
  58. codevanta/storage/__init__.py +1 -0
  59. codevanta/storage/session.py +514 -0
  60. codevanta-0.1.0.dist-info/METADATA +688 -0
  61. codevanta-0.1.0.dist-info/RECORD +65 -0
  62. codevanta-0.1.0.dist-info/WHEEL +5 -0
  63. codevanta-0.1.0.dist-info/entry_points.txt +2 -0
  64. codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. codevanta-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,695 @@
1
+ """Self-contained HTML5 + inline SVG viewer for CODEVANTA's plain-dict graph model.
2
+
3
+ The viewer turns the same mapping that :mod:`codevanta.analysis.graph_export`
4
+ renders to DOT and GraphML into a single, standalone ``.html`` string: no
5
+ external CSS, no JavaScript libraries, no web fonts, no CDN and no ``http(s)``
6
+ reference of any kind. A caller can drop the returned text into a file and open
7
+ it in any modern browser — offline, air-gapped, from a USB stick.
8
+
9
+ Contract
10
+ --------
11
+ * The graph is validated and normalized by
12
+ :func:`codevanta.analysis.graph_export.validate_graph`, so the accepted shape
13
+ and every error message are identical to the DOT/GraphML exporters (see that
14
+ module's docstring).
15
+ * Nodes are laid out in three SVG columns chosen by ``kind``:
16
+
17
+ ========================================== =========
18
+ ``kind`` column
19
+ ========================================== =========
20
+ ``function``, ``entry_point`` left
21
+ ``call_site`` middle
22
+ ``import``, ``external``, anything else right
23
+ ========================================== =========
24
+
25
+ * Ordering is deterministic: nodes are sorted by ``(column, kind rank, id)`` and
26
+ edges by ``(source, target, label)``, so identical content always produces
27
+ byte-identical HTML regardless of input order.
28
+ * Node labels, kinds, ids, edge endpoints, the title and the edge table are all
29
+ escaped with :func:`html.escape`; the embedded JSON payload escapes ``"</"`` as
30
+ ``"<\\/"`` so no data can terminate the surrounding ``script`` element.
31
+ * Besides the inert ``application/json`` payload the document carries exactly one
32
+ behavioural ``<script>`` element: inline, dependency-free, with no ``src`` and
33
+ no network access. It adds pointer drag-to-pan (by rewriting the SVG
34
+ ``viewBox``), wheel zoom clamped to ``0.2x``-``8x``, label search/highlight
35
+ and per-kind filtering. With scripting disabled the static view still renders
36
+ in full, because every node is visible and no class is applied by default.
37
+
38
+ Nothing is fetched and nothing is written to disk: the graph is only rendered.
39
+
40
+ Limitations
41
+ -----------
42
+ * Layout is a simple three-column stack. Edge routing is a straight line between
43
+ the box borders, so dense graphs (and edges inside one column) overlap; there
44
+ is no crossing minimization, and pan/zoom only moves the fixed layout, it does
45
+ not re-flow it.
46
+ * Search matches the *rendered* label and the node id (the box ``title``), so a
47
+ query that hits the cut-off tail of a truncated label does not match; the
48
+ embedded JSON payload always keeps the full text.
49
+ * Hiding a node with the kind filters does not hide its edges, and the edge
50
+ table is intentionally unfiltered: it always lists every edge.
51
+ * Panning is unbounded (it is not clamped to the canvas) and there is no zoom
52
+ reset control: reloading the page restores the initial view.
53
+ * Labels longer than :data:`_LABEL_LIMIT` characters are truncated with an
54
+ ellipsis for display only — the embedded JSON payload keeps the full text.
55
+ * The ``<svg>`` element deliberately omits ``xmlns`` (which would be an ``http``
56
+ reference); it is correct when inlined in HTML5, but the fragment is not a
57
+ standalone ``.svg`` file.
58
+ * Control characters and lone surrogates in graph text are replaced with
59
+ ``U+FFFD`` before rendering, so the document is always valid UTF-8.
60
+ * Very large graphs stay linear in output size but become visually unreadable; the
61
+ ``max_nodes`` / ``max_edges`` budget, not the layout, is the real safeguard.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import html
67
+ import json
68
+ from dataclasses import dataclass
69
+ from typing import Any
70
+
71
+ from codevanta.analysis.graph_export import (
72
+ DEFAULT_MAX_EDGES,
73
+ DEFAULT_MAX_NODES,
74
+ DEFAULT_TITLE,
75
+ GraphExportError,
76
+ sanitize_text,
77
+ validate_graph,
78
+ )
79
+
80
+ __all__ = ["to_html"]
81
+
82
+ #: Box geometry, in SVG user units. Fixed values keep the output byte-stable.
83
+ _BOX_WIDTH = 220
84
+ _BOX_HEIGHT = 40
85
+ _COLUMN_GAP = 120
86
+ _ROW_GAP = 24
87
+ _MARGIN = 40
88
+
89
+ #: Height of the band reserved at the top of the SVG for the column titles.
90
+ _COLUMN_HEADER = 28
91
+
92
+ #: Number of layout columns; index 0, 1 and 2 are left, middle and right.
93
+ _COLUMN_COUNT = 3
94
+
95
+ #: Heading rendered above each column, in column order.
96
+ _COLUMN_TITLES = (
97
+ "function / entry_point",
98
+ "call_site",
99
+ "import / external / other",
100
+ )
101
+
102
+ #: ``kind`` values routed to the left column.
103
+ _LEFT_KINDS = frozenset({"function", "entry_point"})
104
+
105
+ #: ``kind`` value routed to the middle column.
106
+ _MIDDLE_KIND = "call_site"
107
+
108
+ #: Tie-breaker rank inside a column; unknown kinds sort last.
109
+ _KIND_RANK: dict[str, int] = {
110
+ "entry_point": 0,
111
+ "function": 1,
112
+ "call_site": 2,
113
+ "import": 3,
114
+ "external": 4,
115
+ }
116
+ _UNKNOWN_KIND_RANK = 9
117
+
118
+ #: Maximum number of characters of a label drawn inside a node box.
119
+ _LABEL_LIMIT = 34
120
+
121
+ #: Identifier of the SVG arrow marker referenced by every edge line.
122
+ _ARROW_ID = "arrow"
123
+
124
+ #: Inline stylesheet; contains no external reference.
125
+ _STYLE = """
126
+ :root { color-scheme: dark; }
127
+ * { box-sizing: border-box; }
128
+ body {
129
+ margin: 0;
130
+ padding: 24px;
131
+ background: #0f1420;
132
+ color: #e6edf6;
133
+ font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Arial, sans-serif;
134
+ }
135
+ header { margin-bottom: 18px; }
136
+ h1 { margin: 0 0 6px; font-size: 20px; font-weight: 600; }
137
+ .counts { margin: 0; font-size: 13px; color: #9fb0c6; }
138
+ .counts span { margin-right: 16px; }
139
+ .graph {
140
+ display: block;
141
+ width: 100%;
142
+ height: auto;
143
+ background: #131a29;
144
+ border: 1px solid #223049;
145
+ border-radius: 10px;
146
+ cursor: grab;
147
+ }
148
+ .column-title { fill: #7f93ad; font-size: 12px; letter-spacing: .04em; text-anchor: middle; }
149
+ .edge { fill: none; stroke: #46597a; stroke-width: 1.4; }
150
+ #arrow path { fill: #46597a; }
151
+ .node rect { fill: #1b2740; stroke: #33496b; stroke-width: 1.2; }
152
+ .node.col-0 rect { fill: #1d3352; stroke: #4f8bd4; }
153
+ .node.col-1 rect { fill: #1f3040; stroke: #5ba8a0; }
154
+ .node.col-2 rect { fill: #2a2438; stroke: #9d7bd8; }
155
+ .node text { fill: #e6edf6; text-anchor: middle; }
156
+ .node-label { font-size: 13px; }
157
+ .node-kind { font-size: 10px; fill: #9fb0c6; }
158
+ .empty { fill: #7f93ad; font-size: 13px; text-anchor: middle; }
159
+ .toolbar {
160
+ display: flex;
161
+ flex-wrap: wrap;
162
+ align-items: center;
163
+ gap: 14px;
164
+ margin: 0 0 12px;
165
+ font-size: 13px;
166
+ color: #9fb0c6;
167
+ }
168
+ .toolbar input[type="search"] {
169
+ min-width: 220px;
170
+ padding: 5px 9px;
171
+ color: #e6edf6;
172
+ background: #131a29;
173
+ border: 1px solid #223049;
174
+ border-radius: 8px;
175
+ font: inherit;
176
+ }
177
+ .cv-filter { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
178
+ .cv-filter input { margin: 0; accent-color: #4f8bd4; }
179
+ .graph.cv-panning { cursor: grabbing; }
180
+ .node.cv-hit rect { stroke: #f0c674; stroke-width: 2.4; }
181
+ .node.cv-dim { opacity: .22; }
182
+ details {
183
+ margin-top: 18px;
184
+ padding: 10px 14px;
185
+ background: #131a29;
186
+ border: 1px solid #223049;
187
+ border-radius: 10px;
188
+ }
189
+ summary { cursor: pointer; font-size: 13px; color: #9fb0c6; }
190
+ table { width: 100%; margin-top: 10px; border-collapse: collapse; font-size: 12px; }
191
+ th, td { padding: 5px 8px; text-align: left; border-bottom: 1px solid #1e2a3e; }
192
+ th { font-weight: 600; color: #8ea2bb; }
193
+ td { color: #cfdae7; }
194
+ """.strip()
195
+
196
+ #: Identifier of the search input driven by :data:`_SCRIPT`.
197
+ _SEARCH_ID = "cv-search"
198
+
199
+ #: Class shared by the kind-filter checkboxes.
200
+ _FILTER_CLASS = "cv-kind"
201
+
202
+ #: ``(data-kind value, caption)`` pairs rendered as filter checkboxes.
203
+ _FILTERS = (
204
+ ("function", "function / entry_point"),
205
+ ("call_site", "call_site"),
206
+ ("import", "import / external"),
207
+ )
208
+
209
+ #: Inline, dependency-free behaviour script: pan, zoom, search and filtering.
210
+ #: Deliberately free of ``src``/``href``/URL literals and of any ``"</"``
211
+ #: sequence, so it can never terminate its own ``script`` element. The
212
+ #: ``cv-search`` / ``cv-kind`` literals below mirror :data:`_SEARCH_ID` and
213
+ #: :data:`_FILTER_CLASS`; the tests pin both sides together.
214
+ _SCRIPT = """
215
+ (function () {
216
+ "use strict";
217
+
218
+ var CANVAS = "svg.graph";
219
+ var MIN_ZOOM = 0.2;
220
+ var MAX_ZOOM = 8;
221
+ var STEP = 1.15;
222
+
223
+ var svg = document.querySelector(CANVAS);
224
+ if (!svg) {
225
+ return;
226
+ }
227
+
228
+ var parts = String(svg.getAttribute("viewBox") || "0 0 1000 600").split(" ");
229
+ var base = {
230
+ x: Number(parts[0]) || 0,
231
+ y: Number(parts[1]) || 0,
232
+ w: Number(parts[2]) || 1000,
233
+ h: Number(parts[3]) || 600
234
+ };
235
+ var view = { x: base.x, y: base.y, w: base.w, h: base.h };
236
+
237
+ function apply() {
238
+ svg.setAttribute("viewBox", view.x + " " + view.y + " " + view.w + " " + view.h);
239
+ }
240
+
241
+ var drag = null;
242
+ svg.addEventListener("mousedown", function (event) {
243
+ if (event.button !== 0) {
244
+ return;
245
+ }
246
+ var rect = svg.getBoundingClientRect();
247
+ drag = {
248
+ px: event.clientX,
249
+ py: event.clientY,
250
+ x: view.x,
251
+ y: view.y,
252
+ sx: view.w / (rect.width || 1),
253
+ sy: view.h / (rect.height || 1)
254
+ };
255
+ svg.classList.add("cv-panning");
256
+ event.preventDefault();
257
+ });
258
+ window.addEventListener("mousemove", function (event) {
259
+ if (!drag) {
260
+ return;
261
+ }
262
+ view.x = drag.x - (event.clientX - drag.px) * drag.sx;
263
+ view.y = drag.y - (event.clientY - drag.py) * drag.sy;
264
+ apply();
265
+ });
266
+ window.addEventListener("mouseup", function () {
267
+ if (drag) {
268
+ drag = null;
269
+ svg.classList.remove("cv-panning");
270
+ }
271
+ });
272
+
273
+ svg.addEventListener("wheel", function (event) {
274
+ event.preventDefault();
275
+ var rect = svg.getBoundingClientRect();
276
+ var fx = rect.width ? (event.clientX - rect.left) / rect.width : 0.5;
277
+ var fy = rect.height ? (event.clientY - rect.top) / rect.height : 0.5;
278
+ fx = Math.min(1, Math.max(0, fx));
279
+ fy = Math.min(1, Math.max(0, fy));
280
+ var zoom = base.w / view.w;
281
+ var next = event.deltaY < 0 ? zoom * STEP : zoom / STEP;
282
+ next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next));
283
+ if (next === zoom) {
284
+ return;
285
+ }
286
+ var anchorX = view.x + fx * view.w;
287
+ var anchorY = view.y + fy * view.h;
288
+ view.w = base.w / next;
289
+ view.h = base.h / next;
290
+ view.x = anchorX - fx * view.w;
291
+ view.y = anchorY - fy * view.h;
292
+ apply();
293
+ }, { passive: false });
294
+
295
+ var nodes = Array.prototype.slice.call(svg.querySelectorAll("g.node"));
296
+ var search = document.getElementById("cv-search");
297
+ var filters = Array.prototype.slice.call(document.querySelectorAll("input.cv-kind"));
298
+ var needle = "";
299
+
300
+ function lower(value) {
301
+ return String(value === null || value === undefined ? "" : value).toLowerCase();
302
+ }
303
+
304
+ function groupOf(kind) {
305
+ if (kind === "function" || kind === "entry_point") {
306
+ return "function";
307
+ }
308
+ if (kind === "call_site") {
309
+ return "call_site";
310
+ }
311
+ return "import";
312
+ }
313
+
314
+ function haystack(node) {
315
+ var label = node.querySelector(".node-label");
316
+ var title = node.querySelector("title");
317
+ return lower(label ? label.textContent : "") + " " + lower(title ? title.textContent : "");
318
+ }
319
+
320
+ function refresh() {
321
+ var allowed = {};
322
+ var query = lower(needle).trim();
323
+ var index;
324
+ var node;
325
+ var kind;
326
+ for (index = 0; index < filters.length; index += 1) {
327
+ if (filters[index].checked) {
328
+ allowed[lower(filters[index].getAttribute("data-kind"))] = true;
329
+ }
330
+ }
331
+ for (index = 0; index < nodes.length; index += 1) {
332
+ node = nodes[index];
333
+ kind = lower(node.getAttribute("data-kind"));
334
+ node.style.display = allowed[groupOf(kind)] === true ? "" : "none";
335
+ node.classList.remove("cv-hit");
336
+ node.classList.remove("cv-dim");
337
+ if (query !== "" && haystack(node).indexOf(query) === -1) {
338
+ node.classList.add("cv-dim");
339
+ } else if (query !== "") {
340
+ node.classList.add("cv-hit");
341
+ }
342
+ }
343
+ }
344
+
345
+ if (search) {
346
+ search.addEventListener("input", function () {
347
+ needle = search.value || "";
348
+ refresh();
349
+ });
350
+ search.addEventListener("keydown", function (event) {
351
+ if (event.key === "Escape") {
352
+ search.value = "";
353
+ needle = "";
354
+ refresh();
355
+ }
356
+ });
357
+ }
358
+ filters.forEach(function (box) {
359
+ box.addEventListener("change", refresh);
360
+ });
361
+ refresh();
362
+ })();
363
+ """.strip()
364
+
365
+
366
+ @dataclass(frozen=True)
367
+ class _Box:
368
+ """Position of one node box on the SVG canvas."""
369
+
370
+ x: int
371
+ y: int
372
+ column: int
373
+
374
+ @property
375
+ def center_x(self) -> int:
376
+ """Return the horizontal centre of the box."""
377
+ return self.x + _BOX_WIDTH // 2
378
+
379
+ @property
380
+ def center_y(self) -> int:
381
+ """Return the vertical centre of the box."""
382
+ return self.y + _BOX_HEIGHT // 2
383
+
384
+
385
+ def _escape(value: str) -> str:
386
+ """Sanitize and escape ``value`` for HTML/XML text and attribute positions."""
387
+ return html.escape(sanitize_text(value), quote=True)
388
+
389
+
390
+ def _check_title(title: Any) -> str:
391
+ """Return a usable page title, falling back to :data:`DEFAULT_TITLE` when blank."""
392
+ if not isinstance(title, str):
393
+ raise GraphExportError(f"title must be a str, got {type(title).__name__}")
394
+ return title if title.strip() else DEFAULT_TITLE
395
+
396
+
397
+ def _kind_key(kind: str) -> str:
398
+ """Return the lower-cased, stripped form of a node ``kind``."""
399
+ return kind.strip().lower()
400
+
401
+
402
+ def _column_index(kind: str) -> int:
403
+ """Return the layout column (0, 1 or 2) for a node ``kind``."""
404
+ key = _kind_key(kind)
405
+ if key in _LEFT_KINDS:
406
+ return 0
407
+ if key == _MIDDLE_KIND:
408
+ return 1
409
+ return 2
410
+
411
+
412
+ def _kind_rank(kind: str) -> int:
413
+ """Return the deterministic in-column ordering rank for a node ``kind``."""
414
+ return _KIND_RANK.get(_kind_key(kind), _UNKNOWN_KIND_RANK)
415
+
416
+
417
+ def _ordered_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
418
+ """Return ``nodes`` sorted by ``(column, kind rank, id)``."""
419
+ return sorted(
420
+ nodes,
421
+ key=lambda node: (_column_index(node["kind"]), _kind_rank(node["kind"]), node["id"]),
422
+ )
423
+
424
+
425
+ def _truncate(value: str, limit: int = _LABEL_LIMIT) -> str:
426
+ """Shorten ``value`` to ``limit`` characters, marking the cut with an ellipsis."""
427
+ if len(value) <= limit:
428
+ return value
429
+ return value[: limit - 1] + "\u2026"
430
+
431
+
432
+ def _display_label(node: dict[str, Any]) -> str:
433
+ """Return the label drawn in a box, falling back to the node id when blank."""
434
+ label = node["label"]
435
+ return label if label.strip() else node["id"]
436
+
437
+
438
+ def _layout(nodes: list[dict[str, Any]]) -> tuple[dict[str, _Box], dict[int, int]]:
439
+ """Assign a box position to every node and count the rows used per column."""
440
+ rows: dict[int, int] = dict.fromkeys(range(_COLUMN_COUNT), 0)
441
+ boxes: dict[str, _Box] = {}
442
+ for node in nodes:
443
+ column = _column_index(node["kind"])
444
+ row = rows[column]
445
+ rows[column] = row + 1
446
+ boxes[node["id"]] = _Box(
447
+ x=_MARGIN + column * (_BOX_WIDTH + _COLUMN_GAP),
448
+ y=_MARGIN + _COLUMN_HEADER + row * (_BOX_HEIGHT + _ROW_GAP),
449
+ column=column,
450
+ )
451
+ return boxes, rows
452
+
453
+
454
+ def _canvas_width() -> int:
455
+ """Return the fixed SVG canvas width in user units."""
456
+ return 2 * _MARGIN + _COLUMN_COUNT * _BOX_WIDTH + (_COLUMN_COUNT - 1) * _COLUMN_GAP
457
+
458
+
459
+ def _canvas_height(rows: dict[int, int]) -> int:
460
+ """Return the SVG canvas height needed for the tallest column."""
461
+ tallest = max(rows.values(), default=0)
462
+ content = tallest * (_BOX_HEIGHT + _ROW_GAP) - _ROW_GAP if tallest else 0
463
+ return _MARGIN + _COLUMN_HEADER + content + _MARGIN
464
+
465
+
466
+ def _plural(count: int, noun: str) -> str:
467
+ """Render ``count`` together with a naively pluralized ``noun``."""
468
+ return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
469
+
470
+
471
+ def _render_head(heading: str) -> list[str]:
472
+ """Return the document prologue, ending right after ``</head>``."""
473
+ return [
474
+ "<!DOCTYPE html>",
475
+ '<html lang="en">',
476
+ "<head>",
477
+ '<meta charset="utf-8">',
478
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
479
+ f"<title>{_escape(heading)}</title>",
480
+ f"<style>\n{_STYLE}\n</style>",
481
+ "</head>",
482
+ ]
483
+
484
+
485
+ def _render_header(heading: str, node_count: int, edge_count: int) -> list[str]:
486
+ """Return ``<body>`` plus the header block with the node/edge counts."""
487
+ return [
488
+ "<body>",
489
+ "<header>",
490
+ f"<h1>{_escape(heading)}</h1>",
491
+ '<p class="counts">',
492
+ f'<span class="nodes">{_plural(node_count, "node")}</span>',
493
+ f'<span class="edges">{_plural(edge_count, "edge")}</span>',
494
+ "</p>",
495
+ "</header>",
496
+ ]
497
+
498
+
499
+ def _render_defs() -> list[str]:
500
+ """Return the ``defs`` block declaring the edge arrow marker."""
501
+ return [
502
+ "<defs>",
503
+ f'<marker id="{_ARROW_ID}" viewBox="0 0 10 10" refX="9" refY="5" '
504
+ 'markerWidth="7" markerHeight="7" orient="auto-start-reverse">',
505
+ '<path d="M0,0 L10,5 L0,10 z"/>',
506
+ "</marker>",
507
+ "</defs>",
508
+ ]
509
+
510
+
511
+ def _edge_anchors(source: _Box, target: _Box) -> tuple[int, int]:
512
+ """Return the x coordinates where an edge leaves ``source`` and meets ``target``."""
513
+ if target.x > source.x:
514
+ return source.x + _BOX_WIDTH, target.x
515
+ if target.x < source.x:
516
+ return source.x, target.x + _BOX_WIDTH
517
+ return source.x + _BOX_WIDTH, target.x + _BOX_WIDTH
518
+
519
+
520
+ def _render_edges(edges: list[dict[str, Any]], boxes: dict[str, _Box]) -> list[str]:
521
+ """Return the ``g`` group holding one arrowed line per edge."""
522
+ parts = ['<g class="edges">']
523
+ for edge in edges:
524
+ source = boxes[edge["source"]]
525
+ target = boxes[edge["target"]]
526
+ x1, x2 = _edge_anchors(source, target)
527
+ tooltip = _escape(f"{edge['source']} -> {edge['target']} ({edge['label']})")
528
+ parts.append(
529
+ f'<line class="edge" x1="{x1}" y1="{source.center_y}" x2="{x2}" '
530
+ f'y2="{target.center_y}" marker-end="url(#{_ARROW_ID})">'
531
+ f"<title>{tooltip}</title></line>"
532
+ )
533
+ parts.append("</g>")
534
+ return parts
535
+
536
+
537
+ def _render_nodes(nodes: list[dict[str, Any]], boxes: dict[str, _Box]) -> list[str]:
538
+ """Return the ``g`` group holding one labelled box per node."""
539
+ parts = ['<g class="nodes">']
540
+ for node in nodes:
541
+ box = boxes[node["id"]]
542
+ label = _escape(_truncate(_display_label(node)))
543
+ kind = node["kind"].strip()
544
+ node_kind = _escape(_kind_key(node["kind"]))
545
+ parts.append(f'<g class="node col-{box.column}" data-kind="{node_kind}">')
546
+ parts.append(f"<title>{_escape(node['id'])}</title>")
547
+ parts.append(
548
+ f'<rect x="{box.x}" y="{box.y}" width="{_BOX_WIDTH}" height="{_BOX_HEIGHT}" rx="6"/>'
549
+ )
550
+ parts.append(f'<text class="node-label" x="{box.center_x}" y="{box.y + 17}">{label}</text>')
551
+ if kind:
552
+ kind_y = box.y + 31
553
+ parts.append(
554
+ f'<text class="node-kind" x="{box.center_x}" y="{kind_y}">{_escape(kind)}</text>'
555
+ )
556
+ parts.append("</g>")
557
+ parts.append("</g>")
558
+ return parts
559
+
560
+
561
+ def _render_svg(
562
+ edges: list[dict[str, Any]],
563
+ nodes: list[dict[str, Any]],
564
+ boxes: dict[str, _Box],
565
+ rows: dict[int, int],
566
+ ) -> list[str]:
567
+ """Return the complete inline ``<svg>`` element."""
568
+ width = _canvas_width()
569
+ height = _canvas_height(rows)
570
+ parts = [
571
+ f'<svg class="graph" viewBox="0 0 {width} {height}" width="{width}" '
572
+ f'height="{height}" role="img" aria-label="Graph of nodes and edges">'
573
+ ]
574
+ parts.extend(_render_defs())
575
+ for column, column_title in enumerate(_COLUMN_TITLES):
576
+ x = _MARGIN + column * (_BOX_WIDTH + _COLUMN_GAP) + _BOX_WIDTH // 2
577
+ parts.append(
578
+ f'<text class="column-title" x="{x}" y="{_MARGIN + 16}">{_escape(column_title)}</text>'
579
+ )
580
+ parts.extend(_render_edges(edges, boxes))
581
+ parts.extend(_render_nodes(nodes, boxes))
582
+ if not nodes:
583
+ empty_y = _MARGIN + _COLUMN_HEADER + 20
584
+ parts.append(
585
+ f'<text class="empty" x="{width // 2}" y="{empty_y}">no nodes to display</text>'
586
+ )
587
+ parts.append("</svg>")
588
+ return parts
589
+
590
+
591
+ def _render_toolbar() -> list[str]:
592
+ """Return the interaction toolbar rendered directly above the SVG."""
593
+ parts = [
594
+ '<div class="toolbar" role="group" aria-label="graph controls">',
595
+ f'<input id="{_SEARCH_ID}" type="search" placeholder="search labels" '
596
+ 'aria-label="search labels">',
597
+ ]
598
+ for kind, caption in _FILTERS:
599
+ parts.append(
600
+ '<label class="cv-filter">'
601
+ f'<input class="{_FILTER_CLASS}" type="checkbox" data-kind="{kind}" checked> '
602
+ f"{caption}</label>"
603
+ )
604
+ parts.append("</div>")
605
+ return parts
606
+
607
+
608
+ def _render_script() -> list[str]:
609
+ """Return the single inline behaviour ``script`` element (no ``src``)."""
610
+ return ["<script>", _SCRIPT, "</script>"]
611
+
612
+
613
+ def _render_edge_table(edges: list[dict[str, Any]]) -> list[str]:
614
+ """Return the collapsible ``details`` block listing every edge."""
615
+ parts = [
616
+ '<details class="edges">',
617
+ f"<summary>Edges ({len(edges)})</summary>",
618
+ "<table>",
619
+ "<thead><tr><th>#</th><th>source</th><th>target</th><th>label</th></tr></thead>",
620
+ "<tbody>",
621
+ ]
622
+ if not edges:
623
+ parts.append('<tr><td colspan="4">no edges</td></tr>')
624
+ for index, edge in enumerate(edges, start=1):
625
+ parts.append(
626
+ f"<tr><td>{index}</td><td>{_escape(edge['source'])}</td>"
627
+ f"<td>{_escape(edge['target'])}</td><td>{_escape(edge['label'])}</td></tr>"
628
+ )
629
+ parts.extend(["</tbody>", "</table>", "</details>"])
630
+ return parts
631
+
632
+
633
+ def _json_payload(validated: dict[str, Any], heading: str) -> str:
634
+ """Return the normalized graph as compact JSON with ``"</"`` defanged."""
635
+ payload = json.dumps(
636
+ {"title": heading, "nodes": validated["nodes"], "edges": validated["edges"]},
637
+ ensure_ascii=True,
638
+ sort_keys=True,
639
+ separators=(",", ":"),
640
+ )
641
+ return payload.replace("</", "<\\/")
642
+
643
+
644
+ def _render_payload(validated: dict[str, Any], heading: str) -> list[str]:
645
+ """Return the ``application/json`` script element carrying the raw graph."""
646
+ return [
647
+ '<script type="application/json" id="codevanta-graph">',
648
+ _json_payload(validated, heading),
649
+ "</script>",
650
+ ]
651
+
652
+
653
+ def to_html(
654
+ graph: Any,
655
+ title: str = DEFAULT_TITLE,
656
+ max_nodes: int = DEFAULT_MAX_NODES,
657
+ max_edges: int = DEFAULT_MAX_EDGES,
658
+ ) -> str:
659
+ """Render ``graph`` as a self-contained HTML5 document with an inline SVG view.
660
+
661
+ The document holds the escaped title and node/edge counts in a header, an
662
+ interaction toolbar (label search plus one checkbox per kind group), a
663
+ three-column SVG (function/entry_point, call_site, everything else) with
664
+ labelled node boxes and arrowed edges, a collapsible table listing every edge,
665
+ an ``application/json`` payload with the normalized graph and a single inline
666
+ behaviour script implementing pan, wheel zoom, search and filtering.
667
+
668
+ Args:
669
+ graph: Mapping with ``"nodes"`` and ``"edges"`` lists (see
670
+ :mod:`codevanta.analysis.graph_export`).
671
+ title: Page title; blank titles fall back to :data:`DEFAULT_TITLE`.
672
+ max_nodes: Maximum number of nodes to accept (``>= 1``).
673
+ max_edges: Maximum number of edges to accept (``>= 1``).
674
+
675
+ Returns:
676
+ A newline-terminated HTML5 document that references no external resource.
677
+
678
+ Raises:
679
+ GraphExportError: If the graph, the title or any limit violates the
680
+ contract.
681
+ """
682
+ validated = validate_graph(graph, max_nodes, max_edges)
683
+ heading = _check_title(title)
684
+ nodes = _ordered_nodes(validated["nodes"])
685
+ boxes, rows = _layout(nodes)
686
+
687
+ parts = _render_head(heading)
688
+ parts.extend(_render_header(heading, len(nodes), len(validated["edges"])))
689
+ parts.extend(_render_toolbar())
690
+ parts.extend(_render_svg(validated["edges"], nodes, boxes, rows))
691
+ parts.extend(_render_edge_table(validated["edges"]))
692
+ parts.extend(_render_payload(validated, heading))
693
+ parts.extend(_render_script())
694
+ parts.extend(["</body>", "</html>"])
695
+ return "\n".join(parts) + "\n"