Current section

Files

Jump to
ragex lib ragex analysis dependency_graph.ex
Raw

lib/ragex/analysis/dependency_graph.ex

defmodule Ragex.Analysis.DependencyGraph do
@moduledoc """
Dependency analysis for module and function relationships.
Analyzes the dependency graph stored in the knowledge graph to:
- Detect circular dependencies
- Calculate coupling metrics (afferent, efferent, instability)
- Find unused modules
- Identify God modules (high coupling)
- Suggest decoupling opportunities
All analysis operates on the existing knowledge graph edges (`:calls`, `:imports`).
"""
alias Ragex.Analysis.DependencyGraph.AIInsights
alias Ragex.Graph.Store
require Logger
@type module_name :: atom()
@type function_ref :: {:function, module(), atom(), non_neg_integer()}
@type coupling_metrics :: %{
afferent: non_neg_integer(),
efferent: non_neg_integer(),
instability: float()
}
@type suggestion :: %{
type: atom(),
severity: :low | :medium | :high,
description: String.t(),
entities: [term()],
metadata: map()
}
# Cycle Detection
@doc """
Finds circular dependencies in the module dependency graph.
Returns a list of cycles, where each cycle is a list of module names forming a circular dependency.
Uses depth-first search to detect cycles.
## Parameters
- `opts`: Keyword list of options
- `:min_cycle_length` - Minimum cycle length (default: 2)
- `:scope` - `:module` or `:function` (default: `:module`)
- `:limit` - Maximum number of cycles to return (default: 100)
## Returns
- `{:ok, [cycle]}` - List of cycles found
- `{:error, reason}` - Error if analysis fails
## Examples
# Find all module-level cycles
{:ok, cycles} = find_cycles()
# Find function-level cycles (minimum length 3)
{:ok, cycles} = find_cycles(scope: :function, min_cycle_length: 3)
"""
@spec find_cycles(keyword()) :: {:ok, [[module_name() | function_ref()]]} | {:error, term()}
def find_cycles(opts \\ []) do
scope = Keyword.get(opts, :scope, :module)
min_length = Keyword.get(opts, :min_cycle_length, 2)
limit = Keyword.get(opts, :limit, 100)
try do
# Build adjacency list based on scope
adjacency = build_dependency_adjacency(scope)
# Find all cycles using DFS
nodes = Map.keys(adjacency)
cycles = find_all_cycles(nodes, adjacency, min_length, limit)
{:ok, cycles}
rescue
e ->
Logger.error("Failed to detect cycles: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
end
# Module Analysis
@doc """
Analyzes a module comprehensively.
Provides complete dependency analysis for a single module including:
- Coupling metrics (afferent, efferent, instability)
- Direct dependencies and dependents
- Circular dependency involvement
- God module status
- Function count and complexity indicators
## Parameters
- `module`: Module name atom
- `opts`: Keyword list of options
- `:include_transitive` - Include transitive dependencies (default: false)
- `:include_functions` - Include function list (default: false)
- `:ai_insights` - Use AI for architectural insights (default: from config)
## Returns
- `{:ok, analysis}` - Comprehensive module analysis map
- `{:error, reason}` - Error if module not found or analysis fails
## Analysis Map Structure
```elixir
%{
module: ModuleName,
exists: true,
coupling: %{afferent: 5, efferent: 3, instability: 0.375},
dependencies: [ModuleA, ModuleB],
dependents: [ModuleC, ModuleD],
function_count: 12,
in_cycles: [[ModuleA, ModuleB, ModuleName]],
is_god_module: false,
functions: [...] # if include_functions: true
}
```
## Examples
{:ok, analysis} = analyze_module(MyModule)
{:ok, analysis} = analyze_module(MyModule, include_transitive: true, include_functions: true)
"""
@spec analyze_module(module_name(), keyword()) :: {:ok, map()} | {:error, term()}
def analyze_module(module, opts \\ []) do
include_transitive = Keyword.get(opts, :include_transitive, false)
include_functions = Keyword.get(opts, :include_functions, false)
ai_insights = Keyword.get(opts, :ai_insights)
case Store.find_node(:module, module) do
nil ->
{:error, {:module_not_found, module}}
_node ->
try do
# Get coupling metrics
{:ok, coupling} = coupling_metrics(module, include_transitive: include_transitive)
# Get direct dependencies and dependents
dependencies = get_direct_module_dependencies(module)
dependents = get_direct_module_dependents(module)
# Get function information
functions = get_module_functions(module)
function_count = length(functions)
# Check if in any cycles
{:ok, all_cycles} = find_cycles(scope: :module)
in_cycles = Enum.filter(all_cycles, fn cycle -> module in cycle end)
# Check if God module (threshold: 15)
is_god_module = coupling.afferent + coupling.efferent >= 15
analysis = %{
module: module,
exists: true,
coupling: coupling,
dependencies: dependencies,
dependents: dependents,
function_count: function_count,
in_cycles: in_cycles,
is_god_module: is_god_module,
transitive: include_transitive
}
analysis =
if include_functions do
Map.put(analysis, :functions, format_functions(functions))
else
analysis
end
# Optionally add AI insights
analysis = maybe_add_ai_insights(analysis, ai_insights, opts)
{:ok, analysis}
rescue
e ->
Logger.error("Failed to analyze module #{module}: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
end
end
# Coupling Metrics
@doc """
Calculates coupling metrics for a module.
Coupling metrics:
- **Afferent coupling (Ca)**: Number of modules that depend on this module (incoming)
- **Efferent coupling (Ce)**: Number of modules this module depends on (outgoing)
- **Instability (I)**: Ce / (Ca + Ce) - ranges from 0 (stable) to 1 (unstable)
## Parameters
- `module`: Module name atom
- `opts`: Keyword list of options
- `:include_transitive` - Include transitive dependencies (default: false)
## Returns
- `{:ok, metrics}` - Coupling metrics map
- `{:error, reason}` - Error if module not found or analysis fails
## Examples
{:ok, metrics} = coupling_metrics(MyModule)
# => %{afferent: 5, efferent: 3, instability: 0.375}
"""
@spec coupling_metrics(module_name(), keyword()) ::
{:ok, coupling_metrics()} | {:error, term()}
def coupling_metrics(module, opts \\ []) do
include_transitive = Keyword.get(opts, :include_transitive, false)
case Store.find_node(:module, module) do
nil ->
{:error, {:module_not_found, module}}
_node ->
try do
metrics =
if include_transitive do
calculate_transitive_coupling(module)
else
calculate_direct_coupling(module)
end
{:ok, metrics}
rescue
e ->
Logger.error("Failed to calculate coupling for #{module}: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
end
end
@doc """
Calculates coupling metrics for all modules in the project.
Returns a map of module => coupling_metrics.
## Parameters
- `opts`: Keyword list of options
- `:include_transitive` - Include transitive dependencies (default: false)
- `:sort_by` - Sort by `:instability`, `:afferent`, `:efferent`, or `:name` (default: `:instability`)
- `:descending` - Sort in descending order (default: true)
## Returns
- `{:ok, [{module, metrics}]}` - List of module-metrics tuples, sorted
## Examples
{:ok, all_metrics} = all_coupling_metrics(sort_by: :instability)
"""
@spec all_coupling_metrics(keyword()) ::
{:ok, [{module_name(), coupling_metrics()}]} | {:error, term()}
def all_coupling_metrics(opts \\ []) do
include_transitive = Keyword.get(opts, :include_transitive, false)
sort_by = Keyword.get(opts, :sort_by, :instability)
descending = Keyword.get(opts, :descending, true)
try do
modules =
Store.list_nodes(:module, :infinity)
|> Enum.map(& &1.id)
metrics_list =
modules
|> Enum.map(fn module ->
{:ok, metrics} = coupling_metrics(module, include_transitive: include_transitive)
{module, metrics}
end)
|> sort_coupling_metrics(sort_by, descending)
{:ok, metrics_list}
rescue
e ->
Logger.error("Failed to calculate all coupling metrics: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
end
# Dead Code / Unused Module Detection
@doc """
Finds modules that are not referenced by any other module.
A module is considered unused if:
- No functions in the module are called by other modules
- The module is not imported/required/used by other modules
- The module is not an entry point (tests, mix tasks, etc.)
## Parameters
- `opts`: Keyword list of options
- `:exclude_tests` - Exclude test modules from results (default: true)
- `:exclude_mix_tasks` - Exclude Mix tasks (default: true)
## Returns
- `{:ok, [module_name]}` - List of potentially unused modules
- `{:error, reason}` - Error if analysis fails
## Examples
{:ok, unused} = find_unused()
"""
@spec find_unused(keyword()) :: {:ok, [module_name()]} | {:error, term()}
def find_unused(opts \\ []) do
exclude_tests = Keyword.get(opts, :exclude_tests, true)
exclude_mix_tasks = Keyword.get(opts, :exclude_mix_tasks, true)
try do
# Get all modules
all_modules =
Store.list_nodes(:module, :infinity)
|> Enum.map(& &1.id)
|> MapSet.new()
# Get modules that have incoming edges (are referenced)
referenced_modules =
Store.list_nodes(:module, :infinity)
|> Enum.flat_map(fn %{id: module} ->
# Check for incoming imports
imports = Store.get_incoming_edges({:module, module}, :imports)
# Check for incoming calls to functions in this module
functions = get_module_functions(module)
calls =
Enum.flat_map(functions, fn func ->
Store.get_incoming_edges(func, :calls)
end)
if Enum.empty?(imports) && Enum.empty?(calls) do
[]
else
[module]
end
end)
|> MapSet.new()
# Find unreferenced modules
unused = MapSet.difference(all_modules, referenced_modules) |> MapSet.to_list()
# Apply filters
unused =
unused
|> filter_if(exclude_tests, &(!test_module?(&1)))
|> filter_if(exclude_mix_tasks, &(!mix_task?(&1)))
{:ok, unused}
rescue
e ->
Logger.error("Failed to find unused modules: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
end
# God Module Detection
@doc """
Finds "God modules" - modules with high coupling.
God modules are modules that have excessive dependencies or dependents,
indicating potential design issues.
## Parameters
- `threshold`: Minimum total coupling (afferent + efferent) to be considered a God module
- `opts`: Keyword list of options
- `:sort_by` - Sort by `:total`, `:afferent`, `:efferent`, or `:instability` (default: `:total`)
## Returns
- `{:ok, [{module, metrics}]}` - List of God modules with their metrics
## Examples
# Find modules with total coupling >= 20
{:ok, god_modules} = find_god_modules(20)
"""
@spec find_god_modules(non_neg_integer(), keyword()) ::
{:ok, [{module_name(), coupling_metrics()}]} | {:error, term()}
def find_god_modules(threshold, opts \\ []) do
sort_by = Keyword.get(opts, :sort_by, :total)
case all_coupling_metrics(sort_by: sort_by) do
{:ok, all_metrics} ->
god_modules =
all_metrics
|> Enum.filter(fn {_module, metrics} ->
metrics.afferent + metrics.efferent >= threshold
end)
{:ok, god_modules}
error ->
error
end
end
# Decoupling Suggestions
@doc """
Generates suggestions for decoupling the codebase.
Analyzes the dependency graph to find:
- Circular dependencies that should be broken
- God modules that should be split
- Highly unstable modules that need stabilization
- Unused modules that can be removed
## Returns
- `{:ok, [suggestion]}` - List of decoupling suggestions
- `{:error, reason}` - Error if analysis fails
## Examples
{:ok, suggestions} = decoupling_suggestions()
"""
@spec decoupling_suggestions(keyword()) :: {:ok, [suggestion()]} | {:error, term()}
def decoupling_suggestions(_opts \\ []) do
suggestions = []
# Detect circular dependencies
suggestions =
case find_cycles(scope: :module) do
{:ok, [_ | _] = cycles} ->
cycle_suggestions =
cycles
|> Enum.map(fn cycle ->
%{
type: :circular_dependency,
severity: severity_for_cycle(cycle),
description: "Circular dependency detected: #{format_cycle(cycle)}",
entities: cycle,
metadata: %{cycle_length: length(cycle)}
}
end)
suggestions ++ cycle_suggestions
_ ->
suggestions
end
# Find God modules
suggestions =
case find_god_modules(15) do
{:ok, [_ | _] = god_modules} ->
god_suggestions =
god_modules
|> Enum.map(fn {module, metrics} ->
%{
type: :god_module,
severity: severity_for_coupling(metrics),
description:
"Module #{module} has high coupling (#{metrics.afferent + metrics.efferent} total dependencies)",
entities: [module],
metadata: metrics
}
end)
suggestions ++ god_suggestions
_ ->
suggestions
end
# Find highly unstable modules
suggestions =
case all_coupling_metrics() do
{:ok, all_metrics} ->
unstable_suggestions =
all_metrics
|> Enum.filter(fn {_module, metrics} -> metrics.instability > 0.8 end)
|> Enum.map(fn {module, metrics} ->
%{
type: :unstable_module,
severity: :medium,
description:
"Module #{module} is highly unstable (I=#{Float.round(metrics.instability, 2)})",
entities: [module],
metadata: metrics
}
end)
suggestions ++ unstable_suggestions
_ ->
suggestions
end
# Find unused modules
suggestions =
case find_unused() do
{:ok, [_ | _] = unused} ->
unused_suggestions =
unused
|> Enum.map(fn module ->
%{
type: :unused_module,
severity: :low,
description: "Module #{module} appears to be unused and could be removed",
entities: [module],
metadata: %{}
}
end)
suggestions ++ unused_suggestions
_ ->
suggestions
end
{:ok, suggestions}
rescue
e ->
Logger.error("Failed to generate decoupling suggestions: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
# Private functions
# Build adjacency list for dependency analysis
defp build_dependency_adjacency(:module) do
# Build module-level adjacency from :imports edges and function calls
modules = Store.list_nodes(:module, :infinity) |> Enum.map(& &1.id)
Enum.reduce(modules, %{}, fn module, acc ->
# Get direct imports
imports = Store.get_outgoing_edges({:module, module}, :imports)
import_targets = Enum.map(imports, fn %{to: {:module, target}} -> target end)
# Get calls from functions in this module to other modules
call_targets =
get_module_functions(module)
|> Enum.flat_map(fn func ->
Store.get_outgoing_edges(func, :calls)
end)
|> Enum.map(fn %{to: {:function, target_module, _, _}} -> target_module end)
|> Enum.uniq()
|> Enum.reject(&(&1 == module))
all_targets = (import_targets ++ call_targets) |> Enum.uniq()
Map.put(acc, module, all_targets)
end)
end
defp build_dependency_adjacency(:function) do
# Build function-level adjacency from :calls edges
functions = Store.list_nodes(:function, :infinity) |> Enum.map(& &1.id)
Enum.reduce(functions, %{}, fn {module, name, arity}, acc ->
func_id = {:function, module, name, arity}
calls = Store.get_outgoing_edges(func_id, :calls)
targets = Enum.map(calls, fn %{to: target} -> target end)
Map.put(acc, func_id, targets)
end)
end
# Find all cycles using DFS
defp find_all_cycles(nodes, adjacency, min_length, limit) do
{cycles, _} =
Enum.reduce_while(nodes, {[], 0}, fn node, {cycles_acc, count} ->
if count >= limit do
{:halt, {cycles_acc, count}}
else
node_cycles = find_cycles_from_node(node, adjacency, min_length)
new_count = count + length(node_cycles)
{:cont, {cycles_acc ++ node_cycles, new_count}}
end
end)
# Deduplicate cycles (same cycle can be found from different starting points)
cycles
|> Enum.map(&normalize_cycle/1)
|> Enum.uniq()
|> Enum.take(limit)
end
# Find cycles starting from a specific node using DFS
defp find_cycles_from_node(start_node, adjacency, min_length) do
find_cycles_dfs(start_node, adjacency, [start_node], MapSet.new([start_node]), start_node, [])
|> Enum.filter(fn cycle -> length(cycle) >= min_length end)
end
# DFS to detect cycles
defp find_cycles_dfs(current, adjacency, path, visited, target, cycles) do
neighbors = Map.get(adjacency, current, [])
Enum.reduce(neighbors, cycles, fn neighbor, acc ->
cond do
# Found cycle back to target
neighbor == target && length(path) >= 2 ->
[path | acc]
# Already visited, skip
MapSet.member?(visited, neighbor) ->
acc
# Continue DFS
true ->
new_path = path ++ [neighbor]
new_visited = MapSet.put(visited, neighbor)
find_cycles_dfs(neighbor, adjacency, new_path, new_visited, target, acc)
end
end)
end
# Normalize cycle to start with the smallest element (for deduplication)
defp normalize_cycle(cycle) do
min_idx = Enum.find_index(cycle, &(&1 == Enum.min(cycle)))
{first, second} = Enum.split(cycle, min_idx)
second ++ first
end
# Calculate direct coupling metrics
defp calculate_direct_coupling(module) do
# Afferent: modules that depend on this module
afferent = count_module_dependents(module)
# Efferent: modules this module depends on
efferent = count_module_dependencies(module)
# Instability
total = afferent + efferent
instability = if total == 0, do: 0.0, else: efferent / total
%{
afferent: afferent,
efferent: efferent,
instability: instability
}
end
# Calculate transitive coupling metrics
defp calculate_transitive_coupling(module) do
# For transitive, we need to walk the graph
# Afferent: all modules that transitively depend on this module
afferent_set = find_transitive_dependents(module)
# Efferent: all modules this module transitively depends on
efferent_set = find_transitive_dependencies(module)
afferent = MapSet.size(afferent_set)
efferent = MapSet.size(efferent_set)
total = afferent + efferent
instability = if total == 0, do: 0.0, else: efferent / total
%{
afferent: afferent,
efferent: efferent,
instability: instability
}
end
# Count direct dependents (modules that depend on this module)
defp count_module_dependents(module) do
# Check imports to this module
import_dependents =
Store.get_incoming_edges({:module, module}, :imports)
|> Enum.map(fn %{from: {:module, source}} -> source end)
|> Enum.uniq()
# Check calls to functions in this module from other modules
call_dependents =
get_module_functions(module)
|> Enum.flat_map(fn func ->
Store.get_incoming_edges(func, :calls)
end)
|> Enum.map(fn %{from: {:function, source_module, _, _}} -> source_module end)
|> Enum.uniq()
|> Enum.reject(&(&1 == module))
(import_dependents ++ call_dependents) |> Enum.uniq() |> length()
end
# Count direct dependencies (modules this module depends on)
defp count_module_dependencies(module) do
# Check imports from this module
import_deps =
Store.get_outgoing_edges({:module, module}, :imports)
|> Enum.map(fn %{to: {:module, target}} -> target end)
|> Enum.uniq()
# Check calls from functions in this module to other modules
call_deps =
get_module_functions(module)
|> Enum.flat_map(fn func ->
Store.get_outgoing_edges(func, :calls)
end)
|> Enum.map(fn %{to: {:function, target_module, _, _}} -> target_module end)
|> Enum.uniq()
|> Enum.reject(&(&1 == module))
(import_deps ++ call_deps) |> Enum.uniq() |> length()
end
# Find all modules that transitively depend on this module (BFS)
defp find_transitive_dependents(module) do
initial = [module]
visited = MapSet.new([module])
find_transitive_dependents_bfs(initial, visited, MapSet.new())
end
defp find_transitive_dependents_bfs([], _visited, dependents), do: dependents
defp find_transitive_dependents_bfs([current | rest], visited, dependents) do
# Find modules that directly depend on current
direct_dependents =
get_module_functions(current)
|> Enum.flat_map(fn func ->
Store.get_incoming_edges(func, :calls)
end)
|> Enum.map(fn %{from: {:function, source_module, _, _}} -> source_module end)
|> Enum.uniq()
|> Enum.reject(&(&1 == current))
# Add to dependents and queue unvisited
new_dependents = MapSet.union(dependents, MapSet.new(direct_dependents))
unvisited = Enum.reject(direct_dependents, &MapSet.member?(visited, &1))
new_visited = MapSet.union(visited, MapSet.new(unvisited))
find_transitive_dependents_bfs(rest ++ unvisited, new_visited, new_dependents)
end
# Find all modules this module transitively depends on (BFS)
defp find_transitive_dependencies(module) do
initial = [module]
visited = MapSet.new([module])
find_transitive_dependencies_bfs(initial, visited, MapSet.new())
end
defp find_transitive_dependencies_bfs([], _visited, dependencies), do: dependencies
defp find_transitive_dependencies_bfs([current | rest], visited, dependencies) do
# Find modules current directly depends on
direct_deps =
get_module_functions(current)
|> Enum.flat_map(fn func ->
Store.get_outgoing_edges(func, :calls)
end)
|> Enum.map(fn %{to: {:function, target_module, _, _}} -> target_module end)
|> Enum.uniq()
|> Enum.reject(&(&1 == current))
# Add to dependencies and queue unvisited
new_dependencies = MapSet.union(dependencies, MapSet.new(direct_deps))
unvisited = Enum.reject(direct_deps, &MapSet.member?(visited, &1))
new_visited = MapSet.union(visited, MapSet.new(unvisited))
find_transitive_dependencies_bfs(rest ++ unvisited, new_visited, new_dependencies)
end
# Get all functions in a module
defp get_module_functions(module) do
Store.list_nodes(:function, :infinity)
|> Enum.filter(fn %{id: {mod, _name, _arity}} -> mod == module end)
|> Enum.map(fn %{id: {mod, name, arity}} -> {:function, mod, name, arity} end)
end
# Get direct module dependencies (modules this module depends on)
defp get_direct_module_dependencies(module) do
# Check imports from this module
import_deps =
Store.get_outgoing_edges({:module, module}, :imports)
|> Enum.map(fn %{to: {:module, target}} -> target end)
# Check calls from functions in this module to other modules
call_deps =
get_module_functions(module)
|> Enum.flat_map(fn func ->
Store.get_outgoing_edges(func, :calls)
end)
|> Enum.map(fn %{to: {:function, target_module, _, _}} -> target_module end)
|> Enum.reject(&(&1 == module))
(import_deps ++ call_deps) |> Enum.uniq() |> Enum.sort()
end
# Get direct module dependents (modules that depend on this module)
defp get_direct_module_dependents(module) do
# Check imports to this module
import_dependents =
Store.get_incoming_edges({:module, module}, :imports)
|> Enum.map(fn %{from: {:module, source}} -> source end)
# Check calls to functions in this module from other modules
call_dependents =
get_module_functions(module)
|> Enum.flat_map(fn func ->
Store.get_incoming_edges(func, :calls)
end)
|> Enum.map(fn %{from: {:function, source_module, _, _}} -> source_module end)
|> Enum.reject(&(&1 == module))
(import_dependents ++ call_dependents) |> Enum.uniq() |> Enum.sort()
end
# Format functions for output
defp format_functions(functions) do
functions
|> Enum.map(fn {:function, module, name, arity} ->
%{module: module, name: name, arity: arity}
end)
|> Enum.sort_by(fn %{name: name, arity: arity} -> {name, arity} end)
end
# Helper: determine severity for cycle
defp severity_for_cycle(cycle) do
length = length(cycle)
cond do
length >= 5 -> :high
length >= 3 -> :medium
true -> :low
end
end
# Helper: determine severity for coupling
defp severity_for_coupling(%{afferent: a, efferent: e}) do
total = a + e
cond do
total >= 30 -> :high
total >= 20 -> :medium
true -> :low
end
end
# Helper: format cycle for display
defp format_cycle(cycle) do
Enum.map_join(cycle, " -> ", &format_entity/1)
end
# Helper: format entity for display
defp format_entity({:function, module, name, arity}), do: "#{module}.#{name}/#{arity}"
defp format_entity(module) when is_atom(module), do: inspect(module)
defp format_entity(other), do: inspect(other)
# Helper: sort coupling metrics
defp sort_coupling_metrics(metrics_list, sort_by, descending) do
sorted =
case sort_by do
:name ->
Enum.sort_by(metrics_list, fn {module, _} -> module end)
:instability ->
Enum.sort_by(metrics_list, fn {_, metrics} -> metrics.instability end)
:afferent ->
Enum.sort_by(metrics_list, fn {_, metrics} -> metrics.afferent end)
:efferent ->
Enum.sort_by(metrics_list, fn {_, metrics} -> metrics.efferent end)
_ ->
metrics_list
end
if descending && sort_by != :name do
Enum.reverse(sorted)
else
sorted
end
end
# Helper: filter list if condition is true
defp filter_if(list, true, filter_fn), do: Enum.filter(list, filter_fn)
defp filter_if(list, false, _filter_fn), do: list
# Helper: check if module is a test module
defp test_module?(module) do
module_str = to_string(module)
String.ends_with?(module_str, "Test") || String.contains?(module_str, ".Test.")
end
# Helper: check if module is a Mix task
defp mix_task?(module) do
module_str = to_string(module)
String.starts_with?(module_str, "Mix.Tasks.")
end
# Conditionally add AI insights to analysis
defp maybe_add_ai_insights(analysis, ai_insights, opts) do
# Only use AI if explicitly enabled or if config enables it
use_ai =
case ai_insights do
true -> true
false -> false
nil -> AIInsights.enabled?(opts)
end
if use_ai do
# Build coupling data for AI
coupling_data = %{
module: analysis.module,
coupling_in: analysis.coupling.afferent,
coupling_out: analysis.coupling.efferent,
instability: analysis.coupling.instability,
dependencies: analysis.dependencies,
dependents: analysis.dependents
}
case AIInsights.analyze_coupling(coupling_data, opts) do
{:ok, insights} ->
Logger.info("Added AI insights for #{analysis.module}")
Map.put(analysis, :ai_insights, insights)
{:error, reason} ->
Logger.warning("Failed to get AI insights for #{analysis.module}: #{inspect(reason)}")
analysis
end
else
analysis
end
end
@doc """
Analyzes dependencies for all modules in the knowledge graph.
Convenience function that returns a comprehensive dependency analysis.
## Examples
{:ok, analysis} = DependencyGraph.analyze_all_dependencies()
analysis.modules # => %{MyModule => %{dependencies: [...], ...}}
"""
@spec analyze_all_dependencies() :: {:ok, map()} | {:error, term()}
def analyze_all_dependencies do
modules = Store.list_nodes(:module)
module_analyses =
Enum.reduce(modules, %{}, fn node, acc ->
case analyze_module(node.id) do
{:ok, analysis} -> Map.put(acc, node.id, analysis)
{:error, _} -> acc
end
end)
result = %{
modules: module_analyses,
total_modules: map_size(module_analyses),
timestamp: DateTime.utc_now()
}
{:ok, result}
end
end