• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

code-shoily / choreo / 8f4569353e6f529ff097053f0fd6c68ee22ad359

26 Apr 2026 08:10PM UTC coverage: 92.503% (+0.2%) from 92.313%
8f4569353e6f529ff097053f0fd6c68ee22ad359

push

github

code-shoily
Update README and CHANGESET

1419 of 1534 relevant lines covered (92.5%)

20.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.75
/lib/choreo/dependency.ex
1
defmodule Choreo.Dependency do
2
  @moduledoc """
3
  Software dependency graph builder on top of Yog.
4

5
  `Choreo.Dependency` models component relationships — modules, libraries,
6
  applications, interfaces, and tests — to help visualize and analyze coupling,
7
  layering, and circular dependencies.
8

9
  ## When to use
10

11
  Use `Choreo.Dependency` when refactoring a codebase, onboarding new
12
  developers, or enforcing architectural boundaries. It surfaces hidden
13
  cycles, measures instability, and identifies the deepest dependency chains
14
  that slow down builds and tests.
15

16
  ## Node types
17

18
    * `:application` — deployable service or app
19
    * `:library` — external or shared library
20
    * `:module` — internal code module
21
    * `:interface` — API, contract, or protocol definition
22
    * `:test` — test suite or spec
23

24
  ## Edge types
25

26
    * `:uses` — general dependency
27
    * `:imports` — explicit import / require
28
    * `:calls` — runtime function call
29
    * `:inherits` — inheritance / implementation
30
    * `:dev` — development-only dependency
31

32
  ## Further reading
33

34
    * [Dependency inversion principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
35
    * [Circular dependency](https://en.wikipedia.org/wiki/Circular_dependency)
36
    * [Coupling (computer programming)](https://en.wikipedia.org/wiki/Coupling_(computer_programming))
37

38
  ## Quick Start
39

40
      deps =
41
        Choreo.Dependency.new()
42
        |> Choreo.Dependency.add_application(:api, label: "API Gateway")
43
        |> Choreo.Dependency.add_library(:phoenix, label: "Phoenix")
44
        |> Choreo.Dependency.add_module(:auth, label: "Auth Module")
45
        |> Choreo.Dependency.depends_on(:api, :phoenix, type: :uses)
46
        |> Choreo.Dependency.depends_on(:api, :auth, type: :calls)
47

48
      dot = Choreo.Dependency.to_dot(deps)
49

50
  ## Diagram
51

52
  <div class="graphviz">
53
    digraph G {
54
      graph [rankdir=TB, splines=spline, nodesep=0.5, ranksep=1.0];
55
      node [shape=box, style=filled, fillcolor="white", fontname="Helvetica", fontsize=12, fontcolor="white"];
56
      edge [arrowhead=normal, color="#64748b", style=solid, fontname="Helvetica", fontsize=9, penwidth=1.0];
57

58
      auth [label="Auth Module", fillcolor="#10b981", shape="box"];
59
      api [label="API Gateway", fillcolor="#3b82f6", shape="box3d"];
60
      phoenix [label="Phoenix", fillcolor="#f59e0b", shape="cylinder"];
61

62
      api -> auth [style="dotted", label="calls"];
63
      api -> phoenix [label="uses"];
64
    }
65
  </div>
66

67
  ## Analysis
68

69
      # Find circular dependencies
70
      Choreo.Dependency.Analysis.cyclic_dependencies(deps)
71
      #=> [[:repo, :service, :repo]]
72

73
      # Impact analysis: what breaks if :auth changes?
74
      Choreo.Dependency.Analysis.affected_by(deps, :auth)
75
      #=> [:api, :web]
76

77
      # Check layer violations
78
      layers = %{api: 3, web: 2, repo: 1}
79
      Choreo.Dependency.Analysis.layer_violations(deps, layers)
80
      #=> [{:repo, :api, "repo (layer 1) calls api (layer 3)"}]
81
  """
82

83
  @type t :: %__MODULE__{
84
          graph: Yog.Multi.Graph.t(),
85
          edge_meta: %{optional(Yog.Multi.Graph.edge_id()) => map()},
86
          clusters: %{String.t() => map()}
87
        }
88

