pcode-graph 1.0.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.
- pcode_graph/__init__.py +0 -0
- pcode_graph/__main__.py +3 -0
- pcode_graph/analysis.py +395 -0
- pcode_graph/arch.py +10 -0
- pcode_graph/asm.py +60 -0
- pcode_graph/charac.py +97 -0
- pcode_graph/checker.py +120 -0
- pcode_graph/cli.py +175 -0
- pcode_graph/emulator.py +419 -0
- pcode_graph/gnn_exporter.py +208 -0
- pcode_graph/graph.py +234 -0
- pcode_graph/lief_importer.py +299 -0
- pcode_graph/log.py +36 -0
- pcode_graph/maker.py +369 -0
- pcode_graph/nx_exporter.py +20 -0
- pcode_graph/pcode.py +187 -0
- pcode_graph/registers.py +2929 -0
- pcode_graph/simplifier.py +185 -0
- pcode_graph/torch_utils.py +30 -0
- pcode_graph/translator.py +235 -0
- pcode_graph/utils.py +15 -0
- pcode_graph/view.py +65 -0
- pcode_graph/visitor.py +103 -0
- pcode_graph-1.0.0.dist-info/METADATA +205 -0
- pcode_graph-1.0.0.dist-info/RECORD +28 -0
- pcode_graph-1.0.0.dist-info/WHEEL +4 -0
- pcode_graph-1.0.0.dist-info/entry_points.txt +2 -0
- pcode_graph-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from torch import Tensor
|
|
2
|
+
import torch
|
|
3
|
+
from torch_geometric.data import Data
|
|
4
|
+
from pcode_graph.arch import Arch
|
|
5
|
+
from pcode_graph.pcode import OpCodes
|
|
6
|
+
from pcode_graph.registers import ALIAS, CALLING_CONVENTIONS, PARENTS
|
|
7
|
+
from pcode_graph.torch_utils import BinaryConverter
|
|
8
|
+
from pcode_graph.graph import EdgeKinds, NodeKinds, Node, CDG
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def map_registers(registers: set[str], arch: Arch) -> dict[str, Tensor]:
|
|
12
|
+
"""Maps the given register names to a one-hot encoded mask,
|
|
13
|
+
handling registers aliases."""
|
|
14
|
+
|
|
15
|
+
kept_regs: set[str] = set()
|
|
16
|
+
|
|
17
|
+
for r in registers:
|
|
18
|
+
kept_regs.add(PARENTS[arch].get(r, r))
|
|
19
|
+
|
|
20
|
+
masks = {}
|
|
21
|
+
|
|
22
|
+
zeros = torch.zeros((len(kept_regs),), dtype=torch.float32)
|
|
23
|
+
for i, r in enumerate(kept_regs):
|
|
24
|
+
mask = zeros.clone()
|
|
25
|
+
mask[i] = 1
|
|
26
|
+
|
|
27
|
+
return masks
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def map_calling_convention_registers(arch: Arch) -> dict[str, Tensor]:
|
|
31
|
+
"""Maps the register involved in parameter passing and value-return to a bit mask."""
|
|
32
|
+
|
|
33
|
+
lengths: dict[str, int] = {}
|
|
34
|
+
|
|
35
|
+
for arch_cc in CALLING_CONVENTIONS.values():
|
|
36
|
+
for k, regs in arch_cc.items():
|
|
37
|
+
lengths[k] = max(len(regs), lengths.get(k, 0))
|
|
38
|
+
|
|
39
|
+
arch_cc = CALLING_CONVENTIONS[arch]
|
|
40
|
+
registers = sum(arch_cc.values(), start=[])
|
|
41
|
+
zeros = torch.zeros((sum(lengths.values()),), dtype=torch.float)
|
|
42
|
+
masks: dict[str, Tensor] = {r: zeros.clone() for r in registers}
|
|
43
|
+
|
|
44
|
+
i = 0
|
|
45
|
+
for key, l in lengths.items():
|
|
46
|
+
for j, r in enumerate(arch_cc.get(key, [])):
|
|
47
|
+
masks[r][i + j] = 1
|
|
48
|
+
i += l
|
|
49
|
+
|
|
50
|
+
for r in registers:
|
|
51
|
+
for a in ALIAS[arch].get(r, set()):
|
|
52
|
+
masks[a] = masks[r]
|
|
53
|
+
|
|
54
|
+
return masks
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def node_to_tensor(
|
|
58
|
+
node: Node,
|
|
59
|
+
include_size: bool,
|
|
60
|
+
value_converter: BinaryConverter,
|
|
61
|
+
registers_emb: dict[str, Tensor] | None,
|
|
62
|
+
opcodes_mapper: dict[OpCodes, int],
|
|
63
|
+
node_kinds_mapper: dict[NodeKinds, int],
|
|
64
|
+
encode_registers_in_all_nodes: bool,
|
|
65
|
+
) -> Tensor:
|
|
66
|
+
"""Computes the initial feature for the given node."""
|
|
67
|
+
|
|
68
|
+
features = []
|
|
69
|
+
|
|
70
|
+
node_kind = torch.zeros((len(node_kinds_mapper),), dtype=torch.float32)
|
|
71
|
+
if node.kind in node_kinds_mapper:
|
|
72
|
+
node_kind[node_kinds_mapper[node.kind]] = 1
|
|
73
|
+
features.append(node_kind)
|
|
74
|
+
|
|
75
|
+
max_bytes = value_converter.num_bits // 8
|
|
76
|
+
if include_size:
|
|
77
|
+
# Use hot-encoding for possible value sizes,
|
|
78
|
+
# which are necessarily powers of two
|
|
79
|
+
size_indexes = {
|
|
80
|
+
1: 0,
|
|
81
|
+
2: 1,
|
|
82
|
+
4: 2,
|
|
83
|
+
8: 3,
|
|
84
|
+
16: 4,
|
|
85
|
+
}
|
|
86
|
+
node_size = torch.zeros((size_indexes[max_bytes] + 1,), dtype=torch.float32)
|
|
87
|
+
if node.size:
|
|
88
|
+
node_size[size_indexes[node.size]] = 1
|
|
89
|
+
features.append(node_size)
|
|
90
|
+
|
|
91
|
+
node_opcode = torch.zeros((len(opcodes_mapper),), dtype=torch.float32)
|
|
92
|
+
if node.opcode is not None:
|
|
93
|
+
node_opcode[opcodes_mapper[node.opcode]] = 1
|
|
94
|
+
features.append(node_opcode)
|
|
95
|
+
|
|
96
|
+
if registers_emb is not None:
|
|
97
|
+
mask = unknown_reg = torch.zeros_like(next(iter(registers_emb.values())))
|
|
98
|
+
if (
|
|
99
|
+
node.kind in {NodeKinds.InputRegister, NodeKinds.OutputRegister}
|
|
100
|
+
or encode_registers_in_all_nodes
|
|
101
|
+
):
|
|
102
|
+
mask = registers_emb.get(node.register_name, unknown_reg)
|
|
103
|
+
features.append(mask)
|
|
104
|
+
|
|
105
|
+
node_val = value_converter.value_to_bits(
|
|
106
|
+
node.signed_value() if node.kind == NodeKinds.Constant else 0
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
features.append(node_val)
|
|
110
|
+
|
|
111
|
+
return torch.cat(features)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Used to encode the order of operation inputs,
|
|
115
|
+
# (except for OutputRegister, Phi and CALLOTHER Operation,
|
|
116
|
+
# which can have more inputs) and the special successor
|
|
117
|
+
# for CBRANCH, CALL and CALLIND.
|
|
118
|
+
MAX_OPERAND_NUMBER = 2
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def graph_to_data(
|
|
122
|
+
graph: CDG,
|
|
123
|
+
registers_emb: dict[str, Tensor] | None = None,
|
|
124
|
+
opcodes_mapper: dict[OpCodes, int] | None = None,
|
|
125
|
+
node_kinds_mapper: dict[NodeKinds, int] | None = None,
|
|
126
|
+
max_value_bytes: int = 4,
|
|
127
|
+
include_size: bool = False,
|
|
128
|
+
include_output_registers_for_all_node: bool = False,
|
|
129
|
+
) -> Data:
|
|
130
|
+
"""Converts this graph into pytorch geometric Data format.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
graph (CDG): the graph to export.
|
|
134
|
+
registers_emb (dict[str, Tensor]): Dictionary giving the embedding to use for a set of registers
|
|
135
|
+
to encode in node features. Registers not found in the dict will be encoded as full-zero.
|
|
136
|
+
You can pass None to avoid any register encoding in node features.
|
|
137
|
+
opcodes_mapper (dict[OpCodes, int]): A dict mapping Operation OpCodes into zero-based indexes.
|
|
138
|
+
OpCodes are one-hot encoded. OpCodes not found in the dict are converted to zero.
|
|
139
|
+
Pass None to use the whole list of supported opcodes.
|
|
140
|
+
node_kinds_mapper (dict[NodeKinds, int]): A dict mapping node kinds into zero-based indexes.
|
|
141
|
+
Node kinds are one-hot encoded. Node kinds not found in the dict are converted to zero.
|
|
142
|
+
Pass None to use the whole list of node kinds.
|
|
143
|
+
max_value_bytes (int): Maximum bytes to encode when putting constant values in the graph.
|
|
144
|
+
include_size (bool): Whether to add sizes in node features.
|
|
145
|
+
include_output_registers_for_all_node (bool): Whether to include output register for all node
|
|
146
|
+
else, only InputRegister and OutputRegister node include them.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Data: the graph in pytorch_geometric format.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
value_converter = BinaryConverter(max_value_bytes * 8)
|
|
153
|
+
|
|
154
|
+
features = []
|
|
155
|
+
edge_src = []
|
|
156
|
+
edge_dst = []
|
|
157
|
+
edge_attr = []
|
|
158
|
+
|
|
159
|
+
if opcodes_mapper is None:
|
|
160
|
+
opcodes_mapper = {o: o.value for o in OpCodes}
|
|
161
|
+
|
|
162
|
+
if node_kinds_mapper is None:
|
|
163
|
+
node_kinds_mapper = {k: k.value for k in NodeKinds}
|
|
164
|
+
|
|
165
|
+
for index, node in enumerate(graph.nodes):
|
|
166
|
+
node_features = node_to_tensor(
|
|
167
|
+
node,
|
|
168
|
+
include_size,
|
|
169
|
+
value_converter,
|
|
170
|
+
registers_emb,
|
|
171
|
+
opcodes_mapper,
|
|
172
|
+
node_kinds_mapper,
|
|
173
|
+
include_output_registers_for_all_node
|
|
174
|
+
or node.kind in {NodeKinds.InputRegister, NodeKinds.OutputRegister},
|
|
175
|
+
)
|
|
176
|
+
features.append(node_features)
|
|
177
|
+
|
|
178
|
+
# Gather edges by node
|
|
179
|
+
assert len(EdgeKinds) == 2
|
|
180
|
+
for edge in graph.edges:
|
|
181
|
+
attr = torch.zeros((2 + MAX_OPERAND_NUMBER,))
|
|
182
|
+
if edge.kind == EdgeKinds.Data:
|
|
183
|
+
attr[0] = 1
|
|
184
|
+
else:
|
|
185
|
+
attr[1] = 1
|
|
186
|
+
if edge.operand_number is not None and edge.operand_number < MAX_OPERAND_NUMBER:
|
|
187
|
+
attr[edge.operand_number + 2] = 1
|
|
188
|
+
|
|
189
|
+
edge_src.append(edge.source_node)
|
|
190
|
+
edge_dst.append(edge.destination_node)
|
|
191
|
+
edge_attr.append(attr)
|
|
192
|
+
|
|
193
|
+
if len(edge_attr) == 0:
|
|
194
|
+
# Support for a graph without any edge
|
|
195
|
+
edges = torch.zeros((2, 2 + MAX_OPERAND_NUMBER, 0))
|
|
196
|
+
else:
|
|
197
|
+
edges = torch.stack(edge_attr)
|
|
198
|
+
|
|
199
|
+
return Data(
|
|
200
|
+
x=torch.stack(features),
|
|
201
|
+
edge_index=torch.stack(
|
|
202
|
+
(
|
|
203
|
+
torch.tensor(edge_src, dtype=torch.int64),
|
|
204
|
+
torch.tensor(edge_dst, dtype=torch.int64),
|
|
205
|
+
)
|
|
206
|
+
),
|
|
207
|
+
edge_attr=edges,
|
|
208
|
+
)
|
pcode_graph/graph.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from dataclasses import dataclass, replace
|
|
2
|
+
from enum import Enum, auto
|
|
3
|
+
from pcode_graph.pcode import OpCodes
|
|
4
|
+
from pcode_graph.utils import unsigned_to_signed
|
|
5
|
+
|
|
6
|
+
NodeIndex = int
|
|
7
|
+
OperandNumber = int
|
|
8
|
+
RegisterId = int
|
|
9
|
+
Value = int
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NodeKinds(Enum):
|
|
13
|
+
|
|
14
|
+
InputRegister = 0
|
|
15
|
+
OutputRegister = 1
|
|
16
|
+
Constant = 2
|
|
17
|
+
|
|
18
|
+
Operation = 3
|
|
19
|
+
Phi = 4
|
|
20
|
+
|
|
21
|
+
ReadMemory = 5
|
|
22
|
+
WrittenMemory = 6
|
|
23
|
+
|
|
24
|
+
Begin = 7
|
|
25
|
+
External = 8
|
|
26
|
+
End = 9
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EdgeKinds(Enum):
|
|
30
|
+
Data = 0
|
|
31
|
+
Control = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Edge:
|
|
36
|
+
source_node: NodeIndex
|
|
37
|
+
|
|
38
|
+
destination_node: NodeIndex
|
|
39
|
+
|
|
40
|
+
# Kind of edge
|
|
41
|
+
kind: EdgeKinds
|
|
42
|
+
|
|
43
|
+
# Operand rank if has meaning
|
|
44
|
+
operand_number: OperandNumber | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Node:
|
|
49
|
+
# Kind of node
|
|
50
|
+
kind: NodeKinds
|
|
51
|
+
|
|
52
|
+
# Number of bytes of the input or operation represented by this node
|
|
53
|
+
size: int = 0
|
|
54
|
+
|
|
55
|
+
# Value for a constant node
|
|
56
|
+
value: Value = 0
|
|
57
|
+
|
|
58
|
+
# Id of the register whatever its size version (ex: w0 and x0 will have same id in arm64)
|
|
59
|
+
# Warning: this value is unique in the current pcode context only.
|
|
60
|
+
register_id: RegisterId = -1
|
|
61
|
+
|
|
62
|
+
# Name of the register, for display purpose
|
|
63
|
+
register_name: str = ""
|
|
64
|
+
|
|
65
|
+
# Operation code
|
|
66
|
+
opcode: OpCodes | None = None
|
|
67
|
+
|
|
68
|
+
def __post_init__(self):
|
|
69
|
+
assert (self.kind == NodeKinds.Operation) ^ (self.opcode is None)
|
|
70
|
+
|
|
71
|
+
def signed_value(self) -> int:
|
|
72
|
+
assert self.kind == NodeKinds.Constant
|
|
73
|
+
return unsigned_to_signed(self.value, self.size)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def size_bits(self) -> int:
|
|
77
|
+
return self.size * 8
|
|
78
|
+
|
|
79
|
+
def __str__(self):
|
|
80
|
+
match self.kind:
|
|
81
|
+
case NodeKinds.ReadMemory | NodeKinds.WrittenMemory:
|
|
82
|
+
return "MEMORY"
|
|
83
|
+
case NodeKinds.InputRegister | NodeKinds.OutputRegister:
|
|
84
|
+
return self.register_name
|
|
85
|
+
case NodeKinds.Constant:
|
|
86
|
+
return f"#{hex(self.signed_value())}"
|
|
87
|
+
case NodeKinds.Operation:
|
|
88
|
+
assert self.opcode is not None
|
|
89
|
+
return self.opcode.name
|
|
90
|
+
case NodeKinds.Phi:
|
|
91
|
+
return "ϕ"
|
|
92
|
+
case NodeKinds.Begin:
|
|
93
|
+
return "BEGIN"
|
|
94
|
+
case NodeKinds.External:
|
|
95
|
+
return "EXTERNAL"
|
|
96
|
+
case NodeKinds.End:
|
|
97
|
+
return "END"
|
|
98
|
+
case _:
|
|
99
|
+
raise ValueError(self.kind)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class EdgeProxy:
|
|
104
|
+
"""Easy access to edges for one node."""
|
|
105
|
+
|
|
106
|
+
in_edges: list[Edge]
|
|
107
|
+
out_edges: list[Edge]
|
|
108
|
+
|
|
109
|
+
def get_data_predecessors(self) -> list[NodeIndex]:
|
|
110
|
+
return [
|
|
111
|
+
edge.source_node for edge in self.in_edges if edge.kind == EdgeKinds.Data
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def get_control_predecessors(self) -> list[NodeIndex]:
|
|
115
|
+
return [
|
|
116
|
+
edge.source_node for edge in self.in_edges if edge.kind == EdgeKinds.Control
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def get_data_successors(self) -> list[NodeIndex]:
|
|
120
|
+
return [
|
|
121
|
+
edge.destination_node
|
|
122
|
+
for edge in self.out_edges
|
|
123
|
+
if edge.kind == EdgeKinds.Data
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
def get_control_successors(self) -> list[NodeIndex]:
|
|
127
|
+
return [
|
|
128
|
+
edge.destination_node
|
|
129
|
+
for edge in self.out_edges
|
|
130
|
+
if edge.kind == EdgeKinds.Control
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
def get_inputs_edges(self) -> list[Edge]:
|
|
134
|
+
"""Returns data input operands, by number."""
|
|
135
|
+
operands: list[tuple[OperandNumber | None, Edge]] = []
|
|
136
|
+
hasNone = False
|
|
137
|
+
hasNumber = False
|
|
138
|
+
for in_edge in self.in_edges:
|
|
139
|
+
if in_edge.kind == EdgeKinds.Control:
|
|
140
|
+
continue
|
|
141
|
+
n = in_edge.operand_number
|
|
142
|
+
if n is None:
|
|
143
|
+
hasNone = True
|
|
144
|
+
else:
|
|
145
|
+
hasNumber = True
|
|
146
|
+
operands.append((n, in_edge))
|
|
147
|
+
assert hasNone ^ hasNumber
|
|
148
|
+
if hasNumber:
|
|
149
|
+
operands.sort()
|
|
150
|
+
return [o[1] for o in operands]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class CDG:
|
|
155
|
+
"""
|
|
156
|
+
Graph of P-Code operations with Control and Data flow edges.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
# Graph nodes
|
|
160
|
+
# Order in this list gives the NodeIndex used in Edge class
|
|
161
|
+
nodes: list[Node]
|
|
162
|
+
|
|
163
|
+
# Edges, order does not matter
|
|
164
|
+
edges: list[Edge]
|
|
165
|
+
|
|
166
|
+
def clone(self) -> "CDG":
|
|
167
|
+
return CDG([replace(n) for n in self.nodes], [replace(e) for e in self.edges])
|
|
168
|
+
|
|
169
|
+
def compute_edge_proxy(self) -> dict[NodeIndex, EdgeProxy]:
|
|
170
|
+
cache = {index: EdgeProxy([], []) for index in range(len(self.nodes))}
|
|
171
|
+
for edge in self.edges:
|
|
172
|
+
cache[edge.source_node].out_edges.append(edge)
|
|
173
|
+
cache[edge.destination_node].in_edges.append(edge)
|
|
174
|
+
return cache
|
|
175
|
+
|
|
176
|
+
def add_node(self, node: Node) -> NodeIndex:
|
|
177
|
+
"""Adds a node into the graph, without any edge. Returns the index of the added node."""
|
|
178
|
+
index = len(self.nodes)
|
|
179
|
+
self.nodes.append(node)
|
|
180
|
+
return index
|
|
181
|
+
|
|
182
|
+
def remove_node(self, index: NodeIndex):
|
|
183
|
+
"""Removes a node from the graph and its immediate edges, former linked nodes can remain unconnected."""
|
|
184
|
+
|
|
185
|
+
def remap(i: NodeIndex) -> NodeIndex:
|
|
186
|
+
return i - int(i > index)
|
|
187
|
+
|
|
188
|
+
self.nodes.pop(index)
|
|
189
|
+
self.edges = [
|
|
190
|
+
replace(
|
|
191
|
+
e,
|
|
192
|
+
source_node=remap(e.source_node),
|
|
193
|
+
destination_node=remap(e.destination_node),
|
|
194
|
+
)
|
|
195
|
+
for e in self.edges
|
|
196
|
+
if e.source_node != index and e.destination_node != index
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
def add_edge(self, edge: Edge):
|
|
200
|
+
self.edges.append(edge)
|
|
201
|
+
|
|
202
|
+
def remove_edge(self, edge: Edge):
|
|
203
|
+
self.edges.remove(edge)
|
|
204
|
+
|
|
205
|
+
def dump(self) -> str:
|
|
206
|
+
"""Dumps the graph in mermaid format."""
|
|
207
|
+
s = ""
|
|
208
|
+
for index, node in enumerate(self.nodes):
|
|
209
|
+
match node.kind:
|
|
210
|
+
case NodeKinds.Begin | NodeKinds.End | NodeKinds.External:
|
|
211
|
+
s += f"n{index}[[{node}]]\n"
|
|
212
|
+
case NodeKinds.Operation:
|
|
213
|
+
s += f"n{index}[{node}]\n"
|
|
214
|
+
case NodeKinds.Phi:
|
|
215
|
+
s += f"n{index}" + "{{" + f"{node}" + "}}\n"
|
|
216
|
+
case _:
|
|
217
|
+
s += f"n{index}(({node}))\n"
|
|
218
|
+
|
|
219
|
+
for edge in self.edges:
|
|
220
|
+
link = "-->" if edge.kind == EdgeKinds.Data else "-.->"
|
|
221
|
+
rank = f"|{edge.operand_number}|" if edge.operand_number is not None else ""
|
|
222
|
+
s += f"n{edge.source_node} {link}{rank} n{edge.destination_node}\n"
|
|
223
|
+
|
|
224
|
+
return s
|
|
225
|
+
|
|
226
|
+
def dump_edge(self, edge: Edge) -> str:
|
|
227
|
+
link = "-->" if edge.kind == EdgeKinds.Data else "-.->"
|
|
228
|
+
rank = f"|{edge.operand_number}|" if edge.operand_number is not None else ""
|
|
229
|
+
return f"{self.nodes[edge.source_node]} {link}{rank} {self.nodes[edge.destination_node]}"
|
|
230
|
+
|
|
231
|
+
def __str__(self) -> str:
|
|
232
|
+
"""Dumps the graph in markdown + mermaid format."""
|
|
233
|
+
|
|
234
|
+
return "```mermaid\nflowchart\n" + self.dump() + "```\n"
|