codedebrief 0.11.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.
- codedebrief/__init__.py +12 -0
- codedebrief/analysis/__init__.py +16 -0
- codedebrief/analysis/common.py +527 -0
- codedebrief/analysis/discovery.py +100 -0
- codedebrief/analysis/languages/__init__.py +6 -0
- codedebrief/analysis/languages/_common.py +68 -0
- codedebrief/analysis/languages/c.py +96 -0
- codedebrief/analysis/languages/cpp.py +146 -0
- codedebrief/analysis/languages/csharp.py +137 -0
- codedebrief/analysis/languages/go.py +157 -0
- codedebrief/analysis/languages/java.py +158 -0
- codedebrief/analysis/languages/php.py +83 -0
- codedebrief/analysis/languages/ruby.py +75 -0
- codedebrief/analysis/languages/rust.py +96 -0
- codedebrief/analysis/project.py +373 -0
- codedebrief/analysis/python.py +939 -0
- codedebrief/analysis/registry.py +320 -0
- codedebrief/analysis/treesitter.py +884 -0
- codedebrief/analysis/typescript.py +1019 -0
- codedebrief/artifacts.py +49 -0
- codedebrief/cli.py +585 -0
- codedebrief/config.py +226 -0
- codedebrief/doctor.py +175 -0
- codedebrief/install.py +441 -0
- codedebrief/mcp_server.py +2720 -0
- codedebrief/model.py +189 -0
- codedebrief/py.typed +1 -0
- codedebrief/quality.py +392 -0
- codedebrief/query.py +641 -0
- codedebrief/render/__init__.py +6 -0
- codedebrief/render/assets/generated/codedebrief-viewer-runtime.iife.js +10 -0
- codedebrief/render/assets/panels.js +462 -0
- codedebrief/render/assets/shell.js +1649 -0
- codedebrief/render/assets/styles.css +1715 -0
- codedebrief/render/assets/tree.js +616 -0
- codedebrief/render/html.py +191 -0
- codedebrief/render/markdown.py +153 -0
- codedebrief/render/payload.py +326 -0
- codedebrief/render/snapshot.py +769 -0
- codedebrief/schema/codedebrief.schema.json +449 -0
- codedebrief/util.py +65 -0
- codedebrief/validation.py +214 -0
- codedebrief-0.11.0.dist-info/METADATA +426 -0
- codedebrief-0.11.0.dist-info/RECORD +48 -0
- codedebrief-0.11.0.dist-info/WHEEL +4 -0
- codedebrief-0.11.0.dist-info/entry_points.txt +2 -0
- codedebrief-0.11.0.dist-info/licenses/LICENSE +176 -0
- codedebrief-0.11.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from html import escape
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from codedebrief.model import Flow, FlowNode, NodeKind, ProjectModel
|
|
9
|
+
|
|
10
|
+
SNAPSHOT_FORMATS = ("svg",)
|
|
11
|
+
MAX_FLOW_NODES = 44
|
|
12
|
+
MAX_SUBGRAPH_FLOWS = 8
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class _RenderedSubgraphFlow:
|
|
17
|
+
flow: Flow
|
|
18
|
+
highlighted: set[str]
|
|
19
|
+
nodes: list[FlowNode]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class _LayoutBox:
|
|
24
|
+
id: str
|
|
25
|
+
x: float
|
|
26
|
+
y: float
|
|
27
|
+
width: float
|
|
28
|
+
height: float
|
|
29
|
+
kind: str
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def right(self) -> float:
|
|
33
|
+
return self.x + self.width
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def bottom(self) -> float:
|
|
37
|
+
return self.y + self.height
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class _LayoutEdge:
|
|
42
|
+
source: str
|
|
43
|
+
target: str
|
|
44
|
+
label: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
SNAPSHOT_WIDTH = 720
|
|
48
|
+
FLOW_NODE_WIDTH = 340
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def unsupported_snapshot_format(requested: str) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"error": f"Unsupported snapshot format: {requested}",
|
|
54
|
+
"error_code": "unsupported_snapshot_format",
|
|
55
|
+
"requested_format": requested,
|
|
56
|
+
"supported_formats": list(SNAPSHOT_FORMATS),
|
|
57
|
+
"recoverable": True,
|
|
58
|
+
"guardrail": (
|
|
59
|
+
"This reports an unsupported visual export format for CodeDebrief snapshots."
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _subgraph_empty_error() -> dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"error": "Subgraph snapshot requires at least one flow_id.",
|
|
67
|
+
"error_code": "snapshot_subgraph_empty",
|
|
68
|
+
"target_type": "subgraph",
|
|
69
|
+
"recoverable": True,
|
|
70
|
+
"guardrail": "This reports an empty visual snapshot request.",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _unique(items: list[str]) -> list[str]:
|
|
75
|
+
seen: set[str] = set()
|
|
76
|
+
result: list[str] = []
|
|
77
|
+
for item in items:
|
|
78
|
+
if item in seen:
|
|
79
|
+
continue
|
|
80
|
+
result.append(item)
|
|
81
|
+
seen.add(item)
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_subgraph_snapshot(
|
|
86
|
+
model: ProjectModel,
|
|
87
|
+
*,
|
|
88
|
+
flow_ids: list[str] | None = None,
|
|
89
|
+
max_flows: int | None = None,
|
|
90
|
+
max_nodes: int | None = None,
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""Render a deterministic SVG for an explicit flow subgraph."""
|
|
93
|
+
requested_flow_ids = _unique(flow_ids or [])
|
|
94
|
+
if not requested_flow_ids:
|
|
95
|
+
return _subgraph_empty_error()
|
|
96
|
+
|
|
97
|
+
flows_by_id = {flow.id: flow for flow in model.flows}
|
|
98
|
+
unresolved_targets: list[dict[str, str]] = []
|
|
99
|
+
selected_flow_ids: list[str] = []
|
|
100
|
+
highlighted_node_ids: set[str] = set()
|
|
101
|
+
|
|
102
|
+
for flow_id in requested_flow_ids:
|
|
103
|
+
if flow_id in flows_by_id:
|
|
104
|
+
selected_flow_ids.append(flow_id)
|
|
105
|
+
else:
|
|
106
|
+
unresolved_targets.append({"type": "flow", "value": flow_id, "reason": "not_found"})
|
|
107
|
+
|
|
108
|
+
selected_flows = [
|
|
109
|
+
flows_by_id[flow_id] for flow_id in selected_flow_ids if flow_id in flows_by_id
|
|
110
|
+
]
|
|
111
|
+
flow_limit = _effective_limit(max_flows, MAX_SUBGRAPH_FLOWS)
|
|
112
|
+
rendered_flows = selected_flows[:flow_limit]
|
|
113
|
+
rendered: list[_RenderedSubgraphFlow] = []
|
|
114
|
+
for flow in rendered_flows:
|
|
115
|
+
rendered.append(
|
|
116
|
+
_RenderedSubgraphFlow(
|
|
117
|
+
flow=flow,
|
|
118
|
+
highlighted=set(),
|
|
119
|
+
nodes=_select_flow_nodes(flow, set(), max_nodes),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
layout = _subgraph_layout(rendered, unresolved_targets)
|
|
123
|
+
svg = _subgraph_svg(
|
|
124
|
+
rendered,
|
|
125
|
+
unresolved_targets=unresolved_targets,
|
|
126
|
+
layout=layout,
|
|
127
|
+
selected_flow_count=len(selected_flows),
|
|
128
|
+
)
|
|
129
|
+
rendered_node_count = sum(len(item.nodes) for item in rendered)
|
|
130
|
+
node_count = sum(len(flow.nodes) for flow in selected_flows)
|
|
131
|
+
return {
|
|
132
|
+
"format": "svg",
|
|
133
|
+
"title": "Subgraph snapshot",
|
|
134
|
+
"requested_flow_ids": requested_flow_ids,
|
|
135
|
+
"unresolved_targets": unresolved_targets,
|
|
136
|
+
"flow_ids": [flow.id for flow in selected_flows],
|
|
137
|
+
"rendered_flow_ids": [flow.id for flow in rendered_flows],
|
|
138
|
+
"omitted_flow_count": max(0, len(selected_flows) - len(rendered_flows)),
|
|
139
|
+
"highlighted_node_ids": sorted(highlighted_node_ids),
|
|
140
|
+
"node_count": node_count,
|
|
141
|
+
"rendered_node_count": rendered_node_count,
|
|
142
|
+
"omitted_node_count": max(0, node_count - rendered_node_count),
|
|
143
|
+
"layout": _subgraph_layout_payload(selected_flows, rendered, layout),
|
|
144
|
+
"layout_quality": _subgraph_layout_quality(
|
|
145
|
+
selected_flows,
|
|
146
|
+
rendered,
|
|
147
|
+
layout,
|
|
148
|
+
unresolved_targets,
|
|
149
|
+
),
|
|
150
|
+
"svg": svg,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _subgraph_svg(
|
|
155
|
+
rendered: list[_RenderedSubgraphFlow],
|
|
156
|
+
*,
|
|
157
|
+
unresolved_targets: list[dict[str, str]],
|
|
158
|
+
layout: dict[str, Any],
|
|
159
|
+
selected_flow_count: int,
|
|
160
|
+
) -> str:
|
|
161
|
+
width = int(layout["width"])
|
|
162
|
+
height = int(layout["height"])
|
|
163
|
+
node_width = int(layout["node_width"])
|
|
164
|
+
node_height = int(layout["node_height"])
|
|
165
|
+
positions = layout["positions"]
|
|
166
|
+
parts = [
|
|
167
|
+
_svg_open(width, height, "CodeDebrief subgraph snapshot"),
|
|
168
|
+
_style(),
|
|
169
|
+
f'<rect class="background" x="0" y="0" width="{width}" height="{height}" />',
|
|
170
|
+
_text(28, 34, "Subgraph snapshot", "title"),
|
|
171
|
+
_text(
|
|
172
|
+
28,
|
|
173
|
+
58,
|
|
174
|
+
f"{selected_flow_count} flows - {layout['selected_node_count']} nodes - "
|
|
175
|
+
f"{len(unresolved_targets)} unresolved targets",
|
|
176
|
+
"subtitle",
|
|
177
|
+
),
|
|
178
|
+
]
|
|
179
|
+
if unresolved_targets:
|
|
180
|
+
unresolved_label = ", ".join(_unresolved_target_label(item) for item in unresolved_targets)
|
|
181
|
+
parts.append(
|
|
182
|
+
_text(28, 84, _compact(f"Unresolved targets: {unresolved_label}", 125), "meta")
|
|
183
|
+
)
|
|
184
|
+
if not rendered:
|
|
185
|
+
parts.append(_text(52, 132, "No valid flows matched the requested subgraph.", "meta"))
|
|
186
|
+
parts.append("</svg>")
|
|
187
|
+
return "\n".join(parts)
|
|
188
|
+
|
|
189
|
+
for section, item in zip(layout["sections"], rendered, strict=True):
|
|
190
|
+
flow = item.flow
|
|
191
|
+
nodes = item.nodes
|
|
192
|
+
highlighted = item.highlighted
|
|
193
|
+
section_y = int(section["y"])
|
|
194
|
+
parts.extend(
|
|
195
|
+
[
|
|
196
|
+
f'<rect class="subgraph-section" x="28" y="{section_y}" '
|
|
197
|
+
f'width="{width - 56}" height="{section["height"]}" rx="14" />',
|
|
198
|
+
_text(52, section_y + 28, flow.name, "column"),
|
|
199
|
+
_text(
|
|
200
|
+
52,
|
|
201
|
+
section_y + 50,
|
|
202
|
+
_compact(
|
|
203
|
+
f"{flow.entry_kind} - {flow.language} - "
|
|
204
|
+
f"{flow.location.path}:{flow.location.start_line}",
|
|
205
|
+
100,
|
|
206
|
+
),
|
|
207
|
+
"meta",
|
|
208
|
+
),
|
|
209
|
+
]
|
|
210
|
+
)
|
|
211
|
+
flow_positions = {node.id: positions[node.id] for node in nodes if node.id in positions}
|
|
212
|
+
for edge in flow.edges:
|
|
213
|
+
if edge.source not in flow_positions or edge.target not in flow_positions:
|
|
214
|
+
continue
|
|
215
|
+
parts.append(
|
|
216
|
+
_edge(edge.source, edge.target, flow_positions, node_width, node_height, edge.label)
|
|
217
|
+
)
|
|
218
|
+
for node in nodes:
|
|
219
|
+
parts.append(
|
|
220
|
+
_flow_node(
|
|
221
|
+
node,
|
|
222
|
+
positions[node.id],
|
|
223
|
+
node_width,
|
|
224
|
+
node_height,
|
|
225
|
+
highlighted=node.id in highlighted,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
omitted = max(0, len(flow.nodes) - len(nodes))
|
|
229
|
+
if omitted:
|
|
230
|
+
parts.append(
|
|
231
|
+
_text(
|
|
232
|
+
52,
|
|
233
|
+
section_y + int(section["height"]) - 18,
|
|
234
|
+
f"{omitted} additional nodes omitted from this compact flow.",
|
|
235
|
+
"meta",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
omitted_flows = max(0, selected_flow_count - len(rendered))
|
|
239
|
+
if omitted_flows:
|
|
240
|
+
parts.append(_text(28, height - 34, f"{omitted_flows} additional flows omitted.", "meta"))
|
|
241
|
+
parts.append("</svg>")
|
|
242
|
+
return "\n".join(parts)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _unresolved_target_label(item: Any) -> str:
|
|
246
|
+
if isinstance(item, dict):
|
|
247
|
+
target_type = item.get("type")
|
|
248
|
+
value = item.get("value")
|
|
249
|
+
if target_type is not None and value is not None:
|
|
250
|
+
return f"{target_type}:{value}"
|
|
251
|
+
return _compact(str(item), 80)
|
|
252
|
+
return str(item)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _subgraph_layout(
|
|
256
|
+
rendered: list[_RenderedSubgraphFlow],
|
|
257
|
+
unresolved_targets: list[dict[str, str]],
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
width = SNAPSHOT_WIDTH
|
|
260
|
+
header_height = 116 + (24 if unresolved_targets else 0)
|
|
261
|
+
section_gap = 28
|
|
262
|
+
section_header_height = 70
|
|
263
|
+
node_width = FLOW_NODE_WIDTH
|
|
264
|
+
node_height = 76
|
|
265
|
+
row_gap = 34
|
|
266
|
+
x = (width - node_width) // 2
|
|
267
|
+
y = header_height
|
|
268
|
+
sections: list[dict[str, Any]] = []
|
|
269
|
+
positions: dict[str, tuple[int, int]] = {}
|
|
270
|
+
rendered_edge_count = 0
|
|
271
|
+
total_edge_count = 0
|
|
272
|
+
selected_node_count = 0
|
|
273
|
+
rendered_node_count = 0
|
|
274
|
+
|
|
275
|
+
for item in rendered:
|
|
276
|
+
flow = item.flow
|
|
277
|
+
nodes = item.nodes
|
|
278
|
+
node_rows = max(1, len(nodes))
|
|
279
|
+
section_height = section_header_height + node_rows * (node_height + row_gap) + 24
|
|
280
|
+
node_start_y = y + section_header_height
|
|
281
|
+
for index, node in enumerate(nodes):
|
|
282
|
+
positions[node.id] = (x, node_start_y + index * (node_height + row_gap))
|
|
283
|
+
edge_count = sum(
|
|
284
|
+
edge.source in positions and edge.target in positions for edge in flow.edges
|
|
285
|
+
)
|
|
286
|
+
sections.append(
|
|
287
|
+
{
|
|
288
|
+
"flow_id": flow.id,
|
|
289
|
+
"x": 28,
|
|
290
|
+
"y": y,
|
|
291
|
+
"width": width - 56,
|
|
292
|
+
"height": section_height,
|
|
293
|
+
"node_start_y": node_start_y,
|
|
294
|
+
"rendered_node_count": len(nodes),
|
|
295
|
+
"omitted_node_count": max(0, len(flow.nodes) - len(nodes)),
|
|
296
|
+
"rendered_edge_count": edge_count,
|
|
297
|
+
"omitted_edge_count": max(0, len(flow.edges) - edge_count),
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
selected_node_count += len(flow.nodes)
|
|
301
|
+
rendered_node_count += len(nodes)
|
|
302
|
+
total_edge_count += len(flow.edges)
|
|
303
|
+
rendered_edge_count += edge_count
|
|
304
|
+
y += section_height + section_gap
|
|
305
|
+
|
|
306
|
+
if not rendered:
|
|
307
|
+
y = header_height + 86
|
|
308
|
+
height = y + 44
|
|
309
|
+
return {
|
|
310
|
+
"engine": "static-subgraph-snapshot-v1",
|
|
311
|
+
"direction": "top_to_bottom_stacked_flows",
|
|
312
|
+
"width": width,
|
|
313
|
+
"height": height,
|
|
314
|
+
"header_height": header_height,
|
|
315
|
+
"section_gap": section_gap,
|
|
316
|
+
"section_header_height": section_header_height,
|
|
317
|
+
"node_width": node_width,
|
|
318
|
+
"node_height": node_height,
|
|
319
|
+
"row_gap": row_gap,
|
|
320
|
+
"x": x,
|
|
321
|
+
"positions": positions,
|
|
322
|
+
"sections": sections,
|
|
323
|
+
"selected_node_count": selected_node_count,
|
|
324
|
+
"rendered_node_count": rendered_node_count,
|
|
325
|
+
"rendered_edge_count": rendered_edge_count,
|
|
326
|
+
"omitted_edge_count": max(0, total_edge_count - rendered_edge_count),
|
|
327
|
+
"unresolved_target_count": len(unresolved_targets),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _subgraph_layout_payload(
|
|
332
|
+
selected_flows: list[Flow],
|
|
333
|
+
rendered: list[_RenderedSubgraphFlow],
|
|
334
|
+
layout: dict[str, Any],
|
|
335
|
+
) -> dict[str, Any]:
|
|
336
|
+
positions: dict[str, tuple[int, int]] = layout["positions"]
|
|
337
|
+
rendered_nodes = [node for item in rendered for node in item.nodes]
|
|
338
|
+
return {
|
|
339
|
+
"engine": layout["engine"],
|
|
340
|
+
"direction": layout["direction"],
|
|
341
|
+
"orientation": "vertical",
|
|
342
|
+
"canvas": {"width": layout["width"], "height": layout["height"]},
|
|
343
|
+
"node": {
|
|
344
|
+
"width": layout["node_width"],
|
|
345
|
+
"height": layout["node_height"],
|
|
346
|
+
"row_gap": layout["row_gap"],
|
|
347
|
+
},
|
|
348
|
+
"sections": [
|
|
349
|
+
{
|
|
350
|
+
"flow_id": section["flow_id"],
|
|
351
|
+
"x": section["x"],
|
|
352
|
+
"y": section["y"],
|
|
353
|
+
"width": section["width"],
|
|
354
|
+
"height": section["height"],
|
|
355
|
+
"rendered_node_count": section["rendered_node_count"],
|
|
356
|
+
"omitted_node_count": section["omitted_node_count"],
|
|
357
|
+
"rendered_edge_count": section["rendered_edge_count"],
|
|
358
|
+
"omitted_edge_count": section["omitted_edge_count"],
|
|
359
|
+
}
|
|
360
|
+
for section in layout["sections"]
|
|
361
|
+
],
|
|
362
|
+
"rendered_edge_count": layout["rendered_edge_count"],
|
|
363
|
+
"omitted_edge_count": layout["omitted_edge_count"],
|
|
364
|
+
"compact": (
|
|
365
|
+
len(rendered) < len(selected_flows)
|
|
366
|
+
or any(section["omitted_node_count"] for section in layout["sections"])
|
|
367
|
+
),
|
|
368
|
+
"node_positions": [
|
|
369
|
+
{
|
|
370
|
+
"id": node.id,
|
|
371
|
+
"x": positions[node.id][0],
|
|
372
|
+
"y": positions[node.id][1],
|
|
373
|
+
"width": layout["node_width"],
|
|
374
|
+
"height": layout["node_height"],
|
|
375
|
+
}
|
|
376
|
+
for node in rendered_nodes
|
|
377
|
+
],
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _subgraph_layout_quality(
|
|
382
|
+
selected_flows: list[Flow],
|
|
383
|
+
rendered: list[_RenderedSubgraphFlow],
|
|
384
|
+
layout: dict[str, Any],
|
|
385
|
+
unresolved_targets: list[dict[str, str]],
|
|
386
|
+
) -> dict[str, Any]:
|
|
387
|
+
omitted_flows = max(0, len(selected_flows) - len(rendered))
|
|
388
|
+
omitted_nodes = sum(int(section["omitted_node_count"]) for section in layout["sections"])
|
|
389
|
+
omitted_edges = int(layout["omitted_edge_count"])
|
|
390
|
+
clarity = _layout_clarity(
|
|
391
|
+
_subgraph_layout_boxes(rendered, layout),
|
|
392
|
+
canvas_width=float(layout["width"]),
|
|
393
|
+
canvas_height=float(layout["height"]),
|
|
394
|
+
edges=_subgraph_layout_edges(rendered),
|
|
395
|
+
)
|
|
396
|
+
return _snapshot_layout_quality(
|
|
397
|
+
compact=omitted_flows > 0 or omitted_nodes > 0 or omitted_edges > 0,
|
|
398
|
+
counts={
|
|
399
|
+
"flow_count": len(selected_flows),
|
|
400
|
+
"rendered_flow_count": len(rendered),
|
|
401
|
+
"omitted_flow_count": omitted_flows,
|
|
402
|
+
"node_count": int(layout["selected_node_count"]),
|
|
403
|
+
"rendered_node_count": int(layout["rendered_node_count"]),
|
|
404
|
+
"omitted_node_count": omitted_nodes,
|
|
405
|
+
"rendered_edge_count": int(layout["rendered_edge_count"]),
|
|
406
|
+
"omitted_edge_count": omitted_edges,
|
|
407
|
+
"unresolved_target_count": len(unresolved_targets),
|
|
408
|
+
},
|
|
409
|
+
clarity=clarity,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _snapshot_layout_quality(
|
|
414
|
+
*,
|
|
415
|
+
compact: bool,
|
|
416
|
+
counts: dict[str, int],
|
|
417
|
+
clarity: dict[str, Any],
|
|
418
|
+
) -> dict[str, Any]:
|
|
419
|
+
return {
|
|
420
|
+
"status": "compact" if compact else "complete",
|
|
421
|
+
"complete": not compact,
|
|
422
|
+
"counts": counts,
|
|
423
|
+
"clarity": clarity,
|
|
424
|
+
"guardrail": (
|
|
425
|
+
"Layout quality describes the rendered snapshot only. Compact snapshots may omit "
|
|
426
|
+
"model nodes, edges, or flows; request a higher token_budget or the follow-up "
|
|
427
|
+
"snapshot tool when omitted counts are non-zero."
|
|
428
|
+
),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _subgraph_layout_boxes(
|
|
433
|
+
rendered: list[_RenderedSubgraphFlow],
|
|
434
|
+
layout: dict[str, Any],
|
|
435
|
+
) -> list[_LayoutBox]:
|
|
436
|
+
positions: dict[str, tuple[int, int]] = layout["positions"]
|
|
437
|
+
boxes: list[_LayoutBox] = []
|
|
438
|
+
for item in rendered:
|
|
439
|
+
for node in item.nodes:
|
|
440
|
+
if node.id not in positions:
|
|
441
|
+
continue
|
|
442
|
+
boxes.append(
|
|
443
|
+
_LayoutBox(
|
|
444
|
+
id=node.id,
|
|
445
|
+
x=float(positions[node.id][0]),
|
|
446
|
+
y=float(positions[node.id][1]),
|
|
447
|
+
width=float(layout["node_width"]),
|
|
448
|
+
height=float(layout["node_height"]),
|
|
449
|
+
kind="node",
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
return boxes
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _subgraph_layout_edges(rendered: list[_RenderedSubgraphFlow]) -> list[_LayoutEdge]:
|
|
456
|
+
edges: list[_LayoutEdge] = []
|
|
457
|
+
for item in rendered:
|
|
458
|
+
node_ids = {node.id for node in item.nodes}
|
|
459
|
+
edges.extend(
|
|
460
|
+
_LayoutEdge(edge.source, edge.target, edge.label)
|
|
461
|
+
for edge in item.flow.edges
|
|
462
|
+
if edge.source in node_ids and edge.target in node_ids
|
|
463
|
+
)
|
|
464
|
+
return edges
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _layout_clarity(
|
|
468
|
+
boxes: list[_LayoutBox],
|
|
469
|
+
*,
|
|
470
|
+
canvas_width: float,
|
|
471
|
+
canvas_height: float,
|
|
472
|
+
edges: list[_LayoutEdge] | None = None,
|
|
473
|
+
) -> dict[str, Any]:
|
|
474
|
+
box_overlaps = _box_overlaps(boxes)
|
|
475
|
+
edge_hits = _edge_obstacle_hits(edges or [], boxes)
|
|
476
|
+
overflow = _canvas_overflow_boxes(boxes, canvas_width, canvas_height)
|
|
477
|
+
finite = (
|
|
478
|
+
math.isfinite(canvas_width)
|
|
479
|
+
and math.isfinite(canvas_height)
|
|
480
|
+
and all(
|
|
481
|
+
math.isfinite(value) for box in boxes for value in (box.x, box.y, box.width, box.height)
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
clear = finite and not box_overlaps and not edge_hits and not overflow
|
|
485
|
+
return {
|
|
486
|
+
"status": "clear" if clear else "needs_review",
|
|
487
|
+
"clear": clear,
|
|
488
|
+
"counts": {
|
|
489
|
+
"box_count": len(boxes),
|
|
490
|
+
"box_overlap_count": len(box_overlaps),
|
|
491
|
+
"edge_obstacle_hit_count": len(edge_hits),
|
|
492
|
+
"canvas_overflow_count": len(overflow),
|
|
493
|
+
"non_finite_geometry_count": 0 if finite else 1,
|
|
494
|
+
},
|
|
495
|
+
"minimum_box_gap": _minimum_box_gap(boxes),
|
|
496
|
+
"samples": {
|
|
497
|
+
"box_overlaps": box_overlaps[:5],
|
|
498
|
+
"edge_obstacle_hits": edge_hits[:5],
|
|
499
|
+
"canvas_overflow": overflow[:5],
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _box_overlaps(boxes: list[_LayoutBox]) -> list[dict[str, str]]:
|
|
505
|
+
overlaps: list[dict[str, str]] = []
|
|
506
|
+
for left_index, left in enumerate(boxes):
|
|
507
|
+
for right in boxes[left_index + 1 :]:
|
|
508
|
+
if _boxes_overlap(left, right):
|
|
509
|
+
overlaps.append({"first": left.id, "second": right.id})
|
|
510
|
+
return overlaps
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _edge_obstacle_hits(
|
|
514
|
+
edges: list[_LayoutEdge],
|
|
515
|
+
boxes: list[_LayoutBox],
|
|
516
|
+
) -> list[dict[str, str]]:
|
|
517
|
+
boxes_by_id = {box.id: box for box in boxes}
|
|
518
|
+
hits: list[dict[str, str]] = []
|
|
519
|
+
for edge in edges:
|
|
520
|
+
source = boxes_by_id.get(edge.source)
|
|
521
|
+
target = boxes_by_id.get(edge.target)
|
|
522
|
+
if source is None or target is None:
|
|
523
|
+
continue
|
|
524
|
+
corridor = _edge_corridor(source, target)
|
|
525
|
+
for box in boxes:
|
|
526
|
+
if box.id in {edge.source, edge.target}:
|
|
527
|
+
continue
|
|
528
|
+
if _boxes_overlap(corridor, box):
|
|
529
|
+
hits.append(
|
|
530
|
+
{
|
|
531
|
+
"edge": f"{edge.source}->{edge.target}",
|
|
532
|
+
"obstacle": box.id,
|
|
533
|
+
}
|
|
534
|
+
)
|
|
535
|
+
return hits
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _edge_corridor(source: _LayoutBox, target: _LayoutBox) -> _LayoutBox:
|
|
539
|
+
x1 = source.x + source.width / 2
|
|
540
|
+
y1 = source.bottom
|
|
541
|
+
x2 = target.x + target.width / 2
|
|
542
|
+
y2 = target.y
|
|
543
|
+
left = min(x1, x2) - 6
|
|
544
|
+
top = min(y1, y2)
|
|
545
|
+
return _LayoutBox(
|
|
546
|
+
id=f"{source.id}->{target.id}",
|
|
547
|
+
x=left,
|
|
548
|
+
y=top,
|
|
549
|
+
width=abs(x2 - x1) + 12,
|
|
550
|
+
height=abs(y2 - y1),
|
|
551
|
+
kind="edge_corridor",
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _canvas_overflow_boxes(
|
|
556
|
+
boxes: list[_LayoutBox],
|
|
557
|
+
canvas_width: float,
|
|
558
|
+
canvas_height: float,
|
|
559
|
+
) -> list[dict[str, str]]:
|
|
560
|
+
result: list[dict[str, str]] = []
|
|
561
|
+
for box in boxes:
|
|
562
|
+
sides: list[str] = []
|
|
563
|
+
if box.x < 0:
|
|
564
|
+
sides.append("left")
|
|
565
|
+
if box.y < 0:
|
|
566
|
+
sides.append("top")
|
|
567
|
+
if box.right > canvas_width:
|
|
568
|
+
sides.append("right")
|
|
569
|
+
if box.bottom > canvas_height:
|
|
570
|
+
sides.append("bottom")
|
|
571
|
+
if sides:
|
|
572
|
+
result.append({"box": box.id, "sides": ",".join(sides)})
|
|
573
|
+
return result
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _minimum_box_gap(boxes: list[_LayoutBox]) -> float | None:
|
|
577
|
+
if len(boxes) < 2:
|
|
578
|
+
return None
|
|
579
|
+
gap: float | None = None
|
|
580
|
+
for left_index, left in enumerate(boxes):
|
|
581
|
+
for right in boxes[left_index + 1 :]:
|
|
582
|
+
distance = _box_gap(left, right)
|
|
583
|
+
gap = distance if gap is None else min(gap, distance)
|
|
584
|
+
return round(gap, 2) if gap is not None else None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _box_gap(left: _LayoutBox, right: _LayoutBox) -> float:
|
|
588
|
+
if _boxes_overlap(left, right):
|
|
589
|
+
return 0.0
|
|
590
|
+
dx = max(left.x - right.right, right.x - left.right, 0.0)
|
|
591
|
+
dy = max(left.y - right.bottom, right.y - left.bottom, 0.0)
|
|
592
|
+
return math.sqrt(dx * dx + dy * dy)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _boxes_overlap(left: _LayoutBox, right: _LayoutBox) -> bool:
|
|
596
|
+
return (
|
|
597
|
+
left.x < right.right
|
|
598
|
+
and left.right > right.x
|
|
599
|
+
and left.y < right.bottom
|
|
600
|
+
and left.bottom > right.y
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _select_flow_nodes(
|
|
605
|
+
flow: Flow,
|
|
606
|
+
highlight_node_ids: set[str],
|
|
607
|
+
max_nodes: int | None,
|
|
608
|
+
) -> list[FlowNode]:
|
|
609
|
+
limit = _effective_limit(max_nodes, MAX_FLOW_NODES)
|
|
610
|
+
selected = flow.nodes[:limit]
|
|
611
|
+
selected_ids = {node.id for node in selected}
|
|
612
|
+
for node in flow.nodes:
|
|
613
|
+
if node.id not in highlight_node_ids or node.id in selected_ids:
|
|
614
|
+
continue
|
|
615
|
+
if len(selected) < limit:
|
|
616
|
+
selected.append(node)
|
|
617
|
+
elif selected:
|
|
618
|
+
selected[-1] = node
|
|
619
|
+
selected_ids = {item.id for item in selected}
|
|
620
|
+
return [node for node in flow.nodes if node.id in selected_ids]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _effective_limit(value: int | None, default: int) -> int:
|
|
624
|
+
if value is None:
|
|
625
|
+
return default
|
|
626
|
+
return max(1, min(default, value))
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _flow_node(
|
|
630
|
+
node: FlowNode,
|
|
631
|
+
position: tuple[int, int],
|
|
632
|
+
width: int,
|
|
633
|
+
height: int,
|
|
634
|
+
*,
|
|
635
|
+
highlighted: bool,
|
|
636
|
+
) -> str:
|
|
637
|
+
x, y = position
|
|
638
|
+
classes = ["node", f"kind-{node.kind.value}"]
|
|
639
|
+
if highlighted:
|
|
640
|
+
classes.append("highlight")
|
|
641
|
+
shape = _node_shape(node.kind, x, y, width, height, " ".join(classes))
|
|
642
|
+
label_lines = _wrap(node.label, 34, 2)
|
|
643
|
+
meta = f"{node.location.path}:{node.location.start_line}"
|
|
644
|
+
text_lines = [
|
|
645
|
+
_text(x + width / 2, y + 28, line, "node-label", anchor="middle") for line in label_lines
|
|
646
|
+
]
|
|
647
|
+
text_lines.append(_text(x + width / 2, y + height - 18, meta, "node-meta", anchor="middle"))
|
|
648
|
+
return "\n".join([shape, *text_lines])
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _node_shape(kind: NodeKind, x: int, y: int, width: int, height: int, classes: str) -> str:
|
|
652
|
+
if kind is NodeKind.DECISION:
|
|
653
|
+
points = [
|
|
654
|
+
(x + width / 2, y),
|
|
655
|
+
(x + width, y + height / 2),
|
|
656
|
+
(x + width / 2, y + height),
|
|
657
|
+
(x, y + height / 2),
|
|
658
|
+
]
|
|
659
|
+
return '<polygon class="{}" points="{}" />'.format(
|
|
660
|
+
classes,
|
|
661
|
+
" ".join(f"{px},{py}" for px, py in points),
|
|
662
|
+
)
|
|
663
|
+
if kind in {NodeKind.ENTRY, NodeKind.TERMINAL}:
|
|
664
|
+
return (
|
|
665
|
+
f'<rect class="{classes}" x="{x}" y="{y}" width="{width}" height="{height}" '
|
|
666
|
+
f'rx="{height / 2}" />'
|
|
667
|
+
)
|
|
668
|
+
return f'<rect class="{classes}" x="{x}" y="{y}" width="{width}" height="{height}" rx="10" />'
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _edge(
|
|
672
|
+
source_id: str,
|
|
673
|
+
target_id: str,
|
|
674
|
+
positions: dict[str, tuple[int, int]],
|
|
675
|
+
node_width: int,
|
|
676
|
+
node_height: int,
|
|
677
|
+
label: str,
|
|
678
|
+
) -> str:
|
|
679
|
+
sx, sy = positions[source_id]
|
|
680
|
+
tx, ty = positions[target_id]
|
|
681
|
+
x1 = sx + node_width / 2
|
|
682
|
+
y1 = sy + node_height
|
|
683
|
+
x2 = tx + node_width / 2
|
|
684
|
+
y2 = ty
|
|
685
|
+
mid_y = (y1 + y2) / 2
|
|
686
|
+
path = f'<path class="edge" d="M {x1} {y1} C {x1} {mid_y}, {x2} {mid_y}, {x2} {y2}" />'
|
|
687
|
+
if not label:
|
|
688
|
+
return path
|
|
689
|
+
return "\n".join(
|
|
690
|
+
[
|
|
691
|
+
path,
|
|
692
|
+
_text((x1 + x2) / 2 + 10, mid_y - 4, _compact(label, 28), "edge-label"),
|
|
693
|
+
]
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _style() -> str:
|
|
698
|
+
return """
|
|
699
|
+
<style>
|
|
700
|
+
.background { fill: #f8fafc; }
|
|
701
|
+
.title { fill: #0f172a; font: 700 20px system-ui, sans-serif; }
|
|
702
|
+
.subtitle { fill: #334155; font: 13px system-ui, sans-serif; }
|
|
703
|
+
.meta { fill: #64748b; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
704
|
+
.column { fill: #334155; font: 700 13px system-ui, sans-serif; }
|
|
705
|
+
.subgraph-section { fill: #ffffff; stroke: #cbd5e1; stroke-width: 1.2; }
|
|
706
|
+
.node { fill: #ffffff; stroke: #94a3b8; stroke-width: 1.4; }
|
|
707
|
+
.kind-decision { fill: #fff7ed; stroke: #f97316; }
|
|
708
|
+
.kind-call { fill: #ecfeff; stroke: #0891b2; }
|
|
709
|
+
.kind-error { fill: #fef2f2; stroke: #ef4444; }
|
|
710
|
+
.highlight { stroke: #2563eb; stroke-width: 3; filter: drop-shadow(0 2px 5px #bfdbfe); }
|
|
711
|
+
.edge { fill: none; stroke: #64748b; stroke-width: 1.2; marker-end: url(#arrow); }
|
|
712
|
+
.edge-label { fill: #475569; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
713
|
+
.node-label { fill: #0f172a; font: 700 12px system-ui, sans-serif; }
|
|
714
|
+
.node-meta { fill: #64748b; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
715
|
+
</style>
|
|
716
|
+
<defs>
|
|
717
|
+
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3.5" orient="auto">
|
|
718
|
+
<polygon points="0 0, 8 3.5, 0 7" fill="#64748b" />
|
|
719
|
+
</marker>
|
|
720
|
+
</defs>
|
|
721
|
+
""".strip()
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _svg_open(width: int, height: int, label: str) -> str:
|
|
725
|
+
return (
|
|
726
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
|
727
|
+
f'viewBox="0 0 {width} {height}" role="img" aria-label="{escape(label)}">'
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _text(
|
|
732
|
+
x: float,
|
|
733
|
+
y: float,
|
|
734
|
+
value: str,
|
|
735
|
+
class_name: str,
|
|
736
|
+
*,
|
|
737
|
+
anchor: str = "start",
|
|
738
|
+
) -> str:
|
|
739
|
+
return (
|
|
740
|
+
f'<text class="{class_name}" x="{x}" y="{y}" text-anchor="{anchor}">{escape(value)}</text>'
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _wrap(value: str, width: int, max_lines: int) -> list[str]:
|
|
745
|
+
words = value.split()
|
|
746
|
+
if not words:
|
|
747
|
+
return [""]
|
|
748
|
+
lines: list[str] = []
|
|
749
|
+
current = ""
|
|
750
|
+
for word in words:
|
|
751
|
+
candidate = f"{current} {word}".strip()
|
|
752
|
+
if len(candidate) <= width:
|
|
753
|
+
current = candidate
|
|
754
|
+
continue
|
|
755
|
+
if current:
|
|
756
|
+
lines.append(current)
|
|
757
|
+
current = word
|
|
758
|
+
if len(lines) == max_lines:
|
|
759
|
+
break
|
|
760
|
+
if current and len(lines) < max_lines:
|
|
761
|
+
lines.append(current)
|
|
762
|
+
if len(lines) == max_lines and len(" ".join(words)) > len(" ".join(lines)):
|
|
763
|
+
lines[-1] = _compact(lines[-1], max(4, width - 1))
|
|
764
|
+
return lines
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _compact(value: str, width: int) -> str:
|
|
768
|
+
collapsed = " ".join(value.split())
|
|
769
|
+
return collapsed if len(collapsed) <= width else collapsed[: max(0, width - 3)] + "..."
|