89
  defstruct graph: nil, edge_meta: %{}, clusters: %{}
90

91
  @add_application_schema [
92
    label: [
93
      type: :string,
94
      required: false
95
    ],
96
    description: [
97
      type: :string,
98
      required: false
99
    ],
100
    cluster: [
101
      type: :string,
102
      required: false
103
    ]
104
  ]
105

106
  @add_library_schema [
107
    label: [
108
      type: :string,
109
      required: false
110
    ],
111
    description: [
112
      type: :string,
113
      required: false
114
    ],
115
    cluster: [
116
      type: :string,
117
      required: false
118
    ]
119
  ]
120

121
  @add_module_schema [
122
    label: [
123
      type: :string,
124
      required: false
125
    ],
126
    description: [
127
      type: :string,
128
      required: false
129
    ],
130
    cluster: [
131
      type: :string,
132
      required: false
133
    ]
134
  ]
135

136
  @add_interface_schema [
137
    label: [
138
      type: :string,
139
      required: false
140
    ],
141
    description: [
142
      type: :string,
143
      required: false
144
    ],
145
    cluster: [
146
      type: :string,
147
      required: false
148
    ]
149
  ]
150

151
  @add_test_schema [
152
    label: [
153
      type: :string,
154
      required: false
155
    ],
156
    description: [
157
      type: :string,
158
      required: false
159
    ],
160
    cluster: [
161
      type: :string,
162
      required: false
163
    ]
164
  ]
165

166
  @add_cluster_schema [
167
    parent: [
168
      type: :string,
169
      required: false
170
    ],
171
    label: [
172
      type: :string,
173
      required: false
174
    ],
175
    style: [
176
      type: :string,
177
      required: false
178
    ],
179
    fillcolor: [
180
      type: :string,
181
      required: false
182
    ],
183
    color: [
184
      type: :string,
185
      required: false
186
    ]
187
  ]
188

189
  @depends_on_schema [
190
    type: [
191
      type: {:in, [:uses, :imports, :calls, :inherits, :dev]},
192
      required: false
193
    ],
194
    label: [
195
      type: :string,
196
      required: false
197
    ]
198
  ]
199

200
  # ============================================================================
201
  # Creation
202
  # ============================================================================
203

204
  @doc """
205
  Creates a new empty dependency graph.
206

207
  Dependency graphs are always directed.
208

209
  ## Examples
210

211
      iex> deps = Choreo.Dependency.new()
212
      iex> Choreo.Dependency.nodes(deps)
213
      []
214
      iex> Choreo.Dependency.edges(deps)
215
      []
216
  """
217
  @spec new() :: t()
218
  def new do
219
    %__MODULE__{
72✔
220
      graph: Yog.Multi.new(:directed),
221
      edge_meta: %{},
222
      clusters: %{}
223
    }
224
  end
225

226
  # ============================================================================
227
  # Node builders
228
  # ============================================================================
229

230
  @doc """
231
  Adds an application node (deployable service or app).
232

233
  ## Options
234

235
    * `:label` — display label (defaults to the node id)
236
    * `:description` — tooltip text
237
    * `:cluster` — cluster name for grouping
238

239
  ## Examples
240

241
      iex> deps = Choreo.Dependency.new()
242
      iex> deps = Choreo.Dependency.add_application(deps, :api, label: "API")
243
      iex> Choreo.Dependency.nodes(deps)
244
      [:api]
245
      iex> Map.get(deps.graph.nodes, :api).node_type
246
      :application
247
      iex> Map.get(deps.graph.nodes, :api).label
248
      "API"
249

250
  ## Diagram
251

252
  <div class="graphviz">
253
    digraph G {
254
      graph [rankdir=TB, splines=spline, nodesep=0.5, ranksep=1.0];
255
      node [shape=box, style=filled, fillcolor="white", fontname="Helvetica", fontsize=12, fontcolor="white"];
256
      edge [arrowhead=normal, color="#64748b", style=solid, fontname="Helvetica", fontsize=9, penwidth=1.0];
257

258
      api [label="API Gateway", fillcolor="#3b82f6", shape="box3d"];
259
    }
260
  </div>
261
  """
262
  def add_application(deps, id, opts \\ []) do
