Choreo.Dependency.Analysis (Choreo v0.6.0)

Copy Markdown View Source

Analysis functions for Choreo.Dependency graphs.

Provides algorithms that answer practical questions about a codebase:

  • Where are the circular dependencies?
  • What breaks if I change a component?
  • Are we violating our layer architecture?
  • Which components are most coupled?
  • What is the deepest dependency chain?

Further reading

Summary

Functions

Returns all nodes that transitively depend on the given node.

Returns nodes ranked by coupling centrality (in-degree + out-degree).

Returns all circular dependency chains.

Returns all nodes that the given node transitively depends on.

Calculates the Instability metric for each component.

Identifies completely isolated subsystems (weakly connected components).

Checks edges against an expected layered architecture.

Returns nodes with no dependents (nothing depends on them).

Finds the longest dependency chain in the graph.

Returns nodes with no dependencies (foundational components).

Identifies redundant explicit dependencies.

Validates a dependency graph and returns a list of issues.

Functions

affected_by(dependency, target)

@spec affected_by(Choreo.Dependency.t(), Yog.node_id()) :: [Yog.node_id()]

Returns all nodes that transitively depend on the given node.

If you change target, these are the components that could break.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_application(:api)
...>   |> Choreo.Dependency.add_module(:auth)
...>   |> Choreo.Dependency.add_module(:util)
...>   |> Choreo.Dependency.depends_on(:api, :auth)
...>   |> Choreo.Dependency.depends_on(:auth, :util)
iex> Enum.sort(Choreo.Dependency.Analysis.affected_by(deps, :util))
[:api, :auth]
iex> Choreo.Dependency.Analysis.affected_by(deps, :api)
[]

This analysis answers the question: "What breaks if I change this component?"

centrality(dependency, opts \\ [])

@spec centrality(
  Choreo.Dependency.t(),
  keyword()
) :: [Yog.node_id()]

Returns nodes ranked by coupling centrality (in-degree + out-degree).

The most coupled components are at the head of the list.

Options

  • :limit — maximum number of results (default: all)

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:hub)
...>   |> Choreo.Dependency.add_module(:a)
...>   |> Choreo.Dependency.add_module(:b)
...>   |> Choreo.Dependency.add_module(:c)
...>   |> Choreo.Dependency.depends_on(:a, :hub)
...>   |> Choreo.Dependency.depends_on(:b, :hub)
...>   |> Choreo.Dependency.depends_on(:hub, :c)
iex> hd(Choreo.Dependency.Analysis.centrality(deps))
:hub
iex> length(Choreo.Dependency.Analysis.centrality(deps, limit: 2))
2

This analysis answers the question: "Which components are the most coupled?"

cyclic_dependencies(dependency)

@spec cyclic_dependencies(Choreo.Dependency.t()) :: [[Yog.node_id()]]

Returns all circular dependency chains.

Each cycle is returned as a list of node IDs starting and ending at the same node. Only one representative cycle is returned per strongly connected component.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:a)
...>   |> Choreo.Dependency.add_module(:b)
...>   |> Choreo.Dependency.add_module(:c)
...>   |> Choreo.Dependency.depends_on(:a, :b)
...>   |> Choreo.Dependency.depends_on(:b, :a)
...>   |> Choreo.Dependency.depends_on(:c, :a)
iex> cycles = Choreo.Dependency.Analysis.cyclic_dependencies(deps)
iex> length(cycles)
1
iex> [cycle] = cycles
iex> hd(cycle) == List.last(cycle)
true
iex> :a in cycle
true
iex> :b in cycle
true

This analysis answers the question: "Where are the circular dependencies?"

depends_on(dependency, target)

@spec depends_on(Choreo.Dependency.t(), Yog.node_id()) :: [Yog.node_id()]

Returns all nodes that the given node transitively depends on.

