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.
- codevanta/__init__.py +1 -0
- codevanta/analysis/__init__.py +1 -0
- codevanta/analysis/binary_diff.py +1168 -0
- codevanta/analysis/callgraph.py +542 -0
- codevanta/analysis/cfg.py +1201 -0
- codevanta/analysis/dataflow.py +762 -0
- codevanta/analysis/debugger.py +289 -0
- codevanta/analysis/debuginfo.py +304 -0
- codevanta/analysis/decompile.py +1094 -0
- codevanta/analysis/decompilers/__init__.py +250 -0
- codevanta/analysis/decompilers/angr_backend.py +251 -0
- codevanta/analysis/decompilers/base.py +278 -0
- codevanta/analysis/decompilers/ghidra_backend.py +316 -0
- codevanta/analysis/dynamic.py +756 -0
- codevanta/analysis/emulate.py +1349 -0
- codevanta/analysis/evidence_bundle.py +736 -0
- codevanta/analysis/function_hash.py +562 -0
- codevanta/analysis/graph_export.py +441 -0
- codevanta/analysis/html_view.py +695 -0
- codevanta/analysis/jump_tables.py +546 -0
- codevanta/analysis/solver.py +715 -0
- codevanta/analysis/symbolic.py +1809 -0
- codevanta/analysis/trace.py +646 -0
- codevanta/analysis/type_propagation.py +1055 -0
- codevanta/analysis/type_recovery.py +1214 -0
- codevanta/analysis/vtables.py +572 -0
- codevanta/analysis/xrefs.py +434 -0
- codevanta/api/__init__.py +1 -0
- codevanta/cli/__init__.py +1 -0
- codevanta/cli/main.py +3038 -0
- codevanta/core/__init__.py +31 -0
- codevanta/core/disassembler.py +121 -0
- codevanta/core/entropy.py +237 -0
- codevanta/core/functions.py +176 -0
- codevanta/core/loader.py +1351 -0
- codevanta/core/report.py +306 -0
- codevanta/core/strings.py +109 -0
- codevanta/evidence/__init__.py +7 -0
- codevanta/evidence/confidence.py +36 -0
- codevanta/evidence/models.py +43 -0
- codevanta/evidence/provenance.py +37 -0
- codevanta/gui/__init__.py +46 -0
- codevanta/gui/layout.py +1384 -0
- codevanta/gui/server.py +1305 -0
- codevanta/ir/__init__.py +6 -0
- codevanta/ir/adapters/__init__.py +31 -0
- codevanta/ir/base.py +31 -0
- codevanta/ir/instruction.py +42 -0
- codevanta/mcp_server/__init__.py +5 -0
- codevanta/mcp_server/server.py +710 -0
- codevanta/mcp_server/tools.py +1364 -0
- codevanta/plugins/__init__.py +748 -0
- codevanta/setup/__init__.py +28 -0
- codevanta/setup/apply.py +22 -0
- codevanta/setup/clients.py +285 -0
- codevanta/setup/plan.py +79 -0
- codevanta/setup/verify.py +43 -0
- codevanta/storage/__init__.py +1 -0
- codevanta/storage/session.py +514 -0
- codevanta-0.1.0.dist-info/METADATA +688 -0
- codevanta-0.1.0.dist-info/RECORD +65 -0
- codevanta-0.1.0.dist-info/WHEEL +5 -0
- codevanta-0.1.0.dist-info/entry_points.txt +2 -0
- codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"
|