archunitpython 1.1.2__py3-none-any.whl → 1.2.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.
@@ -0,0 +1,795 @@
1
+ """Dependency graph querying and report generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import fnmatch
7
+ import json
8
+ import os
9
+ import re
10
+ from dataclasses import asdict, dataclass, replace
11
+ from html import escape as escape_html
12
+ from io import StringIO
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from archunitpython.common.extraction.extract_graph import extract_graph
17
+ from archunitpython.common.extraction.graph import Graph
18
+ from archunitpython.common.fluentapi.checkable import CheckOptions
19
+ from archunitpython.common.types import Pattern
20
+
21
+ GraphReportFormat = Literal["dot", "mermaid", "d2", "csv", "json", "html"]
22
+
23
+ DEFAULT_TITLE = "ArchUnitPython Dependency Graph"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class FolderDepthCollapse:
28
+ """Collapse file nodes to their folder path up to a fixed depth."""
29
+
30
+ depth: int
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class PatternCollapse:
35
+ """Collapse file nodes by applying a regular expression replacement."""
36
+
37
+ pattern: re.Pattern[str]
38
+ replacement: str
39
+
40
+
41
+ GraphCollapseStrategy = FolderDepthCollapse | PatternCollapse
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class GraphQueryOptions:
46
+ """Options for selecting, collapsing, and rendering a dependency graph."""
47
+
48
+ include_external_dependencies: bool = False
49
+ include_self_dependencies: bool = False
50
+ focus: Pattern | None = None
51
+ focus_depth: int = 1
52
+ reachable_from: Pattern | None = None
53
+ dependents_of: Pattern | None = None
54
+ collapse: GraphCollapseStrategy | None = None
55
+ title: str | None = None
56
+ project_path: str | None = None
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class GraphReportNode:
61
+ """A rendered graph node."""
62
+
63
+ id: str
64
+ label: str
65
+
66
+
67
+ @dataclass
68
+ class GraphReportEdge:
69
+ """A rendered graph edge, possibly aggregating multiple raw edges."""
70
+
71
+ source: str
72
+ target: str
73
+ count: int
74
+ external: bool
75
+ import_kinds: tuple[str, ...]
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class GraphReportSummary:
80
+ """Basic counts for a rendered graph snapshot."""
81
+
82
+ node_count: int
83
+ edge_count: int
84
+ raw_edge_count: int
85
+ external_edge_count: int
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class GraphReportSnapshot:
90
+ """A dependency graph after filtering, collapsing, and aggregation."""
91
+
92
+ title: str
93
+ nodes: tuple[GraphReportNode, ...]
94
+ edges: tuple[GraphReportEdge, ...]
95
+ summary: GraphReportSummary
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class _DisplayEdge:
100
+ source: str
101
+ target: str
102
+ external: bool
103
+ import_kinds: tuple[object, ...]
104
+
105
+
106
+ def project_graph(project_path: str | None = None) -> ProjectGraphBuilder:
107
+ """Create a builder for dependency graph reports."""
108
+ return ProjectGraphBuilder(project_path)
109
+
110
+
111
+ dependency_graph = project_graph
112
+
113
+
114
+ class ProjectGraphBuilder:
115
+ """Fluent builder for dependency graph reports."""
116
+
117
+ def __init__(
118
+ self,
119
+ project_path: str | None = None,
120
+ options: GraphQueryOptions | None = None,
121
+ check_options: CheckOptions | None = None,
122
+ ) -> None:
123
+ self._project_path = project_path
124
+ self._options = options or GraphQueryOptions()
125
+ self._check_options = check_options
126
+
127
+ def include_external_dependencies(self) -> "ProjectGraphBuilder":
128
+ """Include imports to external modules in graph reports."""
129
+ return self._with_options(
130
+ replace(self._options, include_external_dependencies=True)
131
+ )
132
+
133
+ def include_self_dependencies(self) -> "ProjectGraphBuilder":
134
+ """Include self edges used to keep files visible as graph nodes."""
135
+ return self._with_options(replace(self._options, include_self_dependencies=True))
136
+
137
+ def focus_on(self, pattern: Pattern, depth: int = 1) -> "ProjectGraphBuilder":
138
+ """Keep matching nodes and their neighbors up to the given depth."""
139
+ return self._with_options(
140
+ replace(self._options, focus=pattern, focus_depth=depth)
141
+ )
142
+
143
+ def reachable_from(self, pattern: Pattern) -> "ProjectGraphBuilder":
144
+ """Keep matching nodes and all dependencies reachable from them."""
145
+ return self._with_options(replace(self._options, reachable_from=pattern))
146
+
147
+ def dependents_of(self, pattern: Pattern) -> "ProjectGraphBuilder":
148
+ """Keep matching nodes and all files that transitively depend on them."""
149
+ return self._with_options(replace(self._options, dependents_of=pattern))
150
+
151
+ def collapse_to_folder_depth(self, depth: int) -> "ProjectGraphBuilder":
152
+ """Aggregate file nodes to folders at the requested depth."""
153
+ return self._with_options(
154
+ replace(self._options, collapse=FolderDepthCollapse(depth=depth))
155
+ )
156
+
157
+ def collapse_by_pattern(
158
+ self,
159
+ pattern: str | re.Pattern[str],
160
+ replacement: str = r"\1",
161
+ ) -> "ProjectGraphBuilder":
162
+ """Aggregate nodes by applying a regular expression replacement."""
163
+ compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
164
+ return self._with_options(
165
+ replace(
166
+ self._options,
167
+ collapse=PatternCollapse(pattern=compiled, replacement=replacement),
168
+ )
169
+ )
170
+
171
+ def titled(self, title: str) -> "ProjectGraphBuilder":
172
+ """Set the graph report title."""
173
+ return self._with_options(replace(self._options, title=title))
174
+
175
+ def with_check_options(
176
+ self, check_options: CheckOptions
177
+ ) -> "ProjectGraphBuilder":
178
+ """Use the given check options when extracting the graph."""
179
+ return ProjectGraphBuilder(self._project_path, self._options, check_options)
180
+
181
+ def snapshot(self) -> GraphReportSnapshot:
182
+ """Return the filtered, collapsed graph snapshot."""
183
+ return GraphReporter.create_snapshot(self._get_graph(), self._report_options())
184
+
185
+ def to_dot(self) -> str:
186
+ """Render the graph in DOT format."""
187
+ return GraphReporter.to_dot(self._get_graph(), self._report_options())
188
+
189
+ def to_mermaid(self) -> str:
190
+ """Render the graph in Mermaid flowchart format."""
191
+ return GraphReporter.to_mermaid(self._get_graph(), self._report_options())
192
+
193
+ def to_d2(self) -> str:
194
+ """Render the graph in D2 format."""
195
+ return GraphReporter.to_d2(self._get_graph(), self._report_options())
196
+
197
+ def to_csv(self) -> str:
198
+ """Render the graph as CSV."""
199
+ return GraphReporter.to_csv(self._get_graph(), self._report_options())
200
+
201
+ def to_json(self) -> str:
202
+ """Render the graph snapshot as JSON."""
203
+ return GraphReporter.to_json(self._get_graph(), self._report_options())
204
+
205
+ def to_html(self) -> str:
206
+ """Render the graph as a self-contained HTML report."""
207
+ return GraphReporter.to_html(self._get_graph(), self._report_options())
208
+
209
+ def export_as_dot(self, output_path: str) -> None:
210
+ """Write a DOT report to disk."""
211
+ GraphReporter.export_as_dot(
212
+ self._get_graph(), output_path, self._report_options()
213
+ )
214
+
215
+ def export_as_mermaid(self, output_path: str) -> None:
216
+ """Write a Mermaid report to disk."""
217
+ GraphReporter.export_as_mermaid(
218
+ self._get_graph(), output_path, self._report_options()
219
+ )
220
+
221
+ def export_as_d2(self, output_path: str) -> None:
222
+ """Write a D2 report to disk."""
223
+ GraphReporter.export_as_d2(self._get_graph(), output_path, self._report_options())
224
+
225
+ def export_as_csv(self, output_path: str) -> None:
226
+ """Write a CSV report to disk."""
227
+ GraphReporter.export_as_csv(
228
+ self._get_graph(), output_path, self._report_options()
229
+ )
230
+
231
+ def export_as_json(self, output_path: str) -> None:
232
+ """Write a JSON report to disk."""
233
+ GraphReporter.export_as_json(
234
+ self._get_graph(), output_path, self._report_options()
235
+ )
236
+
237
+ def export_as_html(self, output_path: str) -> None:
238
+ """Write an HTML report to disk."""
239
+ GraphReporter.export_as_html(
240
+ self._get_graph(), output_path, self._report_options()
241
+ )
242
+
243
+ def _with_options(self, options: GraphQueryOptions) -> "ProjectGraphBuilder":
244
+ return ProjectGraphBuilder(
245
+ self._project_path,
246
+ options,
247
+ self._check_options,
248
+ )
249
+
250
+ def _report_options(self) -> GraphQueryOptions:
251
+ return replace(self._options, project_path=self._project_path)
252
+
253
+ def _get_graph(self) -> Graph:
254
+ return extract_graph(self._project_path, options=self._check_options)
255
+
256
+
257
+ class GraphReporter:
258
+ """Create dependency graph snapshots and render them in report formats."""
259
+
260
+ @staticmethod
261
+ def create_snapshot(
262
+ graph: Graph,
263
+ options: GraphQueryOptions | None = None,
264
+ ) -> GraphReportSnapshot:
265
+ """Create a queryable report snapshot from a raw dependency graph."""
266
+ opts = options or GraphQueryOptions()
267
+ display_graph = _to_display_edges(graph, opts.project_path)
268
+ filtered_by_external = (
269
+ display_graph
270
+ if opts.include_external_dependencies
271
+ else [edge for edge in display_graph if not edge.external]
272
+ )
273
+ selected_nodes = _select_nodes(filtered_by_external, opts)
274
+ display_edges = [
275
+ edge
276
+ for edge in filtered_by_external
277
+ if (opts.include_self_dependencies or edge.source != edge.target)
278
+ and edge.source in selected_nodes
279
+ and edge.target in selected_nodes
280
+ ]
281
+
282
+ collapsed_nodes = {
283
+ _collapse_node(node, opts.collapse) for node in selected_nodes
284
+ }
285
+ edge_map: dict[tuple[str, str], GraphReportEdge] = {}
286
+
287
+ for edge in display_edges:
288
+ source = _collapse_node(edge.source, opts.collapse)
289
+ target = _collapse_node(edge.target, opts.collapse)
290
+
291
+ if not opts.include_self_dependencies and source == target:
292
+ continue
293
+
294
+ key = (source, target)
295
+ existing = edge_map.get(key)
296
+ if existing is not None:
297
+ existing.count += 1
298
+ existing.external = existing.external or edge.external
299
+ existing.import_kinds = _unique_sorted_import_kinds(
300
+ (*existing.import_kinds, *edge.import_kinds)
301
+ )
302
+ continue
303
+
304
+ edge_map[key] = GraphReportEdge(
305
+ source=source,
306
+ target=target,
307
+ count=1,
308
+ external=edge.external,
309
+ import_kinds=_unique_sorted_import_kinds(edge.import_kinds),
310
+ )
311
+
312
+ edges = tuple(sorted(edge_map.values(), key=lambda e: (e.source, e.target)))
313
+ edge_node_labels = [label for edge in edges for label in (edge.source, edge.target)]
314
+ labels = _unique_sorted((*collapsed_nodes, *edge_node_labels))
315
+ nodes = tuple(
316
+ GraphReportNode(id=f"n{index}", label=label)
317
+ for index, label in enumerate(labels)
318
+ )
319
+
320
+ return GraphReportSnapshot(
321
+ title=opts.title or DEFAULT_TITLE,
322
+ nodes=nodes,
323
+ edges=edges,
324
+ summary=GraphReportSummary(
325
+ node_count=len(nodes),
326
+ edge_count=len(edges),
327
+ raw_edge_count=len(display_edges),
328
+ external_edge_count=sum(1 for edge in display_edges if edge.external),
329
+ ),
330
+ )
331
+
332
+ @staticmethod
333
+ def to_dot(graph: Graph, options: GraphQueryOptions | None = None) -> str:
334
+ """Render a dependency graph in DOT format."""
335
+ snapshot = GraphReporter.create_snapshot(graph, options)
336
+ lines = ["digraph dependencies {", "\trankdir=LR;"]
337
+
338
+ for node in snapshot.nodes:
339
+ lines.append(f"\t{_quote_dot(node.label)};")
340
+
341
+ for edge in snapshot.edges:
342
+ label = f' [label="{edge.count}"]' if edge.count > 1 else ""
343
+ lines.append(
344
+ f"\t{_quote_dot(edge.source)} -> {_quote_dot(edge.target)}{label};"
345
+ )
346
+
347
+ lines.append("}")
348
+ return "\n".join(lines)
349
+
350
+ @staticmethod
351
+ def to_mermaid(graph: Graph, options: GraphQueryOptions | None = None) -> str:
352
+ """Render a dependency graph in Mermaid flowchart format."""
353
+ snapshot = GraphReporter.create_snapshot(graph, options)
354
+ node_ids = {node.label: node.id for node in snapshot.nodes}
355
+ lines = ["flowchart LR"]
356
+
357
+ for node in snapshot.nodes:
358
+ lines.append(f' {node.id}["{_escape_mermaid_label(node.label)}"]')
359
+
360
+ for edge in snapshot.edges:
361
+ source = node_ids.get(edge.source)
362
+ target = node_ids.get(edge.target)
363
+ if source is None or target is None:
364
+ continue
365
+ label = f"|{edge.count}|" if edge.count > 1 else ""
366
+ lines.append(f" {source} -->{label} {target}")
367
+
368
+ return "\n".join(lines)
369
+
370
+ @staticmethod
371
+ def to_d2(graph: Graph, options: GraphQueryOptions | None = None) -> str:
372
+ """Render a dependency graph in D2 format."""
373
+ snapshot = GraphReporter.create_snapshot(graph, options)
374
+ lines = [f"# {snapshot.title}"]
375
+
376
+ for node in snapshot.nodes:
377
+ lines.append(_quote_d2(node.label))
378
+
379
+ for edge in snapshot.edges:
380
+ label = f": {_quote_d2(str(edge.count))}" if edge.count > 1 else ""
381
+ lines.append(f"{_quote_d2(edge.source)} -> {_quote_d2(edge.target)}{label}")
382
+
383
+ return "\n".join(lines)
384
+
385
+ @staticmethod
386
+ def to_csv(graph: Graph, options: GraphQueryOptions | None = None) -> str:
387
+ """Render a dependency graph as CSV."""
388
+ snapshot = GraphReporter.create_snapshot(graph, options)
389
+ output = StringIO()
390
+ writer = csv.writer(output, lineterminator="\n")
391
+ writer.writerow(["source", "target", "count", "external", "import_kinds"])
392
+
393
+ for edge in snapshot.edges:
394
+ writer.writerow(
395
+ [
396
+ edge.source,
397
+ edge.target,
398
+ str(edge.count),
399
+ str(edge.external).lower(),
400
+ "|".join(edge.import_kinds),
401
+ ]
402
+ )
403
+
404
+ return output.getvalue().rstrip("\n")
405
+
406
+ @staticmethod
407
+ def to_json(graph: Graph, options: GraphQueryOptions | None = None) -> str:
408
+ """Render a dependency graph snapshot as formatted JSON."""
409
+ return json.dumps(asdict(GraphReporter.create_snapshot(graph, options)), indent=2)
410
+
411
+ @staticmethod
412
+ def to_html(graph: Graph, options: GraphQueryOptions | None = None) -> str:
413
+ """Render a dependency graph as an HTML report."""
414
+ snapshot = GraphReporter.create_snapshot(graph, options)
415
+ mermaid = GraphReporter.to_mermaid(graph, options)
416
+ dot = GraphReporter.to_dot(graph, options)
417
+ d2 = GraphReporter.to_d2(graph, options)
418
+
419
+ return f"""<!DOCTYPE html>
420
+ <html lang="en">
421
+ <head>
422
+ <meta charset="UTF-8">
423
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
424
+ <title>{escape_html(snapshot.title)}</title>
425
+ <style>
426
+ body {{
427
+ font-family: Arial, sans-serif;
428
+ margin: 0;
429
+ color: #1f2933;
430
+ background: #f8fafc;
431
+ }}
432
+ header {{ background: #102a43; color: white; padding: 24px 32px; }}
433
+ main {{ padding: 24px 32px; }}
434
+ h1 {{ margin: 0 0 8px; font-size: 28px; }}
435
+ h2 {{ margin-top: 32px; font-size: 20px; }}
436
+ .summary {{ display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }}
437
+ .metric {{
438
+ background: white;
439
+ border: 1px solid #d9e2ec;
440
+ border-radius: 6px;
441
+ padding: 12px 16px;
442
+ min-width: 140px;
443
+ }}
444
+ .metric strong {{ display: block; font-size: 24px; color: #102a43; }}
445
+ table {{
446
+ border-collapse: collapse;
447
+ width: 100%;
448
+ background: white;
449
+ border: 1px solid #d9e2ec;
450
+ }}
451
+ th, td {{
452
+ text-align: left;
453
+ border-bottom: 1px solid #d9e2ec;
454
+ padding: 8px 10px;
455
+ vertical-align: top;
456
+ }}
457
+ th {{ background: #eef2f7; font-weight: 700; }}
458
+ pre {{
459
+ background: #0b1220;
460
+ color: #e6edf3;
461
+ padding: 16px;
462
+ border-radius: 6px;
463
+ overflow: auto;
464
+ }}
465
+ pre.mermaid {{ background: white; color: #1f2933; border: 1px solid #d9e2ec; }}
466
+ details {{ margin: 16px 0; }}
467
+ summary {{ cursor: pointer; font-weight: 700; }}
468
+ .empty {{
469
+ color: #627d98;
470
+ background: white;
471
+ border: 1px solid #d9e2ec;
472
+ padding: 16px;
473
+ border-radius: 6px;
474
+ }}
475
+ </style>
476
+ </head>
477
+ <body>
478
+ <header>
479
+ <h1>{escape_html(snapshot.title)}</h1>
480
+ <div>Generated by ArchUnitPython graph reporting</div>
481
+ </header>
482
+ <main>
483
+ <section class="summary">
484
+ <div class="metric"><strong>{snapshot.summary.node_count}</strong>Nodes</div>
485
+ <div class="metric"><strong>{snapshot.summary.edge_count}</strong>Aggregated Edges</div>
486
+ <div class="metric"><strong>{snapshot.summary.raw_edge_count}</strong>Raw Edges</div>
487
+ <div class="metric">
488
+ <strong>{snapshot.summary.external_edge_count}</strong>External Edges
489
+ </div>
490
+ </section>
491
+
492
+ <h2>Dependencies</h2>
493
+ {_render_edge_table(snapshot)}
494
+
495
+ <h2>Mermaid Preview</h2>
496
+ <pre class="mermaid">{escape_html(mermaid)}</pre>
497
+
498
+ <details>
499
+ <summary>Mermaid Source</summary>
500
+ <pre>{escape_html(mermaid)}</pre>
501
+ </details>
502
+
503
+ <details>
504
+ <summary>DOT</summary>
505
+ <pre>{escape_html(dot)}</pre>
506
+ </details>
507
+
508
+ <details>
509
+ <summary>D2</summary>
510
+ <pre>{escape_html(d2)}</pre>
511
+ </details>
512
+ </main>
513
+ <script type="module">
514
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
515
+ mermaid.initialize({{ startOnLoad: true, securityLevel: 'loose' }});
516
+ </script>
517
+ </body>
518
+ </html>"""
519
+
520
+ @staticmethod
521
+ def export_as_dot(
522
+ graph: Graph,
523
+ output_path: str,
524
+ options: GraphQueryOptions | None = None,
525
+ ) -> None:
526
+ """Write a DOT report to disk."""
527
+ _write_report(output_path, GraphReporter.to_dot(graph, options))
528
+
529
+ @staticmethod
530
+ def export_as_mermaid(
531
+ graph: Graph,
532
+ output_path: str,
533
+ options: GraphQueryOptions | None = None,
534
+ ) -> None:
535
+ """Write a Mermaid report to disk."""
536
+ _write_report(output_path, GraphReporter.to_mermaid(graph, options))
537
+
538
+ @staticmethod
539
+ def export_as_d2(
540
+ graph: Graph,
541
+ output_path: str,
542
+ options: GraphQueryOptions | None = None,
543
+ ) -> None:
544
+ """Write a D2 report to disk."""
545
+ _write_report(output_path, GraphReporter.to_d2(graph, options))
546
+
547
+ @staticmethod
548
+ def export_as_csv(
549
+ graph: Graph,
550
+ output_path: str,
551
+ options: GraphQueryOptions | None = None,
552
+ ) -> None:
553
+ """Write a CSV report to disk."""
554
+ _write_report(output_path, GraphReporter.to_csv(graph, options))
555
+
556
+ @staticmethod
557
+ def export_as_json(
558
+ graph: Graph,
559
+ output_path: str,
560
+ options: GraphQueryOptions | None = None,
561
+ ) -> None:
562
+ """Write a JSON report to disk."""
563
+ _write_report(output_path, GraphReporter.to_json(graph, options))
564
+
565
+ @staticmethod
566
+ def export_as_html(
567
+ graph: Graph,
568
+ output_path: str,
569
+ options: GraphQueryOptions | None = None,
570
+ ) -> None:
571
+ """Write an HTML report to disk."""
572
+ _write_report(output_path, GraphReporter.to_html(graph, options))
573
+
574
+
575
+ def _to_display_edges(graph: Graph, project_path: str | None) -> list[_DisplayEdge]:
576
+ return [
577
+ _DisplayEdge(
578
+ source=_display_label(edge.source, project_path),
579
+ target=(
580
+ _normalize(edge.target)
581
+ if edge.external
582
+ else _display_label(edge.target, project_path)
583
+ ),
584
+ external=edge.external,
585
+ import_kinds=edge.import_kinds,
586
+ )
587
+ for edge in graph
588
+ ]
589
+
590
+
591
+ def _display_label(node: str, project_path: str | None) -> str:
592
+ normalized = _normalize(node)
593
+ if project_path is None or not os.path.isabs(node):
594
+ return normalized
595
+
596
+ try:
597
+ absolute_node = os.path.abspath(node)
598
+ absolute_root = os.path.abspath(project_path)
599
+ common_path = os.path.commonpath([absolute_node, absolute_root])
600
+ except ValueError:
601
+ return normalized
602
+
603
+ if os.path.normcase(common_path) != os.path.normcase(absolute_root):
604
+ return normalized
605
+
606
+ relative = os.path.relpath(absolute_node, absolute_root)
607
+ return "." if relative == "." else _normalize(relative)
608
+
609
+
610
+ def _select_nodes(graph: list[_DisplayEdge], options: GraphQueryOptions) -> set[str]:
611
+ all_nodes = {node for edge in graph for node in (edge.source, edge.target)}
612
+ has_query = (
613
+ options.focus is not None
614
+ or options.reachable_from is not None
615
+ or options.dependents_of is not None
616
+ )
617
+
618
+ if not has_query:
619
+ return all_nodes
620
+
621
+ selected: set[str] = set()
622
+
623
+ if options.focus is not None:
624
+ selected.update(_expand_focus(graph, options.focus, options.focus_depth))
625
+
626
+ if options.reachable_from is not None:
627
+ selected.update(_walk_graph(graph, options.reachable_from, "outgoing"))
628
+
629
+ if options.dependents_of is not None:
630
+ selected.update(_walk_graph(graph, options.dependents_of, "incoming"))
631
+
632
+ return selected
633
+
634
+
635
+ def _expand_focus(
636
+ graph: list[_DisplayEdge],
637
+ pattern: Pattern,
638
+ depth: int,
639
+ ) -> set[str]:
640
+ selected: set[str] = set()
641
+ queue: list[tuple[str, int]] = []
642
+
643
+ for node in {node for edge in graph for node in (edge.source, edge.target)}:
644
+ if _matches(pattern, node):
645
+ selected.add(node)
646
+ queue.append((node, 0))
647
+
648
+ while queue:
649
+ current, current_depth = queue.pop(0)
650
+ if current_depth >= depth:
651
+ continue
652
+
653
+ for neighbor in _neighbors_of(graph, current):
654
+ if neighbor not in selected:
655
+ selected.add(neighbor)
656
+ queue.append((neighbor, current_depth + 1))
657
+
658
+ return selected
659
+
660
+
661
+ def _walk_graph(
662
+ graph: list[_DisplayEdge],
663
+ pattern: Pattern,
664
+ direction: Literal["incoming", "outgoing"],
665
+ ) -> set[str]:
666
+ selected: set[str] = set()
667
+ queue: list[str] = []
668
+
669
+ for node in {node for edge in graph for node in (edge.source, edge.target)}:
670
+ if _matches(pattern, node):
671
+ selected.add(node)
672
+ queue.append(node)
673
+
674
+ while queue:
675
+ current = queue.pop(0)
676
+ next_nodes: list[str] = []
677
+ for edge in graph:
678
+ if direction == "outgoing" and edge.source == current:
679
+ next_nodes.append(edge.target)
680
+ elif direction == "incoming" and edge.target == current:
681
+ next_nodes.append(edge.source)
682
+
683
+ for next_node in next_nodes:
684
+ if next_node not in selected:
685
+ selected.add(next_node)
686
+ queue.append(next_node)
687
+
688
+ return selected
689
+
690
+
691
+ def _neighbors_of(graph: list[_DisplayEdge], node: str) -> list[str]:
692
+ neighbors: list[str] = []
693
+ for edge in graph:
694
+ if edge.source == node and edge.target != node:
695
+ neighbors.append(edge.target)
696
+ elif edge.target == node and edge.source != node:
697
+ neighbors.append(edge.source)
698
+ return neighbors
699
+
700
+
701
+ def _collapse_node(node: str, strategy: GraphCollapseStrategy | None) -> str:
702
+ if strategy is None:
703
+ return node
704
+
705
+ normalized = _normalize(node)
706
+
707
+ if isinstance(strategy, PatternCollapse):
708
+ return strategy.pattern.sub(strategy.replacement, normalized)
709
+
710
+ depth = max(1, strategy.depth)
711
+ parts = [part for part in normalized.split("/") if part]
712
+ if len(parts) <= 1:
713
+ return normalized
714
+
715
+ folder_parts = parts[:-1]
716
+ if not folder_parts:
717
+ return normalized
718
+
719
+ return "/".join(folder_parts[:depth])
720
+
721
+
722
+ def _matches(pattern: Pattern, node: str) -> bool:
723
+ normalized = _normalize(node)
724
+ if isinstance(pattern, str):
725
+ return fnmatch.fnmatchcase(normalized, pattern)
726
+ return bool(pattern.search(normalized))
727
+
728
+
729
+ def _normalize(node: str) -> str:
730
+ return node.replace("\\", "/")
731
+
732
+
733
+ def _unique_sorted(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
734
+ return tuple(sorted(set(values)))
735
+
736
+
737
+ def _unique_sorted_import_kinds(values: tuple[object, ...]) -> tuple[str, ...]:
738
+ return tuple(
739
+ sorted(
740
+ {
741
+ str(getattr(value, "value", value))
742
+ for value in values
743
+ }
744
+ )
745
+ )
746
+
747
+
748
+ def _quote_dot(input_value: str) -> str:
749
+ return '"' + input_value.replace("\\", "\\\\").replace('"', '\\"') + '"'
750
+
751
+
752
+ def _quote_d2(input_value: str) -> str:
753
+ return '"' + input_value.replace("\\", "\\\\").replace('"', '\\"') + '"'
754
+
755
+
756
+ def _escape_mermaid_label(input_value: str) -> str:
757
+ return input_value.replace("\\", "\\\\").replace('"', "#quot;")
758
+
759
+
760
+ def _render_edge_table(snapshot: GraphReportSnapshot) -> str:
761
+ if not snapshot.edges:
762
+ return '<div class="empty">No dependency edges matched this graph query.</div>'
763
+
764
+ rows = "\n".join(
765
+ f""" <tr>
766
+ <td>{escape_html(edge.source)}</td>
767
+ <td>{escape_html(edge.target)}</td>
768
+ <td>{edge.count}</td>
769
+ <td>{"yes" if edge.external else "no"}</td>
770
+ <td>{escape_html(", ".join(edge.import_kinds))}</td>
771
+ </tr>"""
772
+ for edge in snapshot.edges
773
+ )
774
+
775
+ return f"""<table>
776
+ <thead>
777
+ <tr>
778
+ <th>Source</th>
779
+ <th>Target</th>
780
+ <th>Count</th>
781
+ <th>External</th>
782
+ <th>Import Kinds</th>
783
+ </tr>
784
+ </thead>
785
+ <tbody>
786
+ {rows}
787
+ </tbody>
788
+ </table>"""
789
+
790
+
791
+ def _write_report(output_path: str, content: str) -> None:
792
+ path = Path(output_path)
793
+ if path.parent != Path("."):
794
+ path.parent.mkdir(parents=True, exist_ok=True)
795
+ path.write_text(content, encoding="utf-8")