These are the components target cannot function without.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_application(:api)
...>   |> Choreo.Dependency.add_module(:auth)
...>   |> Choreo.Dependency.add_module(:util)
...>   |> Choreo.Dependency.depends_on(:api, :auth)
...>   |> Choreo.Dependency.depends_on(:auth, :util)
iex> Enum.sort(Choreo.Dependency.Analysis.depends_on(deps, :api))
[:auth, :util]
iex> Choreo.Dependency.Analysis.depends_on(deps, :util)
[]

This analysis answers the question: "What does this component need to function?"

instability(dependency)

@spec instability(Choreo.Dependency.t()) :: %{required(Yog.node_id()) => float()}

Calculates the Instability metric for each component.

Formula: Efferent Coupling / (Afferent + Efferent Coupling) Returns a map of node_id => instability_score. A score of 0.0 means the component is maximally stable. A score of 1.0 means the component is maximally unstable.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:stable)
...>   |> Choreo.Dependency.add_module(:unstable)
...>   |> Choreo.Dependency.add_module(:mixed)
...>   |> Choreo.Dependency.depends_on(:unstable, :stable)
...>   |> Choreo.Dependency.depends_on(:unstable, :mixed)
...>   |> Choreo.Dependency.depends_on(:mixed, :stable)
iex> scores = Choreo.Dependency.Analysis.instability(deps)
iex> scores.stable
0.0
iex> scores.unstable
1.0
iex> scores.mixed
0.5

This analysis answers the question: "How stable is each component?"

isolated_subsystems(dependency)

@spec isolated_subsystems(Choreo.Dependency.t()) :: [[Yog.node_id()]]

Identifies completely isolated subsystems (weakly connected components).

Returns a list of components, where each component is a list of node IDs.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:a1)
...>   |> Choreo.Dependency.add_module(:a2)
...>   |> Choreo.Dependency.depends_on(:a1, :a2)
...>   |> Choreo.Dependency.add_module(:b1)
...>   |> Choreo.Dependency.add_module(:b2)
...>   |> Choreo.Dependency.depends_on(:b1, :b2)
...>   |> Choreo.Dependency.add_module(:orphan)
iex> subsystems = Choreo.Dependency.Analysis.isolated_subsystems(deps)
iex> length(subsystems)
3
iex> Enum.any?(subsystems, fn g -> Enum.sort(g) == [:a1, :a2] end)
true
iex> Enum.any?(subsystems, fn g -> g == [:orphan] end)
true

This analysis answers the question: "Which groups of components are completely disconnected?"

layer_violations(dependency, layers)

@spec layer_violations(Choreo.Dependency.t(), %{required(Yog.node_id()) => integer()}) ::
  [
    {Yog.node_id(), Yog.node_id(), String.t()}
  ]

Checks edges against an expected layered architecture.

layers is a map of node_id => layer_index where lower numbers are "lower" layers (foundations). A violation occurs when an edge points from a lower layer to a higher layer (the dependency arrow goes upward).

Returns a list of violation tuples: {from, to, description}.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:repo)
...>   |> Choreo.Dependency.add_module(:service)
...>   |> Choreo.Dependency.add_module(:api)
...>   |> Choreo.Dependency.depends_on(:service, :repo)
...>   |> Choreo.Dependency.depends_on(:repo, :api)
iex> layers = %{repo: 1, service: 2, api: 3}
iex> violations = Choreo.Dependency.Analysis.layer_violations(deps, layers)
iex> length(violations)
1
iex> {from, to, _desc} = hd(violations)
iex> from
:repo
iex> to
:api

This analysis answers the question: "Which edges violate my layered architecture?"

leaves(dependency)

@spec leaves(Choreo.Dependency.t()) :: [Yog.node_id()]

Returns nodes with no dependents (nothing depends on them).

