Current section

Files

Jump to
ragex lib ragex analysis smells.ex
Raw

lib/ragex/analysis/smells.ex

defmodule Ragex.Analysis.Smells do
@moduledoc """
Code smell detection using Metastatic.Analysis.Smells.
Provides file and directory-level code smell detection with configurable
thresholds, parallel processing, and detailed reporting.
## Usage
alias Ragex.Analysis.Smells
# Analyze single file
{:ok, result} = Smells.analyze_file("lib/my_module.ex")
# Analyze with custom thresholds
{:ok, result} = Smells.analyze_file("lib/my_module.ex",
thresholds: %{max_statements: 30, max_nesting: 3})
# Analyze directory
{:ok, results} = Smells.analyze_directory("lib/",
recursive: true,
parallel: true)
# Filter by severity
critical = Smells.filter_by_severity(results, :critical)
## Detected Smells
- **Long function** - Too many statements (default threshold: 50)
- **Deep nesting** - Excessive nesting depth (default threshold: 4)
- **Magic numbers** - Unexplained numeric literals
- **Complex conditionals** - Deeply nested boolean operations
- **Long parameter list** - Too many parameters (default threshold: 5)
"""
require Logger
alias Metastatic.{Adapter, Document}
alias Metastatic.Analysis.Smells, as: MetaSmells
alias Ragex.Analysis.LocationEnricher
@type smell_result :: %{
path: String.t(),
language: atom(),
has_smells?: boolean(),
total_smells: non_neg_integer(),
smells: [smell_with_location()],
by_severity: %{atom() => non_neg_integer()},
by_type: %{atom() => non_neg_integer()},
summary: String.t(),
timestamp: DateTime.t()
}
@type smell_with_location :: %{
type: atom(),
severity: atom(),
description: String.t(),
suggestion: String.t(),
context: map(),
location: location() | nil
}
@type location :: %{
optional(:module) => atom(),
optional(:function) => atom(),
optional(:arity) => non_neg_integer(),
optional(:line) => non_neg_integer(),
optional(:formatted) => String.t()
}
@type directory_result :: %{
total_files: non_neg_integer(),
files_with_smells: non_neg_integer(),
total_smells: non_neg_integer(),
by_severity: %{atom() => non_neg_integer()},
by_type: %{atom() => non_neg_integer()},
results: [smell_result()],
summary: String.t()
}
@default_thresholds %{
max_statements: 50,
max_nesting: 4,
max_parameters: 5,
max_cognitive: 15
}
@doc """
Analyzes a single file for code smells.
## Options
- `:thresholds` - Map of threshold overrides
- `:language` - Explicit language (default: auto-detect)
## Examples
{:ok, result} = Smells.analyze_file("lib/my_module.ex")
result.has_smells? # => true/false
result.total_smells # => 3
"""
@spec analyze_file(path :: String.t(), opts :: keyword()) ::
{:ok, smell_result()} | {:error, term()}
def analyze_file(path, opts \\ []) do
thresholds = Keyword.get(opts, :thresholds, %{}) |> merge_thresholds()
language = Keyword.get(opts, :language, detect_language(path))
with {:ok, content} <- File.read(path),
{:ok, adapter} <- get_adapter(language),
{:ok, doc} <- parse_document(adapter, content, language),
{:ok, result} <- MetaSmells.analyze(doc, thresholds: thresholds) do
{:ok, format_result(path, language, result)}
else
{:error, reason} = error ->
Logger.warning("Code smell analysis failed for #{path}: #{inspect(reason)}")
error
end
end
@doc """
Analyzes all files in a directory for code smells.
## Options
- `:recursive` - Recursively analyze subdirectories (default: true)
- `:thresholds` - Map of threshold overrides
- `:parallel` - Use parallel processing (default: true)
- `:max_concurrency` - Maximum concurrent analyses (default: System.schedulers_online())
- `:min_severity` - Minimum severity to include (`:low`, `:medium`, `:high`, `:critical`)
## Examples
{:ok, results} = Smells.analyze_directory("lib/",
recursive: true,
parallel: true,
min_severity: :medium)
"""
@spec analyze_directory(path :: String.t(), opts :: keyword()) ::
{:ok, directory_result()} | {:error, term()}
def analyze_directory(path, opts \\ []) do
recursive = Keyword.get(opts, :recursive, true)
parallel = Keyword.get(opts, :parallel, true)
max_concurrency = Keyword.get(opts, :max_concurrency, System.schedulers_online())
min_severity = Keyword.get(opts, :min_severity, :low)
case find_source_files(path, recursive) do
{:ok, []} ->
{:ok, empty_directory_result()}
{:ok, files} ->
results =
if parallel do
analyze_files_parallel(files, opts, max_concurrency)
else
analyze_files_sequential(files, opts)
end
filtered_results = filter_results_by_severity(results, min_severity)
{:ok, aggregate_results(filtered_results)}
{:error, reason} = error ->
Logger.error("Failed to list directory #{path}: #{inspect(reason)}")
error
end
end
@doc """
Filters smell results by minimum severity level.
## Severity Levels
- `:low` - Include all smells
- `:medium` - Include medium, high, and critical
- `:high` - Include high and critical only
- `:critical` - Include critical only
## Examples
critical_smells = Smells.filter_by_severity(results, :critical)
"""
@spec filter_by_severity([smell_result()], atom()) :: [smell_result()]
def filter_by_severity(results, min_severity) do
severity_levels = [:low, :medium, :high, :critical]
min_index = Enum.find_index(severity_levels, &(&1 == min_severity)) || 0
Enum.map(results, fn result ->
filtered_smells =
Enum.filter(result.smells, fn smell ->
smell_index = Enum.find_index(severity_levels, &(&1 == smell.severity))
smell_index >= min_index
end)
%{result | smells: filtered_smells, total_smells: length(filtered_smells)}
end)
|> Enum.reject(&(&1.total_smells == 0))
end
@doc """
Filters results by smell type.
## Examples
magic_numbers = Smells.filter_by_type(results, :magic_number)
"""
@spec filter_by_type([smell_result()], atom()) :: [smell_result()]
def filter_by_type(results, smell_type) do
Enum.map(results, fn result ->
filtered_smells = Enum.filter(result.smells, &(&1.type == smell_type))
%{result | smells: filtered_smells, total_smells: length(filtered_smells)}
end)
|> Enum.reject(&(&1.total_smells == 0))
end
@doc """
Gets default thresholds for smell detection.
## Examples
iex> Ragex.Analysis.Smells.default_thresholds()
%{max_statements: 50, max_nesting: 4, max_parameters: 5, max_cognitive: 15}
"""
@spec default_thresholds() :: map()
def default_thresholds, do: @default_thresholds
# Private functions
defp detect_language(path), do: Ragex.LanguageSupport.detect_language(path)
defp get_adapter(lang), do: Ragex.LanguageSupport.get_adapter(lang)
defp parse_document(adapter, content, language) do
case Adapter.abstract(adapter, content, language) do
{:ok, %Document{} = doc} -> {:ok, doc}
{:error, _} = error -> error
other -> {:error, {:unexpected_parse_result, other}}
end
end
defp merge_thresholds(overrides) do
Map.merge(@default_thresholds, overrides)
end
defp format_result(path, language, result) do
# Enrich smells with knowledge graph function context using LocationEnricher
enriched_smells = LocationEnricher.enrich_issues(result.smells, path)
%{
path: path,
language: language,
has_smells?: result.has_smells?,
total_smells: result.total_smells,
smells: enriched_smells,
by_severity: result.by_severity,
by_type: result.by_type,
summary: result.summary,
timestamp: DateTime.utc_now()
}
end
defp find_source_files(path, recursive) do
Ragex.LanguageSupport.find_source_files(path, recursive: recursive, metastatic_only: true)
end
defp analyze_files_sequential(files, opts) do
Enum.reduce(files, [], fn file, acc ->
case analyze_file(file, opts) do
{:ok, result} -> [result | acc]
{:error, reason} -> [build_error_result(file, reason) | acc]
end
end)
|> Enum.reverse()
end
defp analyze_files_parallel(files, opts, max_concurrency) do
files
|> Task.async_stream(
fn file ->
case analyze_file(file, opts) do
{:ok, result} -> result
{:error, reason} -> build_error_result(file, reason)
end
end,
max_concurrency: max_concurrency,
timeout: 30_000
)
|> Enum.map(fn
{:ok, result} -> result
{:exit, reason} -> build_error_result("unknown", {:task_exit, reason})
end)
end
defp build_error_result(path, error) do
%{
path: path,
language: :unknown,
has_smells?: false,
total_smells: 0,
smells: [],
by_severity: %{},
by_type: %{},
summary: "Analysis failed: #{inspect(error)}",
timestamp: DateTime.utc_now(),
error: error
}
end
defp filter_results_by_severity(results, :low), do: results
defp filter_results_by_severity(results, min_severity) do
filter_by_severity(results, min_severity)
end
defp aggregate_results(results) do
files_with_smells = Enum.count(results, & &1.has_smells?)
total_smells = Enum.sum(Enum.map(results, & &1.total_smells))
by_severity =
results
|> Enum.flat_map(& &1.smells)
|> Enum.reduce(%{}, fn smell, acc ->
Map.update(acc, smell.severity, 1, &(&1 + 1))
end)
by_type =
results
|> Enum.flat_map(& &1.smells)
|> Enum.reduce(%{}, fn smell, acc ->
Map.update(acc, smell.type, 1, &(&1 + 1))
end)
%{
total_files: length(results),
files_with_smells: files_with_smells,
total_smells: total_smells,
by_severity: by_severity,
by_type: by_type,
results: results,
summary: build_summary(length(results), files_with_smells, total_smells, by_severity)
}
end
defp empty_directory_result do
%{
total_files: 0,
files_with_smells: 0,
total_smells: 0,
by_severity: %{},
by_type: %{},
results: [],
summary: "No files found"
}
end
defp build_summary(total_files, files_with_smells, total_smells, by_severity) do
if total_smells == 0 do
"Analyzed #{total_files} files - no code smells detected"
else
severity_summary =
by_severity
|> Enum.sort_by(fn {sev, _} -> severity_order(sev) end, :desc)
|> Enum.map_join(", ", fn {sev, count} -> "#{count} #{sev}" end)
"Analyzed #{total_files} files - found #{total_smells} smell(s) in #{files_with_smells} file(s): #{severity_summary}"
end
end
defp severity_order(:critical), do: 4
defp severity_order(:high), do: 3
defp severity_order(:medium), do: 2
defp severity_order(:low), do: 1
@doc """
Detects code smells in a directory.
Alias for `analyze_directory/2`. Provided for API consistency with mix tasks.
## Examples
{:ok, smells} = Smells.detect_smells("lib/")
"""
@spec detect_smells(String.t(), keyword()) :: {:ok, [map()]} | {:error, term()}
def detect_smells(path, opts \\ []), do: analyze_directory(path, opts)
# NOTE: Legacy enrichment functions removed in favor of LocationEnricher module.
# The LocationEnricher provides a more robust approach by:
# 1. Extracting function names from Metastatic smell context
# 2. Looking up functions directly from knowledge graph by name
# 3. Falling back to line-based matching when needed
# 4. Providing consistent formatted location strings across all analysis modules
end