Current section
Files
Jump to
Current section
Files
lib/ragex/analysis/dead_code.ex
defmodule Ragex.Analysis.DeadCode do
@moduledoc """
Dead code detection for identifying unused functions and code.
Provides two complementary approaches:
## Interprocedural Analysis (Graph-based)
Analyzes the knowledge graph to find:
- Unused public functions (exported but never called externally)
- Unused private functions (never called within module)
- Functions with low confidence of being dead (potential entry points, callbacks)
## Intraprocedural Analysis (AST-based via Metastatic)
Analyzes individual files for:
- Unreachable code after early returns
- Constant conditionals with unreachable branches
- Other dead code patterns within function bodies
Provides confidence scores to help distinguish between truly dead code and
potential entry points (callbacks, GenServer handlers, etc.).
"""
alias MetaCredo.Analysis.DeadCode, as: MetaDeadCode
alias Ragex.{Analysis.DeadCode.AIRefiner, Analysis.MetastaticBridge, Graph.Store}
require Logger
@type function_ref ::
{:function, module(), atom(), non_neg_integer()}
| %{type: :function, module: module(), name: atom(), arity: non_neg_integer()}
@type confidence :: float()
@type dead_function :: %{
function: function_ref(),
confidence: confidence(),
reason: String.t(),
visibility: :public | :private,
module: module(),
metadata: map()
}
@type suggestion :: %{
type: :remove_function | :review_function | :potential_callback,
confidence: confidence(),
target: function_ref(),
description: String.t(),
metadata: map()
}
# Known callback patterns that should not be flagged as dead code
@callback_patterns [
# GenServer callbacks
{:init, 1},
{:handle_call, 3},
{:handle_cast, 2},
{:handle_info, 2},
{:terminate, 2},
{:code_change, 3},
# GenStage callbacks
{:handle_demand, 2},
{:handle_events, 3},
{:handle_subscribe, 4},
{:handle_cancel, 3},
# Supervisor callbacks
{:start_link, 1},
# Phoenix callbacks
{:mount, 3},
{:handle_event, 3},
{:handle_params, 3},
{:render, 1},
# Test callbacks
{:setup, 1},
{:setup_all, 1},
# Mix tasks
{:run, 1},
# Application callbacks
{:start, 2},
{:stop, 1}
]
# Entry point detection patterns
# Note: These are defined as a function to avoid Elixir 1.18 attribute escaping issues
defp entry_point_patterns do
[
~r/^main$/,
~r/^run$/,
~r/^start/,
~r/^handle_/,
~r/^mount$/,
~r/^render$/,
~r/^test_/
]
end
@doc """
Finds unused public (exported) functions.
Public functions with no callers are potentially dead code, but may be:
- Entry points (main, CLI commands)
- Callbacks (GenServer, Supervisor, Phoenix)
- API functions not yet used
- Test helpers
Returns functions with confidence scores indicating likelihood of being dead.
## Parameters
- `opts`: Keyword list of options
- `:min_confidence` - Minimum confidence threshold (0.0-1.0, default: 0.5)
- `:exclude_tests` - Exclude test modules (default: true)
- `:include_callbacks` - Include potential callbacks (default: false)
- `:ai_refine` - Use AI to refine confidence scores (default: from config)
## Returns
- `{:ok, [dead_function]}` - List of potentially unused functions with confidence scores
- `{:error, reason}` - Error if analysis fails
## Examples
# Find unused exports with high confidence
{:ok, dead} = find_unused_exports(min_confidence: 0.8)
# Include potential callbacks
{:ok, all} = find_unused_exports(include_callbacks: true, min_confidence: 0.3)
"""
@spec find_unused_exports(keyword()) :: {:ok, [dead_function()]} | {:error, term()}
def find_unused_exports(opts \\ []) do
# Check if dead code detection is disabled
if dead_code_enabled?() do
min_confidence = Keyword.get(opts, :min_confidence, get_default_min_confidence())
exclude_tests = Keyword.get(opts, :exclude_tests, true)
include_callbacks = Keyword.get(opts, :include_callbacks, false)
ai_refine = Keyword.get(opts, :ai_refine)
try do
functions = Store.list_nodes(:function, :infinity)
dead_functions =
functions
|> Enum.filter(fn func ->
public_function?(func) && should_check_module?(func, exclude_tests)
end)
|> Enum.map(&analyze_function/1)
|> Enum.filter(fn result ->
result != nil &&
(include_callbacks || result.confidence >= min_confidence) &&
result.confidence >= min_confidence
end)
|> maybe_refine_with_ai(ai_refine, opts)
|> Enum.sort_by(& &1.confidence, :desc)
{:ok, dead_functions}
rescue
e ->
Logger.error("Failed to find unused exports: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
else
{:ok, []}
end
end
@doc """
Finds unused private functions.
Private functions with no callers within their module are likely dead code.
Higher confidence than public functions since private functions are not part of the API.
## Parameters
- `opts`: Keyword list of options
- `:min_confidence` - Minimum confidence threshold (default: 0.7)
- `:exclude_tests` - Exclude test modules (default: true)
- `:ai_refine` - Use AI to refine confidence scores (default: from config)
## Returns
- `{:ok, [dead_function]}` - List of unused private functions
- `{:error, reason}` - Error if analysis fails
## Examples
{:ok, dead} = find_unused_private()
"""
@spec find_unused_private(keyword()) :: {:ok, [dead_function()]} | {:error, term()}
def find_unused_private(opts \\ []) do
# Check if dead code detection is disabled
if dead_code_enabled?() do
min_confidence = Keyword.get(opts, :min_confidence, 0.7)
exclude_tests = Keyword.get(opts, :exclude_tests, true)
ai_refine = Keyword.get(opts, :ai_refine)
try do
functions = Store.list_nodes(:function, :infinity)
dead_functions =
functions
|> Enum.filter(fn func ->
private_function?(func) && should_check_module?(func, exclude_tests)
end)
|> Enum.map(&analyze_function/1)
|> Enum.filter(fn result ->
result != nil && result.confidence >= min_confidence
end)
|> maybe_refine_with_ai(ai_refine, opts)
|> Enum.sort_by(& &1.confidence, :desc)
{:ok, dead_functions}
rescue
e ->
Logger.error("Failed to find unused private functions: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
else
{:ok, []}
end
end
@doc """
Finds all unused functions (both public and private).
Combines results from `find_unused_exports/1` and `find_unused_private/1`.
## Parameters
- `opts`: Keyword list of options (same as individual functions)
## Returns
- `{:ok, [dead_function]}` - Combined list of unused functions
## Examples
{:ok, all_dead} = find_all_unused(min_confidence: 0.6)
"""
@spec find_all_unused(keyword()) :: {:ok, [dead_function()]} | {:error, term()}
def find_all_unused(opts \\ []) do
with {:ok, exports} <- find_unused_exports(opts),
{:ok, private} <- find_unused_private(opts) do
all = (exports ++ private) |> Enum.sort_by(& &1.confidence, :desc)
{:ok, all}
end
end
@doc """
Finds unused functions within a specific module.
Analyzes all functions (public and private) in the specified module to identify
potentially dead code. This is useful for focused refactoring of a single module.
## Parameters
- `module`: Module name atom
- `opts`: Keyword list of options
- `:min_confidence` - Minimum confidence threshold (default: 0.5)
- `:include_callbacks` - Include potential callbacks (default: false)
- `:visibility` - Filter by `:public`, `:private`, or `:all` (default: `:all`)
## Returns
- `{:ok, [dead_function]}` - List of unused functions in the module
- `{:error, reason}` - Error if module not found or analysis fails
## Examples
# Find all dead code in MyModule
{:ok, dead} = find_in_module(MyModule)
# Find only dead private functions with high confidence
{:ok, dead_private} = find_in_module(MyModule, visibility: :private, min_confidence: 0.8)
# Include potential callbacks
{:ok, all} = find_in_module(MyModule, include_callbacks: true, min_confidence: 0.3)
"""
@spec find_in_module(module(), keyword()) :: {:ok, [dead_function()]} | {:error, term()}
def find_in_module(module, opts \\ []) do
min_confidence = Keyword.get(opts, :min_confidence, 0.5)
include_callbacks = Keyword.get(opts, :include_callbacks, false)
visibility_filter = Keyword.get(opts, :visibility, :all)
# Verify module exists
case Store.find_node(:module, module) do
nil ->
{:error, {:module_not_found, module}}
_node ->
try do
# Get all functions in the module
functions =
Store.list_nodes(:function, :infinity)
|> Enum.filter(fn %{id: {mod, _name, _arity}} -> mod == module end)
# Filter by visibility if specified
functions =
case visibility_filter do
:public -> Enum.filter(functions, &public_function?/1)
:private -> Enum.filter(functions, &private_function?/1)
:all -> functions
_ -> functions
end
# Analyze each function
dead_functions =
functions
|> Enum.map(&analyze_function/1)
|> Enum.filter(fn result ->
result != nil &&
(include_callbacks || result.confidence >= min_confidence) &&
result.confidence >= min_confidence
end)
|> Enum.sort_by(& &1.confidence, :desc)
{:ok, dead_functions}
rescue
e ->
Logger.error("Failed to find dead code in module #{module}: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
end
end
@doc """
Analyzes a file for intraprocedural dead code patterns.
Uses Metastatic's AST-level analysis to detect:
- Unreachable code after early returns
- Constant conditionals (if true/false) with unreachable branches
- Other dead code patterns within function bodies
This is complementary to the interprocedural analysis (`find_unused_exports/1`, etc.)
which detects unused functions based on the call graph.
## Parameters
- `file_path` - Path to the file to analyze
- `opts` - Keyword list of options
- `:min_confidence` - Minimum confidence level (`:high`, `:medium`, `:low`, default: `:low`)
## Returns
- `{:ok, result}` - Metastatic.Analysis.DeadCode.Result struct
- `{:error, reason}` - Error if analysis fails
## Examples
iex> {:ok, result} = analyze_file("lib/my_module.ex")
iex> result.has_dead_code?
true
"""
@spec analyze_file(String.t(), keyword()) ::
{:ok, MetaCredo.Analysis.DeadCode.Result.t()} | {:error, term()}
def analyze_file(file_path, opts \\ []) do
min_confidence = Keyword.get(opts, :min_confidence, :low)
case MetastaticBridge.parse_file(file_path) do
{:ok, document} ->
# Use Metastatic's dead code analysis
MetaDeadCode.analyze(document, min_confidence: min_confidence)
{:error, reason} ->
Logger.warning("Failed to parse file #{file_path}: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Analyzes multiple files for intraprocedural dead code patterns.
Batch version of `analyze_file/2` that processes multiple files in parallel.
## Parameters
- `file_paths` - List of file paths to analyze
- `opts` - Keyword list of options (same as `analyze_file/2`)
## Returns
- `{:ok, results_map}` - Map of file_path => result
- `{:error, reason}` - Error if analysis fails
## Examples
iex> {:ok, results} = analyze_files(["lib/a.ex", "lib/b.ex"])
iex> is_map(results)
true
"""
@spec analyze_files([String.t()], keyword()) :: {:ok, %{String.t() => any()}} | {:error, term()}
def analyze_files(file_paths, opts \\ []) when is_list(file_paths) do
results =
file_paths
|> Task.async_stream(
fn path ->
case analyze_file(path, opts) do
{:ok, result} -> {path, result}
{:error, reason} -> {path, {:error, reason}}
end
end,
max_concurrency: System.schedulers_online() * 2,
timeout: 30_000
)
|> Enum.map(fn {:ok, result} -> result end)
|> Map.new()
{:ok, results}
rescue
e ->
Logger.error("Failed to analyze files: #{inspect(e)}")
{:error, {:analysis_failed, Exception.message(e)}}
end
@doc """
Finds unused modules (modules with no external references).
Delegates to `DependencyGraph.find_unused/1` for consistency.
## Parameters
- `opts`: Keyword list of options (passed to DependencyGraph)
## Returns
- `{:ok, [module_name]}` - List of unused modules
- `{:error, reason}` - Error if analysis fails
"""
@spec find_unused_modules(keyword()) :: {:ok, [module()]} | {:error, term()}
def find_unused_modules(opts \\ []) do
# Delegate to DependencyGraph for consistency
alias Ragex.Analysis.DependencyGraph
DependencyGraph.find_unused(opts)
end
@doc """
Generates removal suggestions based on dead code analysis.
Categorizes dead code into:
- Safe to remove (high confidence)
- Review recommended (medium confidence)
- Potential callbacks (low confidence, may be entry points)
## Parameters
- `opts`: Keyword list of options
- `:min_confidence` - Minimum confidence for suggestions (default: 0.5)
- `:group_by_module` - Group suggestions by module (default: true)
## Returns
- `{:ok, [suggestion]}` - List of removal suggestions
- `{:error, reason}` - Error if analysis fails
## Examples
{:ok, suggestions} = removal_suggestions()
# Process suggestions...
"""
@spec removal_suggestions(keyword()) :: {:ok, [suggestion()]} | {:error, term()}
def removal_suggestions(opts \\ []) do
min_confidence = Keyword.get(opts, :min_confidence, 0.5)
group_by_module = Keyword.get(opts, :group_by_module, true)
with {:ok, dead_functions} <- find_all_unused(opts) do
suggestions =
dead_functions
|> Enum.map(&create_suggestion/1)
|> Enum.filter(fn s -> s.confidence >= min_confidence end)
suggestions =
if group_by_module do
suggestions
|> Enum.group_by(fn s -> extract_module(s.target) end)
|> Enum.flat_map(fn {_module, module_suggestions} ->
if length(module_suggestions) > 1 do
# Create a summary suggestion for the module
create_module_summary_suggestion(module_suggestions)
else
module_suggestions
end
end)
else
suggestions
end
{:ok, suggestions}
end
end
@doc """
Calculates a confidence score for dead code detection.
Takes into account:
- Function name patterns (callbacks, entry points)
- Visibility (public vs private)
- Number of callers
- Module characteristics (test, Mix task, etc.)
## Returns
- Float between 0.0 (definitely not dead) and 1.0 (definitely dead)
"""
@spec confidence_score(function_ref(), map()) :: confidence()
def confidence_score(func_ref, metadata) do
{module, name, arity} = extract_func_parts(func_ref)
# Base confidence based on callers
caller_count = count_callers(func_ref)
base_confidence = if caller_count == 0, do: 1.0, else: 0.0
# Adjust for visibility
visibility = Map.get(metadata, :visibility, :public)
visibility_modifier = if visibility == :private, do: 0.0, else: -0.2
# Adjust for callback patterns
callback_modifier =
if callback_pattern?(name, arity) do
-0.7
else
0.0
end
# Adjust for entry point patterns
entry_point_modifier =
if entry_point_pattern?(name) do
-0.5
else
0.0
end
# Adjust for module type
module_modifier =
cond do
test_module?(module) -> -0.3
mix_task?(module) -> -0.4
true -> 0.0
end
# Calculate final confidence
confidence =
base_confidence + visibility_modifier + callback_modifier + entry_point_modifier +
module_modifier
# Clamp to [0.0, 1.0]
max(0.0, min(1.0, confidence))
end
# Private functions
# Analyze a function to determine if it's dead code
defp analyze_function(%{id: {module, name, arity}, data: metadata}) do
# func_ref = {:function, module, name, arity}
func_ref = %{type: :function, module: module, name: name, arity: arity}
caller_count = count_callers(func_ref)
if caller_count == 0 do
confidence = confidence_score(func_ref, metadata)
visibility = Map.get(metadata, :visibility, :public)
reason =
cond do
confidence > 0.8 -> "No callers found, likely dead code"
confidence > 0.5 -> "No callers found, but may be entry point or callback"
true -> "Potential callback or entry point with no callers"
end
%{
function: func_ref,
confidence: confidence,
reason: reason,
visibility: visibility,
module: module,
metadata: metadata
}
else
nil
end
end
# Count the number of callers for a function
defp count_callers(func_ref) do
# Convert to tuple format for Store lookup
{module, name, arity} = extract_func_parts(func_ref)
tuple_ref = {:function, module, name, arity}
Store.get_incoming_edges(tuple_ref, :calls)
|> length()
end
# Check if function is public
defp public_function?(%{data: metadata}) do
Map.get(metadata, :visibility, :public) == :public
end
# Check if function is private
defp private_function?(%{data: metadata}) do
Map.get(metadata, :visibility, :public) == :private
end
# Check if we should analyze this module
defp should_check_module?(%{id: {module, _name, _arity}}, exclude_tests) do
if exclude_tests do
!test_module?(module)
else
true
end
end
# Check if function matches callback patterns
defp callback_pattern?(name, arity) do
{name, arity} in @callback_patterns
end
# Check if function name matches entry point patterns
defp entry_point_pattern?(name) do
name_str = Atom.to_string(name)
Enum.any?(entry_point_patterns(), fn pattern ->
Regex.match?(pattern, name_str)
end)
end
# 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
# 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
# Extract module, name, arity from function reference
defp extract_func_parts({:function, module, name, arity}), do: {module, name, arity}
defp extract_func_parts(%{type: :function, module: module, name: name, arity: arity}),
do: {module, name, arity}
# Create a suggestion from a dead function
defp create_suggestion(%{function: func_ref, confidence: confidence} = dead_func) do
{module, name, arity} = extract_func_parts(func_ref)
{type, description} =
cond do
confidence > 0.8 ->
{:remove_function,
"Function #{module}.#{name}/#{arity} appears to be dead code and can be removed"}
confidence > 0.5 ->
{:review_function,
"Function #{module}.#{name}/#{arity} has no callers - review whether it's still needed"}
true ->
{:potential_callback,
"Function #{module}.#{name}/#{arity} may be a callback or entry point - verify before removing"}
end
%{
type: type,
confidence: confidence,
target: func_ref,
description: description,
metadata: Map.get(dead_func, :metadata, %{})
}
end
# Extract module from function reference
defp extract_module({:function, module, _name, _arity}), do: module
defp extract_module(%{type: :function, module: module}), do: module
defp extract_module({:module, module}), do: module
# Conditionally refine confidence scores with AI
defp maybe_refine_with_ai(dead_functions, ai_refine, opts) do
# Only use AI if explicitly enabled or if config enables it AND provider is available
use_ai =
case ai_refine do
true -> ai_provider_available?()
false -> false
nil -> AIRefiner.enabled?(opts) && ai_provider_available?()
end
if use_ai && !Enum.empty?(dead_functions) do
Logger.info("Refining #{length(dead_functions)} dead code results with AI")
case AIRefiner.refine_batch(dead_functions, opts) do
{:ok, refined} ->
Logger.info("AI refinement complete")
refined
# {:error, reason} ->
# Logger.warning("AI refinement failed: #{inspect(reason)}, using original results")
# dead_functions
end
else
dead_functions
end
end
# Check if an AI provider is actually available
defp ai_provider_available? do
case Ragex.AI.Registry.get_provider() do
{:ok, _provider} -> true
_ -> false
end
end
# Create a module-level summary suggestion
defp create_module_summary_suggestion(module_suggestions) do
module = extract_module(List.first(module_suggestions).target)
avg_confidence =
Enum.sum(Enum.map(module_suggestions, & &1.confidence)) / length(module_suggestions)
function_count = length(module_suggestions)
high_confidence = Enum.count(module_suggestions, fn s -> s.confidence > 0.8 end)
medium_confidence =
Enum.count(module_suggestions, fn s -> s.confidence > 0.5 && s.confidence <= 0.8 end)
low_confidence = Enum.count(module_suggestions, fn s -> s.confidence <= 0.5 end)
description =
"""
Module #{module} has #{function_count} potentially unused functions:
- #{high_confidence} with high confidence (can be removed)
- #{medium_confidence} requiring review
- #{low_confidence} potential callbacks/entry points
"""
|> String.trim()
[
%{
type: :review_function,
confidence: avg_confidence,
target: {:module, module},
description: description,
metadata: %{
function_count: function_count,
functions: Enum.map(module_suggestions, & &1.target),
breakdown: %{
high_confidence: high_confidence,
medium_confidence: medium_confidence,
low_confidence: low_confidence
}
}
}
]
end
@doc """
Finds all dead code in the knowledge graph.
Convenience function combining unused exports and private functions.
## Examples
{:ok, dead_functions} = DeadCode.find_dead_code()
"""
@spec find_dead_code() :: {:ok, [map()]} | {:error, term()}
def find_dead_code do
with {:ok, exports} <- find_unused_exports(),
{:ok, private} <- find_unused_private() do
{:ok, exports ++ private}
end
end
# Configuration helpers
defp dead_code_enabled? do
Application.get_env(:ragex, :analysis, [])
|> Keyword.get(:enable_dead_code_detection, true)
end
defp get_default_min_confidence do
Application.get_env(:ragex, :analysis, [])
|> Keyword.get(:dead_code_min_confidence, 0.5)
end
end