263
    opts = NimbleOptions.validate!(opts, @add_application_schema)
25✔
264
    add_typed_node(deps, id, :application, opts)
24✔
265
  end
266

267
  @doc """
268
  Adds a library node (external or shared dependency).
269

270
  ## Options
271

272
    * `:label` — display label (defaults to the node id)
273
    * `:description` — tooltip text
274
    * `:cluster` — cluster name for grouping
275

276
  ## Examples
277

278
      iex> deps = Choreo.Dependency.new()
279
      iex> deps = Choreo.Dependency.add_library(deps, :phx, label: "Phoenix")
280
      iex> Choreo.Dependency.nodes(deps)
281
      [:phx]
282
      iex> Map.get(deps.graph.nodes, :phx).node_type
283
      :library
284

285
  ## Diagram
286

287
  <div class="graphviz">
288
    digraph G {
289
      graph [rankdir=TB, splines=spline, nodesep=0.5, ranksep=1.0];
290
      node [shape=box, style=filled, fillcolor="white", fontname="Helvetica", fontsize=12, fontcolor="white"];
291
      edge [arrowhead=normal, color="#64748b", style=solid, fontname="Helvetica", fontsize=9, penwidth=1.0];
292

293
      phx [label="Phoenix", fillcolor="#f59e0b", shape="cylinder"];
294
    }
295
  </div>
296
  """
297
  def add_library(deps, id, opts \\ []) do
298
    opts = NimbleOptions.validate!(opts, @add_library_schema)
5✔
299
    add_typed_node(deps, id, :library, opts)
5✔
300
  end
301

302
  @doc """
303
  Adds a module node (internal code unit).
304

305
  ## Options
306

307
    * `:label` — display label (defaults to the node id)
308
    * `:description` — tooltip text
309
    * `:cluster` — cluster name for grouping
310

311
  ## Examples
312

313
      iex> deps = Choreo.Dependency.new()
314
      iex> deps = Choreo.Dependency.add_module(deps, :auth)
315
      iex> Choreo.Dependency.nodes(deps)
316
      [:auth]
317
      iex> Map.get(deps.graph.nodes, :auth).node_type
318
      :module
319

320
  ## Diagram
321

322
  <div class="graphviz">
323
    digraph G {
324
      graph [rankdir=TB, splines=spline, nodesep=0.5, ranksep=1.0];
325
      node [shape=box, style=filled, fillcolor="white", fontname="Helvetica", fontsize=12, fontcolor="white"];
326
      edge [arrowhead=normal, color="#64748b", style=solid, fontname="Helvetica", fontsize=9, penwidth=1.0];
327

328
      auth [label="auth", fillcolor="#10b981", shape="box"];
329
    }
330
  </div>
331
  """
332
  def add_module(deps, id, opts \\ []) do
333
    opts = NimbleOptions.validate!(opts, @add_module_schema)
120✔
334
    add_typed_node(deps, id, :module, opts)
120✔
335
  end
336

337
  @doc """
338
  Adds an interface node (API, contract, protocol).
339

340
  ## Options
341

342
    * `:label` — display label (defaults to the node id)
343
    * `:description` — tooltip text
344
    * `:cluster` — cluster name for grouping
345

346
  ## Examples
347

348
      iex> deps = Choreo.Dependency.new()
349
      iex> deps = Choreo.Dependency.add_interface(deps, :contract)
350
      iex> Choreo.Dependency.nodes(deps)
351
      [:contract]
352
      iex> Map.get(deps.graph.nodes, :contract).node_type
353
      :interface
354

355
  ## Diagram
356

357
  <div class="graphviz">
358
    digraph G {
359
      graph [rankdir=TB, splines=spline, nodesep=0.5, ranksep=1.0];
360
      node [shape=box, style=filled, fillcolor="white", fontname="Helvetica", fontsize=12, fontcolor="white"];
361
      edge [arrowhead=normal, color="#64748b", style=solid, fontname="Helvetica", fontsize=9, penwidth=1.0];
362

363
      contract [label="contract", fillcolor="#8b5cf6", shape="diamond"];
364
    }
365
  </div>
366
  """
367
  def add_interface(deps, id, opts \\ []) do
