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.
Files changed (48) hide show
  1. codedebrief/__init__.py +12 -0
  2. codedebrief/analysis/__init__.py +16 -0
  3. codedebrief/analysis/common.py +527 -0
  4. codedebrief/analysis/discovery.py +100 -0
  5. codedebrief/analysis/languages/__init__.py +6 -0
  6. codedebrief/analysis/languages/_common.py +68 -0
  7. codedebrief/analysis/languages/c.py +96 -0
  8. codedebrief/analysis/languages/cpp.py +146 -0
  9. codedebrief/analysis/languages/csharp.py +137 -0
  10. codedebrief/analysis/languages/go.py +157 -0
  11. codedebrief/analysis/languages/java.py +158 -0
  12. codedebrief/analysis/languages/php.py +83 -0
  13. codedebrief/analysis/languages/ruby.py +75 -0
  14. codedebrief/analysis/languages/rust.py +96 -0
  15. codedebrief/analysis/project.py +373 -0
  16. codedebrief/analysis/python.py +939 -0
  17. codedebrief/analysis/registry.py +320 -0
  18. codedebrief/analysis/treesitter.py +884 -0
  19. codedebrief/analysis/typescript.py +1019 -0
  20. codedebrief/artifacts.py +49 -0
  21. codedebrief/cli.py +585 -0
  22. codedebrief/config.py +226 -0
  23. codedebrief/doctor.py +175 -0
  24. codedebrief/install.py +441 -0
  25. codedebrief/mcp_server.py +2720 -0
  26. codedebrief/model.py +189 -0
  27. codedebrief/py.typed +1 -0
  28. codedebrief/quality.py +392 -0
  29. codedebrief/query.py +641 -0
  30. codedebrief/render/__init__.py +6 -0
  31. codedebrief/render/assets/generated/codedebrief-viewer-runtime.iife.js +10 -0
  32. codedebrief/render/assets/panels.js +462 -0
  33. codedebrief/render/assets/shell.js +1649 -0
  34. codedebrief/render/assets/styles.css +1715 -0
  35. codedebrief/render/assets/tree.js +616 -0
  36. codedebrief/render/html.py +191 -0
  37. codedebrief/render/markdown.py +153 -0
  38. codedebrief/render/payload.py +326 -0
  39. codedebrief/render/snapshot.py +769 -0
  40. codedebrief/schema/codedebrief.schema.json +449 -0
  41. codedebrief/util.py +65 -0
  42. codedebrief/validation.py +214 -0
  43. codedebrief-0.11.0.dist-info/METADATA +426 -0
  44. codedebrief-0.11.0.dist-info/RECORD +48 -0
  45. codedebrief-0.11.0.dist-info/WHEEL +4 -0
  46. codedebrief-0.11.0.dist-info/entry_points.txt +2 -0
  47. codedebrief-0.11.0.dist-info/licenses/LICENSE +176 -0
  48. 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)] + "..."