These are safe to change or delete without breaking other components.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:a)
...>   |> Choreo.Dependency.add_module(:b)
...>   |> Choreo.Dependency.add_module(:c)
...>   |> Choreo.Dependency.depends_on(:a, :b)
...>   |> Choreo.Dependency.depends_on(:b, :c)
iex> Choreo.Dependency.Analysis.leaves(deps)
[:a]

This analysis answers the question: "Which components have no dependents?"

longest_dependency_chain(dependency)

@spec longest_dependency_chain(Choreo.Dependency.t()) ::
  {:ok, [Yog.node_id()], number()} | :error

Finds the longest dependency chain in the graph.

This measures the maximum depth of the dependency tree — useful for estimating compilation or test ordering time.

Returns {:ok, [id], total_weight} or :error if cyclic.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:a)
...>   |> Choreo.Dependency.add_module(:b)
...>   |> Choreo.Dependency.add_module(:c)
...>   |> Choreo.Dependency.depends_on(:a, :b)
...>   |> Choreo.Dependency.depends_on(:b, :c)
iex> Choreo.Dependency.Analysis.longest_dependency_chain(deps)
{:ok, [:a, :b, :c], 2}

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:a)
...>   |> Choreo.Dependency.add_module(:b)
...>   |> Choreo.Dependency.depends_on(:a, :b)
...>   |> Choreo.Dependency.depends_on(:b, :a)
iex> Choreo.Dependency.Analysis.longest_dependency_chain(deps)
:error

This analysis answers the question: "What is the deepest dependency chain?"

roots(dependency)

@spec roots(Choreo.Dependency.t()) :: [Yog.node_id()]

Returns nodes with no dependencies (foundational components).

These are the bottom of the dependency stack.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:a)
...>   |> Choreo.Dependency.add_module(:b)
...>   |> Choreo.Dependency.add_module(:c)
...>   |> Choreo.Dependency.depends_on(:a, :b)
...>   |> Choreo.Dependency.depends_on(:b, :c)
iex> Choreo.Dependency.Analysis.roots(deps)
[:c]

This analysis answers the question: "Which components have no dependencies?"

transitive_reduction(dependency)

@spec transitive_reduction(Choreo.Dependency.t()) :: [{Yog.node_id(), Yog.node_id()}]

Identifies redundant explicit dependencies.

If A depends on B, B depends on C, and A depends on C, the direct edge A -> C is often redundant. This returns a list of {from, to} tuples representing these redundant edges.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:a)
...>   |> Choreo.Dependency.add_module(:b)
...>   |> Choreo.Dependency.add_module(:c)
...>   |> Choreo.Dependency.depends_on(:a, :b)
...>   |> Choreo.Dependency.depends_on(:b, :c)
...>   |> Choreo.Dependency.depends_on(:a, :c)
iex> Choreo.Dependency.Analysis.transitive_reduction(deps)
[{:a, :c}]

This analysis answers the question: "Which explicit dependencies are redundant?"

validate(deps)

@spec validate(Choreo.Dependency.t()) :: [{:error | :warning, String.t()}]

Validates a dependency graph and returns a list of issues.

Checks for:

  • circular dependencies
  • orphaned nodes (no edges at all)

Returns a list of {severity, message} tuples.

Examples

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:a)
...>   |> Choreo.Dependency.add_module(:b)
...>   |> Choreo.Dependency.depends_on(:a, :b)
iex> Choreo.Dependency.Analysis.validate(deps)
[]

iex> deps = Choreo.Dependency.new()
iex> deps = deps
...>   |> Choreo.Dependency.add_module(:a)
...>   |> Choreo.Dependency.add_module(:b)
...>   |> Choreo.Dependency.depends_on(:a, :b)
...>   |> Choreo.Dependency.depends_on(:b, :a)
iex> issues = Choreo.Dependency.Analysis.validate(deps)
iex> Enum.any?(issues, fn {sev, msg} -> sev == :error and String.contains?(msg, "Circular") end)
true

This analysis answers the question: "Is the dependency graph structurally sound?"