368
    opts = NimbleOptions.validate!(opts, @add_interface_schema)
2✔
369
    add_typed_node(deps, id, :interface, opts)
2✔
370
  end
371

372
  @doc """
373
  Adds a test node (test suite or spec).
374

375
  ## Options
376

377
    * `:label` — display label (defaults to the node id)
378
    * `:description` — tooltip text
379
    * `:cluster` — cluster name for grouping
380

381
  ## Examples
382

383
      iex> deps = Choreo.Dependency.new()
384
      iex> deps = Choreo.Dependency.add_test(deps, :auth_test)
385
      iex> Choreo.Dependency.nodes(deps)
386
      [:auth_test]
387
      iex> Map.get(deps.graph.nodes, :auth_test).node_type
388
      :test
389

390
  ## Diagram
391

392
  <div class="graphviz">
393
    digraph G {
394
      graph [rankdir=TB, splines=spline, nodesep=0.5, ranksep=1.0];
395
      node [shape=box, style=filled, fillcolor="white", fontname="Helvetica", fontsize=12, fontcolor="white"];
396
      edge [arrowhead=normal, color="#64748b", style=solid, fontname="Helvetica", fontsize=9, penwidth=1.0];
397

398
      auth_test [label="auth_test", fillcolor="#64748b", shape="note"];
399
    }
400
  </div>
401
  """
402
  def add_test(deps, id, opts \\ []) do
403
    opts = NimbleOptions.validate!(opts, @add_test_schema)
2✔
404
    add_typed_node(deps, id, :test, opts)
2✔
405
  end
406

407
  # ============================================================================
408
  # Clusters
409
  # ============================================================================
410

411
  @doc """
412
  Defines a cluster for grouping nodes visually (e.g., by team or layer).
413

414
  ## Options
415

416
    * `:parent` — name of the parent cluster for nesting
417
    * `:label` — display label (defaults to the cluster name)
418
    * `:style` — `:filled`, `:rounded`, etc.
419
    * `:fillcolor` — background colour
420
    * `:color` — border colour
421

422
  ## Examples
423

424
      iex> deps = Choreo.Dependency.new()
425
      iex> deps = Choreo.Dependency.add_cluster(deps, "core", label: "Core")
426
      iex> deps.clusters["cluster_core"].label
427
      "Core"
428
  """
429
  @spec add_cluster(t(), String.t(), keyword()) :: t()
430
  def add_cluster(%__MODULE__{} = deps, name, opts \\ []) do
431
    opts = NimbleOptions.validate!(opts, @add_cluster_schema)
4✔
432
    name = Choreo.Internal.ensure_cluster_prefix(name)
4✔
433
    cluster = Map.new(opts)
4✔
434
    clusters = Map.put(deps.clusters, name, cluster)
4✔
435
    %{deps | clusters: clusters}
4✔
436
  end
437

438
  # ============================================================================
439
  # Edge builders
440
  # ============================================================================
441

442
  @doc """
443
  Creates a dependency edge from one component to another.
444

445
  Direction reads as "`from` depends on `to`".
446

447
  > ### Limitation
448
  > At most one edge is allowed per `(from, to)` pair.
449
  > Adding a second dependency between the same components raises
450
  > `ArgumentError`. Multigraph support (parallel edges) is planned
451
  > for a future release.
452

453
  ## Options
454

455
    * `:type` — `:uses`, `:imports`, `:calls`, `:inherits`, `:dev` (default: `:uses`)
456
    * `:label` — override label
457

458
  ## Examples
459

460
      iex> deps = Choreo.Dependency.new()
461
      iex> deps = deps
462
      ...>   |> Choreo.Dependency.add_application(:api)
463
      ...>   |> Choreo.Dependency.add_module(:auth)
464
      ...>   |> Choreo.Dependency.depends_on(:api, :auth)
465
      iex> [{_, _, _, meta}] = Choreo.Dependency.edges_with_meta(deps)
466
      iex> meta.type
467
      :uses
468

469
  ## Diagram
470

471
  <div class="graphviz">
472
    digraph G {
473
      graph [rankdir=TB, splines=spline, nodesep=0.5, ranksep=1.0];
474
      node [shape=box, style=filled, fillcolor="white", fontname="Helvetica", fontsize=12, fontcolor="white"];
475
      edge [arrowhead=normal, color="#64748b", style=solid, fontname="Helvetica", fontsize=9, penwidth=1.0];
476

477
      auth [label="auth", fillcolor="#10b981", shape="box"];
478
      api [label="api", fillcolor="#3b82f6", shape="box3d"];
479

480
      api -> auth [label="uses"];
481
    }
482
  </div>
483

484
      iex> deps = Choreo.Dependency.new()
485
      iex> deps = deps
486
      ...>   |> Choreo.Dependency.add_application(:api)
487
      ...>   |> Choreo.Dependency.add_module(:auth)
488
      ...>   |> Choreo.Dependency.depends_on(:api, :auth, type: :calls)
489
      iex> [{_, _, _, meta}] = Choreo.Dependency.edges_with_meta(deps)
490
      iex> meta.type
491
      :calls
492
      iex> meta.label
493
      "calls"
494
  """
495
  def depends_on(%__MODULE__{} = deps, from, to, opts \\ []) do
496
    opts = NimbleOptions.validate!(opts, @depends_on_schema)
92✔
497
    type = Keyword.get(opts, :type, :uses)
91✔
498
    label = opts[:label] || type_to_label(type)
91✔
499

500
    meta =
91✔
501
      opts
502
      |> Map.new()
503
      |> Map.put(:type, type)
504
      |> Map.put(:label, label)
505

506
    {graph, edge_id} = Yog.Multi.add_edge(deps.graph, from, to, 1)
91✔
507
    edge_meta = Map.put(deps.edge_meta, edge_id, meta)
91✔
508

509
    %{deps | graph: graph, edge_meta: edge_meta}
91✔
510
  end
511

512
  # ============================================================================
513
  # Queries
514
  # ============================================================================
515

516
  @doc """
517
  Returns all node IDs in the dependency graph.
518

519
  ## Examples
520

521
      iex> deps = Choreo.Dependency.new()
522
      iex> deps = deps
523
      ...>   |> Choreo.Dependency.add_application(:api)
524
      ...>   |> Choreo.Dependency.add_module(:auth)
525
      iex> Enum.sort(Choreo.Dependency.nodes(deps))
526
      [:api, :auth]
527
  """
528
  @spec nodes(t()) :: [Yog.node_id()]
529
  def nodes(%__MODULE__{graph: graph}) do
530
    Map.keys(graph.nodes)
18✔
531
  end
532

533
  @doc """
534
  Returns all dependency edges as `{from, to, weight}` tuples.
535

536
  ## Examples
537

538
      iex> deps = Choreo.Dependency.new()
539
      iex> deps = deps
540
      ...>   |> Choreo.Dependency.add_application(:api)
541
      ...>   |> Choreo.Dependency.add_module(:auth)
542
      ...>   |> Choreo.Dependency.depends_on(:api, :auth)
543
      iex> Choreo.Dependency.edges(deps)
544
      [{:api, :auth, 1}]
545
  """
546
  @spec edges(t()) :: [{Yog.node_id(), Yog.node_id(), any()}]
547
  def edges(%__MODULE__{graph: graph}) do
548
    Enum.map(graph.edges, fn {_edge_id, {from, to, weight}} ->
4✔
549
      {from, to, weight}
3✔
550
    end)
551
  end
552

553
  @doc """
554
  Returns all edges with their metadata as `{from, to, weight, meta}` tuples.
555
  """
556
  @spec edges_with_meta(t()) :: [{Yog.node_id(), Yog.node_id(), any(), map()}]
557
  def edges_with_meta(%__MODULE__{graph: graph, edge_meta: edge_meta}) do
558
    Enum.map(graph.edges, fn {edge_id, {from, to, weight}} ->
5✔
559
      {from, to, weight, Map.get(edge_meta, edge_id, %{})}
5✔
560
    end)
561
  end
562

563
  @doc """
564
  Collapses parallel edges into a simple Graph for algorithm analysis.
565
  """
566
  @spec to_simple_graph(t(), keyword()) :: Yog.Graph.t()
567
  def to_simple_graph(%__MODULE__{graph: graph}, opts \\ []) do
568
    combine = Keyword.get(opts, :combine, fn a, _b -> a end)
56✔
569
    Yog.Multi.to_simple_graph(graph, combine)
56✔
570
  end
571

572
  @doc """
573
  Returns all nodes of a given type.
574

575
  ## Examples
576

577
      iex> deps = Choreo.Dependency.new()
578
      iex> deps = deps
579
      ...>   |> Choreo.Dependency.add_application(:a)
580
      ...>   |> Choreo.Dependency.add_application(:b)
581
      ...>   |> Choreo.Dependency.add_library(:c)
582
      iex> Enum.sort(Choreo.Dependency.nodes_of_type(deps, :application))
583
      [:a, :b]
584
      iex> Choreo.Dependency.nodes_of_type(deps, :library)
585
      [:c]
586
      iex> Choreo.Dependency.nodes_of_type(deps, :module)
587
      []
588
  """
589
  @spec nodes_of_type(t(), atom()) :: [Yog.node_id()]
590
  def nodes_of_type(%__MODULE__{graph: graph}, type) do
591
    graph.nodes
6✔
592
    |> Enum.filter(fn {_id, data} -> data[:node_type] == type end)
21✔
593
    |> Enum.map(fn {id, _data} -> id end)
6✔
594
  end
595

596
  @doc """
597
  Returns the raw `Yog.Graph` struct underpinning the dependency graph.
598

599
  ## Examples
600

601
      iex> deps = Choreo.Dependency.new()
602
      iex> graph = Choreo.Dependency.to_graph(deps)
603
      iex> graph.kind
604
      :directed
605
  """
606
  @spec to_graph(t()) :: Yog.graph()
607
  def to_graph(%__MODULE__{graph: graph}), do: graph
1✔
608

609
  # ============================================================================
610
  # Rendering
611
  # ============================================================================
612

613
  @doc """
614
  Renders the dependency graph to DOT format.
615

616
  ## Options
617

618
    * `:theme` — `:default`, `:dark`, or a `Choreo.Theme` struct
619

620
  ## Examples
621

622
      iex> deps = Choreo.Dependency.new()
623
      iex> deps = deps
624
      ...>   |> Choreo.Dependency.add_application(:api, label: "API")
625
      ...>   |> Choreo.Dependency.add_module(:auth, label: "Auth")
626
      ...>   |> Choreo.Dependency.depends_on(:api, :auth)
627
      iex> dot = Choreo.Dependency.to_dot(deps)
628
      iex> String.contains?(dot, "digraph")
629
      true
630
      iex> String.contains?(dot, "API")
631
      true
632
      iex> String.contains?(dot, "Auth")
633
      true
634
  """
635
  @spec to_dot(t(), keyword()) :: String.t()
636
  def to_dot(%__MODULE__{} = deps, opts \\ []) do
637
    Choreo.Dependency.Render.DOT.to_dot(deps, opts)
5✔
638
  end
639

640
  # ============================================================================
641
  # Private helpers
642
  # ============================================================================
643

644
  defp add_typed_node(%__MODULE__{graph: graph} = deps, id, type, opts) do
645
    {cluster, rest_opts} = Keyword.pop(opts, :cluster)
153✔
646

647
    data = %{
153✔
648
      type: :dependency_node,
649
      node_type: type,
650
      label: Keyword.get(rest_opts, :label, to_string(id)),
153✔
651
      description: rest_opts[:description]
652
    }
653

654
    data =
153✔
655
      if cluster,
656
        do: Map.put(data, :cluster, Choreo.Internal.ensure_cluster_prefix(cluster)),
2✔
657
        else: data
151✔
658

659
    %{deps | graph: Yog.Multi.add_node(graph, id, data)}
153✔
660
  end
661

662
  defp type_to_label(:uses), do: "uses"
87✔
663
  defp type_to_label(:imports), do: "imports"
×
664
  defp type_to_label(:calls), do: "calls"
3✔
665
  defp type_to_label(:inherits), do: "inherits"
×
666
  defp type_to_label(:dev), do: "dev"
1✔
667
  defp type_to_label(_), do: ""
×
668
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc