Current section
Files
Jump to
Current section
Files
lib/mix/tasks/ragex.analyze.ex
# credo:disable-for-this-file Credo.Check.Refactor.Apply
defmodule Mix.Tasks.Ragex.Analyze do
@moduledoc """
Performs comprehensive analysis on a directory.
Analyzes a directory using all available Ragex analysis features:
- Security vulnerability scanning
- Business logic analysis (33 analyzers, including 13 CWE-based security analyzers)
- Code complexity metrics
- Code smell detection
- Code duplication detection
- Dead code analysis
- Dependency analysis
- Quality metrics
- Circular dependency detection
- God module detection (excessive coupling)
- Unstable module detection
- Unused module detection
- Coupling metrics report
## Usage
mix ragex.analyze [options]
## Options
* `--path PATH` - Directory to analyze (default: current directory)
* `--output FILE` - Output file for results (default: stdout)
* `--format FORMAT` - Output format: text, json, markdown (default: text)
* `--security` - Include security analysis
* `--business-logic` - Include business logic analysis (33 analyzers)
* `--complexity` - Include complexity analysis
* `--smells` - Include code smell detection
* `--duplicates` - Include duplication detection
* `--dead-code` - Include dead code analysis
* `--dependencies` - Include dependency analysis
* `--quality` - Include quality metrics
* `--circulars` - Detect circular dependency clusters
* `--god-modules` - Detect modules with excessive coupling
* `--unstable-modules` - Detect highly unstable modules
* `--unused-modules` - Detect unreferenced modules
* `--coupling` - Full coupling metrics report
* `--all` - Include all analyses (default)
* `--severity LEVEL` - Minimum severity for issues: low, medium, high, critical (default: medium)
* `--threshold FLOAT` - Duplication threshold 0.0-1.0 (default: 0.85)
* `--min-complexity INT` - Minimum complexity to report (default: 10)
* `--god-threshold INT` - Min total coupling for god module detection (default: 15)
* `--instability-threshold FLOAT` - Min instability to report (default: 0.8)
* `--verbose` - Show detailed progress information
* `--with-empty` / `--without-empty` - Include/exclude empty issue reports in output (default: without-empty)
* `--ci` - CI mode: plain text, no colors, non-zero exit on issues
* `--strict` - Exit with code 1 if any issues are found
* `--diff` - Diff mode: analyze only files changed between --base and --head (implies --ci)
* `--base REF` - Base git ref for diff mode (default: origin/main)
* `--head REF` - Head git ref for diff mode (default: HEAD)
## Examples
# Analyze current directory with all features
mix ragex.analyze
# Analyze specific directory
mix ragex.analyze --path lib/
# Security and quality analysis only
mix ragex.analyze --security --quality
# Output to file in JSON format
mix ragex.analyze --output report.json --format json
# High severity issues only
mix ragex.analyze --severity high
# Analyze with custom thresholds
mix ragex.analyze --threshold 0.9 --min-complexity 15
# CI pipeline: check for circular deps, fail if found
mix ragex.analyze --circulars --ci
# CI pipeline: full dependency health check
mix ragex.analyze --circulars --god-modules --unstable-modules --unused-modules --ci
# Strict mode with custom thresholds
mix ragex.analyze --coupling --god-threshold 20 --instability-threshold 0.9 --strict
# Diff mode: only check changed files in a PR
mix ragex.analyze --diff
# Diff mode with custom base ref and GitHub Actions annotations
mix ragex.analyze --diff --base origin/develop --format github
"""
use Mix.Task
alias Ragex.Analysis.Runner
alias Ragex.Git.Diff
alias Ragex.MCP.{Client, Delegate}
@shortdoc "Performs comprehensive code analysis on a directory"
# Check if CLI modules are available (not available when installed as archive)
@has_cli_modules match?({:ok, _}, Code.ensure_compiled(Ragex.CLI.Colors))
@impl Mix.Task
def run(args) do
# Parse options early to check format and decide whether to start MCP server
{opts, _, _} =
OptionParser.parse(args,
strict: [
path: :string,
output: :string,
format: :string,
security: :boolean,
business_logic: :boolean,
complexity: :boolean,
smells: :boolean,
duplicates: :boolean,
dead_code: :boolean,
dependencies: :boolean,
quality: :boolean,
circulars: :boolean,
god_modules: :boolean,
unstable_modules: :boolean,
unused_modules: :boolean,
coupling: :boolean,
all: :boolean,
severity: :string,
threshold: :float,
min_complexity: :integer,
god_threshold: :integer,
instability_threshold: :float,
verbose: :boolean,
with_empty: :boolean,
ci: :boolean,
strict: :boolean,
diff: :boolean,
base: :string,
head: :string
]
)
config = build_config(opts)
# If a Ragex server is already running, delegate all work to it
# via MCP socket -- avoids starting a second BEAM VM / Bumblebee.
if Client.server_running?() do
run_delegated(config)
else
run_local(config)
end
end
# Delegation path: all heavy work happens on the running server
defp run_delegated(config) do
show_progress = config.format != "json" and not config.ci
if config.format == "json" or config.ci do
Logger.configure(level: :emergency)
end
if show_progress do
info_msg("Ragex Comprehensive Analysis (delegated to running server)")
Mix.shell().info("")
end
# Build MCP tool arguments from config
analyses_arg =
Map.new(config.analyses, fn {k, v} -> {Atom.to_string(k), v} end)
severity_str =
case config.severity do
[:low, :medium, :high, :critical] -> "low"
[:medium, :high, :critical] -> "medium"
[:high, :critical] -> "high"
[:critical] -> "critical"
_ -> "medium"
end
tool_args = %{
"path" => config.path,
"analyses" => analyses_arg,
"severity" => severity_str,
"threshold" => config.threshold,
"min_complexity" => config.min_complexity,
"god_threshold" => config.god_threshold,
"instability_threshold" => config.instability_threshold
}
case Delegate.with_server(fn conn ->
Delegate.call!(conn, "comprehensive_analyze", tool_args)
end) do
{:ok, remote_result} ->
analyze_result =
Map.get(remote_result, :analyze_result, %{
files_analyzed: 0,
entities_found: 0,
errors: []
})
results = Map.get(remote_result, :results, %{})
# Feed into the existing report/output/summary pipeline
report = generate_report(config, analyze_result, results)
output_results(config, report)
print_summary(config, results)
maybe_exit(config, results)
{:error, reason} ->
error_msg("Delegation to running server failed: #{inspect(reason)}")
error_msg("Falling back to local execution...")
run_local(config)
end
:ok
end
# Local path: starts the full application (original behavior)
defp run_local(config) do
# Disable MCP server for non-interactive formats (prevents hanging)
if config.format in ["json", "markdown", "github"] or config.ci do
Application.put_env(:ragex, :start_server, false)
end
# Start required applications
Mix.Task.run("app.start")
show_progress = config.format not in ["json", "github"] and not config.ci
if config.format in ["json", "github"] or config.ci do
Logger.configure(level: :emergency)
end
if config.verbose and show_progress do
info_msg("Ragex Comprehensive Analysis")
Mix.shell().info("")
end
# Step 1: Analyze files and build knowledge graph
if show_progress,
do: header_msg("Step 1: Analyzing#{if config.diff, do: " changed", else: ""} files...")
analyze_result =
if config.diff do
# Diff mode: only analyze changed files
{:ok, result} = Runner.analyze_files(config.changed_files_absolute)
result
else
case Runner.analyze_directory(config.path) do
{:ok, result} ->
result
{:error, reason} ->
error_msg("Failed to analyze directory: #{inspect(reason)}")
System.halt(1)
end
end
if config.verbose and show_progress do
success_msg(
" analyzed #{analyze_result.files_analyzed} files (#{analyze_result.entities_found} entities)"
)
Mix.shell().info("")
end
# Step 2: Run analyses
results = run_analyses(config)
# Step 2.5: In diff mode, filter results to changed files only
results =
if config.diff and config.changed_files_set do
Runner.filter_results_by_files(results, config.changed_files_set)
else
results
end
# Step 3: Generate report
report = generate_report(config, analyze_result, results)
# Step 4: Output results
output_results(config, report)
# Step 5: Summary
print_summary(config, results)
# Step 6: Exit code for CI/strict mode
maybe_exit(config, results)
:ok
end
# Build configuration from options
@doc false
def build_config(opts) do
path = Keyword.get(opts, :path, File.cwd!())
ci = Keyword.get(opts, :ci, false)
strict = Keyword.get(opts, :strict, false)
# All analysis flag keys (original + new)
all_analysis_keys = [
:security,
:business_logic,
:complexity,
:smells,
:duplicates,
:dead_code,
:dependencies,
:quality,
:circulars,
:god_modules,
:unstable_modules,
:unused_modules,
:coupling
]
# Check if user explicitly provided any positive flags
positive_analyses =
Keyword.take(opts, all_analysis_keys)
|> Enum.filter(fn {_key, value} -> value == true end)
# If --all is explicitly set, use it; otherwise enable all only if no positive flags
all_analyses = Keyword.get(opts, :all)
enable_all =
cond do
# --all was explicitly provided
all_analyses == true -> true
# --no-all was explicitly provided
all_analyses == false -> false
# No positive analyses selected, default to all
Enum.empty?(positive_analyses) -> true
# Positive analyses selected, don't enable all
true -> false
end
resolve = fn key ->
if enable_all,
do: Keyword.get(opts, key, true),
else: Keyword.get(opts, key, false)
end
diff = Keyword.get(opts, :diff, false)
# --diff implies --ci
ci = if diff, do: true, else: ci
# Resolve changed files when in diff mode
{changed_files_absolute, changed_files_set} =
if diff do
resolve_diff_files(path, opts)
else
{[], nil}
end
%{
path: path,
output: Keyword.get(opts, :output),
format: Keyword.get(opts, :format, "text"),
verbose: Keyword.get(opts, :verbose, false),
ci: ci,
strict: strict,
diff: diff,
changed_files_absolute: changed_files_absolute,
changed_files_set: changed_files_set,
severity: parse_severity(Keyword.get(opts, :severity, "medium")),
threshold: Keyword.get(opts, :threshold, 0.85),
min_complexity: Keyword.get(opts, :min_complexity, 10),
god_threshold: Keyword.get(opts, :god_threshold, 15),
instability_threshold: Keyword.get(opts, :instability_threshold, 0.8),
with_empty: Keyword.get(opts, :with_empty, false),
analyses: %{
security: resolve.(:security),
business_logic: resolve.(:business_logic),
complexity: resolve.(:complexity),
smells: resolve.(:smells),
duplicates: resolve.(:duplicates),
dead_code: resolve.(:dead_code),
dependencies: resolve.(:dependencies),
quality: resolve.(:quality),
circulars: resolve.(:circulars),
god_modules: resolve.(:god_modules),
unstable_modules: resolve.(:unstable_modules),
unused_modules: resolve.(:unused_modules),
coupling: resolve.(:coupling)
}
}
end
defp resolve_diff_files(path, opts) do
diff_opts =
[]
|> then(fn o -> if opts[:base], do: [{:base, opts[:base]} | o], else: o end)
|> then(fn o -> if opts[:head], do: [{:head, opts[:head]} | o], else: o end)
case Diff.changed_files_for_path(path, diff_opts) do
{:ok, repo_root, files} ->
absolute = Enum.map(files, &Path.join(repo_root, &1))
relative_set = MapSet.new(files)
{absolute, relative_set}
{:error, reason} ->
Mix.raise("--diff: failed to resolve changed files: #{inspect(reason)}")
end
end
defp parse_severity(severity) when is_binary(severity) do
case String.downcase(severity) do
"low" -> [:low, :medium, :high, :critical]
"medium" -> [:medium, :high, :critical]
"high" -> [:high, :critical]
"critical" -> [:critical]
_ -> [:medium, :high, :critical]
end
end
# Run all enabled analyses using shared Runner
defp run_analyses(config) do
Runner.run_all(config)
end
# Generate report
defp generate_report(config, analyze_result, results) do
# Filter out empty results if --without-empty (default)
filtered_results =
if config.with_empty do
results
else
filter_non_empty_results(results)
end
%{
timestamp: DateTime.utc_now(),
path: config.path,
files_analyzed: analyze_result.files_analyzed,
entities: analyze_result.entities_found,
results: filtered_results,
config: %{
severity: config.severity,
threshold: config.threshold,
min_complexity: config.min_complexity
}
}
end
# Filter out empty results and clean up empty file reports within each analysis
defp filter_non_empty_results(results) do
results
|> Enum.map(fn {type, data} -> {type, filter_empty_within_result(type, data)} end)
|> Enum.reject(fn {type, data} -> result_is_empty?(type, data) end)
|> Map.new()
end
# Filter empty file results within each analysis type
defp filter_empty_within_result(:security, %{issues: issues} = data) do
filtered =
Enum.reject(issues, fn issue ->
Map.get(issue, :has_vulnerabilities?, true) == false and
Enum.empty?(Map.get(issue, :vulnerabilities, []))
end)
%{data | issues: filtered}
end
defp filter_empty_within_result(:business_logic, %{results: results} = data) do
filtered =
Enum.reject(results, fn result ->
Map.get(result, :has_issues?, true) == false and
Enum.empty?(Map.get(result, :issues, []))
end)
%{data | results: filtered}
end
defp filter_empty_within_result(:smells, %{smells: smells} = data) do
case smells do
%{results: results} ->
filtered =
Enum.reject(results, fn result ->
Map.get(result, :has_smells?, true) == false and
Enum.empty?(Map.get(result, :smells, []))
end)
%{data | smells: %{smells | results: filtered}}
_ ->
data
end
end
defp filter_empty_within_result(_type, data), do: data
# Check if entire result section is empty after filtering
defp result_is_empty?(:security, %{issues: issues}), do: Enum.empty?(issues)
defp result_is_empty?(:business_logic, data), do: Map.get(data, :total_issues, 0) == 0
defp result_is_empty?(:complexity, %{complex_functions: funcs}), do: Enum.empty?(funcs)
defp result_is_empty?(:smells, %{smells: smells}) do
case smells do
%{total_smells: 0} -> true
%{results: results} -> Enum.all?(results, &(Map.get(&1, :total_smells, 0) == 0))
list when is_list(list) -> Enum.empty?(list)
_ -> true
end
end
defp result_is_empty?(:duplicates, %{duplicates: dups}), do: Enum.empty?(dups)
defp result_is_empty?(:dead_code, %{dead_functions: funcs}), do: Enum.empty?(funcs)
defp result_is_empty?(:dependencies, %{modules: modules}), do: map_size(modules) == 0
defp result_is_empty?(:circulars, %{cycles: cycles}), do: Enum.empty?(cycles)
defp result_is_empty?(:god_modules, %{modules: mods}), do: Enum.empty?(mods)
defp result_is_empty?(:unstable_modules, %{modules: mods}), do: Enum.empty?(mods)
defp result_is_empty?(:unused_modules, %{modules: mods}), do: Enum.empty?(mods)
defp result_is_empty?(:coupling, %{metrics: metrics}), do: Enum.empty?(metrics)
# Quality always has a score, never considered "empty"
defp result_is_empty?(:quality, _), do: false
defp result_is_empty?(_, _), do: false
# Output results
defp output_results(config, report) do
cond do
config.format == "github" ->
output_github(report)
config.ci ->
output_ci(report)
true ->
content =
case config.format do
"json" -> format_json(report)
"markdown" -> format_markdown(report)
_ -> format_text(report)
end
case config.output do
nil ->
if config.format != "json", do: Mix.shell().info("")
Mix.shell().info(content)
file ->
File.write!(file, content)
if config.format != "json", do: success_msg("\nReport written to #{file}")
end
end
end
# GitHub Actions workflow command format for inline PR annotations
defp output_github(report) do
lines =
report.results
|> Enum.flat_map(fn {type, data} -> github_lines_for(type, data) end)
Enum.each(lines, fn line -> Mix.shell().info(line) end)
total = length(lines)
Mix.shell().info("ragex: #{total} issue(s) found")
end
defp github_lines_for(:security, %{issues: issues}) do
issues
|> Enum.flat_map(fn result -> Map.get(result, :vulnerabilities, []) end)
|> Enum.map(fn vuln ->
level = if vuln.severity in [:critical, :high], do: "error", else: "warning"
"::#{level} file=#{vuln.file},line=#{vuln.line}::SECURITY #{vuln.category}: #{vuln.description}"
end)
end
defp github_lines_for(:business_logic, data) do
data
|> Map.get(:results, [])
|> Enum.flat_map(&Map.get(&1, :issues, []))
|> Enum.map(fn issue ->
level = if issue[:severity] in [:critical, :high], do: "error", else: "warning"
"::#{level} file=#{issue[:file]},line=#{issue[:line]}::#{issue[:analyzer]}: #{issue[:description]}"
end)
end
defp github_lines_for(:complexity, %{complex_functions: funcs}) do
Enum.map(funcs, fn f ->
"::warning file=#{f[:file] || f[:path]},line=#{f[:line] || 1}::COMPLEXITY #{f.module}.#{f.name}/#{f.arity} cyclomatic=#{f.cyclomatic_complexity}"
end)
end
defp github_lines_for(:dead_code, %{dead_functions: funcs}) do
Enum.map(funcs, fn f ->
"::notice file=#{f[:file] || f[:path]},line=#{f[:line] || 1}::DEAD_CODE #{f.module}.#{f.name}/#{f.arity}: #{f.reason}"
end)
end
defp github_lines_for(:circulars, %{cycles: cycles}) do
Enum.map(cycles, fn cycle ->
chain = Enum.map_join(cycle, " -> ", &format_module_name/1)
"::error::CIRCULAR dependency: #{chain}"
end)
end
defp github_lines_for(_, _), do: []
# CI output: one-line-per-issue, no ANSI, machine-friendly
defp output_ci(report) do
lines =
report.results
|> Enum.flat_map(fn {type, data} -> ci_lines_for(type, data) end)
Enum.each(lines, fn line -> Mix.shell().info(line) end)
total = length(lines)
Mix.shell().info("ragex: #{total} issue(s) found")
end
defp ci_lines_for(:security, %{issues: issues}) do
issues
|> Enum.flat_map(fn result -> Map.get(result, :vulnerabilities, []) end)
|> Enum.map(fn vuln ->
"SECURITY: #{vuln.category} (#{vuln.severity}) #{vuln.file}:#{vuln.line} - #{vuln.description}"
end)
end
defp ci_lines_for(:business_logic, data) do
data
|> Map.get(:results, [])
|> Enum.flat_map(&Map.get(&1, :issues, []))
|> Enum.map(fn issue ->
"BUSINESS_LOGIC: #{issue[:analyzer] || "unknown"} (#{issue[:severity]}) #{issue[:file]}:#{issue[:line]} - #{issue[:description]}"
end)
end
defp ci_lines_for(:complexity, %{complex_functions: funcs}) do
Enum.map(funcs, fn f ->
"COMPLEXITY: #{f.module}.#{f.name}/#{f.arity} (cyclomatic=#{f.cyclomatic_complexity})"
end)
end
defp ci_lines_for(:smells, %{smells: smells}) do
all =
case smells do
%{results: results} when is_list(results) ->
Enum.flat_map(results, &Map.get(&1, :smells, []))
list when is_list(list) ->
list
_ ->
[]
end
Enum.map(all, fn s -> "SMELL: #{s[:type]} (#{s[:severity]})" end)
end
defp ci_lines_for(:duplicates, %{duplicates: dups}) do
Enum.map(dups, fn d ->
sim = Float.round((d[:similarity] || 0.0) * 100, 1)
"DUPLICATE: #{sim}% similar (#{d[:lines] || 0} lines)"
end)
end
defp ci_lines_for(:dead_code, %{dead_functions: funcs}) do
Enum.map(funcs, fn f ->
"DEAD_CODE: #{f.module}.#{f.name}/#{f.arity} - #{f.reason}"
end)
end
defp ci_lines_for(:circulars, %{cycles: cycles}) do
Enum.map(cycles, fn cycle ->
chain = Enum.map_join(cycle, " -> ", &format_module_name/1)
"CIRCULAR: #{chain} (#{length(cycle)} modules)"
end)
end
defp ci_lines_for(:god_modules, %{modules: modules}) do
Enum.map(modules, fn m ->
"GOD_MODULE: #{format_module_name(m.module)} (afferent=#{m.afferent}, efferent=#{m.efferent}, total=#{m.total})"
end)
end
defp ci_lines_for(:unstable_modules, %{modules: modules}) do
Enum.map(modules, fn m ->
"UNSTABLE: #{format_module_name(m.module)} (instability=#{Float.round(m.instability, 2)})"
end)
end
defp ci_lines_for(:unused_modules, %{modules: modules}) do
Enum.map(modules, fn mod -> "UNUSED: #{format_module_name(mod)}" end)
end
defp ci_lines_for(:coupling, %{metrics: metrics}) do
Enum.map(metrics, fn m ->
"COUPLING: #{format_module_name(m.module)} afferent=#{m.afferent} efferent=#{m.efferent} instability=#{Float.round(m.instability, 2)}"
end)
end
defp ci_lines_for(:dependencies, _), do: []
defp ci_lines_for(:quality, _), do: []
defp ci_lines_for(_, _), do: []
defp format_module_name(mod) when is_atom(mod), do: inspect(mod)
defp format_module_name(other), do: to_string(other)
# Format as JSON
defp format_json(report) do
Jason.encode!(report, pretty: true)
end
# Format as Markdown
defp format_markdown(report) do
"""
# Ragex Analysis Report
**Timestamp**: #{report.timestamp}
**Path**: #{report.path}
**Files Analyzed**: #{report.files_analyzed}
**Entities Found**: #{report.entities}
## Configuration
- Severity: #{inspect(report.config.severity)}
- Duplication Threshold: #{report.config.threshold}
- Min Complexity: #{report.config.min_complexity}
#{format_markdown_results(report.results)}
"""
end
defp format_markdown_results(results) do
Enum.map_join(results, "\n\n", fn {type, data} ->
case type do
:security -> format_markdown_security(data)
:business_logic -> format_markdown_business_logic(data)
:complexity -> format_markdown_complexity(data)
:smells -> format_markdown_smells(data)
:duplicates -> format_markdown_duplicates(data)
:dead_code -> format_markdown_dead_code(data)
:dependencies -> format_markdown_dependencies(data)
:quality -> format_markdown_quality(data)
:circulars -> format_markdown_circulars(data)
:god_modules -> format_markdown_god_modules(data)
:unstable_modules -> format_markdown_unstable_modules(data)
:unused_modules -> format_markdown_unused_modules(data)
:coupling -> format_markdown_coupling(data)
_ -> ""
end
end)
end
defp format_markdown_security(%{issues: issues}) do
"""
## Security Issues (#{length(issues)})
#{Enum.map_join(issues, "\n", fn issue -> "- **#{issue.type}** (#{issue.severity}): #{issue.file}:#{issue.line} - #{issue.description}" end)}
"""
end
defp format_markdown_business_logic(data) do
total = Map.get(data, :total_issues, 0)
files_with_issues = Map.get(data, :files_with_issues, 0)
by_severity = Map.get(data, :by_severity, %{})
by_analyzer = Map.get(data, :by_analyzer, %{})
severity_summary =
Enum.map_join([:critical, :high, :medium, :low, :info], ", ", fn sev ->
"#{sev}: #{Map.get(by_severity, sev, 0)}"
end)
analyzer_summary =
by_analyzer
|> Enum.filter(fn {_name, count} -> count > 0 end)
|> Enum.sort_by(fn {_name, count} -> count end, :desc)
|> Enum.map_join("\n", fn {name, count} -> "- **#{name}**: #{count}" end)
"""
## Business Logic Issues (#{total})
Files with issues: #{files_with_issues}
By severity: #{severity_summary}
### By Analyzer
#{analyzer_summary}
"""
end
defp format_markdown_complexity(%{complex_functions: functions}) do
"""
## Complex Functions (#{length(functions)})
#{Enum.map_join(functions, "\n", fn func -> "- **#{func.module}.#{func.name}/#{func.arity}**: Complexity #{func.cyclomatic_complexity}" end)}
"""
end
defp format_markdown_smells(%{smells: directory_result}) do
# Extract all smells from directory results and flatten
all_smells =
case directory_result do
%{results: results} when is_list(results) ->
Enum.flat_map(results, fn file_result ->
Enum.map(Map.get(file_result, :smells, []), fn smell ->
# Add file path to smell for context
Map.put(smell, :file, Map.get(file_result, :path, "unknown"))
end)
end)
smells when is_list(smells) ->
smells
_ ->
[]
end
# Sort by severity (critical > high > medium > low)
sorted_smells = Enum.sort_by(all_smells, &smell_severity_order(&1.severity), :desc)
# Format location for display
formatted_smells =
Enum.map(sorted_smells, fn smell ->
location =
case smell do
%{location: %{formatted: fmt}} when is_binary(fmt) -> fmt
%{location: loc} when is_map(loc) -> format_smell_location(loc)
%{file: file} -> file
_ -> "unknown"
end
"- **#{smell.type}** (#{smell.severity}): #{location}"
end)
"""
## Code Smells (#{length(sorted_smells)})
#{Enum.join(formatted_smells, "\n")}
"""
end
defp format_smell_location(location) do
module = Map.get(location, :module)
function = Map.get(location, :function)
arity = Map.get(location, :arity)
line = Map.get(location, :line)
cond do
module && function && arity && line ->
"#{inspect(module)}.#{function}/#{arity}:#{line}"
module && function && arity ->
"#{inspect(module)}.#{function}/#{arity}"
line ->
"line #{line}"
true ->
"unknown"
end
end
defp format_markdown_duplicates(%{duplicates: duplicates}) do
formatted_duplicates =
Enum.map(duplicates, fn dup ->
# Extract unique locations with line numbers
locations =
case dup do
%{locations: locs} when is_list(locs) ->
locs
|> Enum.map(fn loc ->
file = loc[:file] || loc.file || "unknown"
line = loc[:start_line] || loc.start_line || loc[:line] || loc.line
%{file: file, line: line}
end)
|> Enum.uniq_by(&{&1.file, &1.line})
%{file1: f1, file2: f2, line1: l1, line2: l2} ->
[%{file: f1, line: l1}, %{file: f2, line: l2}]
%{file1: f1, file2: f2} ->
[%{file: f1, line: nil}, %{file: f2, line: nil}]
_ ->
[]
end
# Format locations with line numbers and individual truncation
# Max length per location (allow reasonable space for each path)
max_per_location = 35
loc_str =
locations
|> Enum.take(2)
|> Enum.map_join(" ↔ ", fn %{file: file, line: line} ->
# Format with line number if available
full_location =
if line && is_integer(line) do
"#{file}:#{line}"
else
file
end
# Truncate from right to preserve filename
truncate_from_right_md(full_location, max_per_location)
end)
|> then(fn str ->
locations_len = length(locations)
if locations_len > 2 do
"#{str} (+#{locations_len - 2} more)"
else
str
end
end)
similarity = (dup[:similarity] || dup.similarity || 0.0) * 100
lines = dup[:lines] || dup.lines || 0
"- **#{Float.round(similarity, 1)}% similar** (#{lines} lines): #{loc_str}"
end)
"""
## Code Duplicates (#{length(duplicates)})
#{Enum.join(formatted_duplicates, "\n")}
"""
end
defp format_markdown_dead_code(%{dead_functions: functions}) do
"""
## Dead Code (#{length(functions)})
#{Enum.map_join(functions, "\n", fn func -> "- **#{func.module}.#{func.name}/#{func.arity}**: #{func.reason}" end)}
"""
end
defp format_markdown_dependencies(%{modules: modules}) do
"""
## Dependencies
Total Modules: #{map_size(modules)}
"""
end
defp format_markdown_quality(metrics) do
"""
## Quality Metrics
Overall Score: #{metrics.overall_score}/100
"""
end
defp format_markdown_circulars(%{cycles: cycles}) do
formatted =
Enum.map(cycles, fn cycle ->
chain = Enum.map_join(cycle, " -> ", &format_module_name/1)
"- #{chain} (#{length(cycle)} modules)"
end)
"""
## Circular Dependencies (#{length(cycles)})
#{Enum.join(formatted, "\n")}
"""
end
defp format_markdown_god_modules(%{modules: modules, threshold: threshold}) do
formatted =
Enum.map(modules, fn m ->
"- **#{format_module_name(m.module)}**: afferent=#{m.afferent}, efferent=#{m.efferent}, total=#{m.total}, instability=#{Float.round(m.instability, 2)}"
end)
"""
## God Modules (#{length(modules)}, threshold >= #{threshold})
#{Enum.join(formatted, "\n")}
"""
end
defp format_markdown_unstable_modules(%{modules: modules, threshold: threshold}) do
formatted =
Enum.map(modules, fn m ->
"- **#{format_module_name(m.module)}**: instability=#{Float.round(m.instability, 2)} (Ca=#{m.afferent}, Ce=#{m.efferent})"
end)
"""
## Unstable Modules (#{length(modules)}, threshold > #{threshold})
#{Enum.join(formatted, "\n")}
"""
end
defp format_markdown_unused_modules(%{modules: modules}) do
formatted = Enum.map(modules, fn mod -> "- #{format_module_name(mod)}" end)
"""
## Unused Modules (#{length(modules)})
#{Enum.join(formatted, "\n")}
"""
end
defp format_markdown_coupling(%{metrics: metrics}) do
formatted =
Enum.map(metrics, fn m ->
"- **#{format_module_name(m.module)}**: Ca=#{m.afferent}, Ce=#{m.efferent}, I=#{Float.round(m.instability, 2)}"
end)
"""
## Coupling Metrics (#{length(metrics)} modules)
#{Enum.join(formatted, "\n")}
"""
end
# Helper to get severity order for sorting (higher = more severe)
defp smell_severity_order(:critical), do: 4
defp smell_severity_order(:high), do: 3
defp smell_severity_order(:medium), do: 2
defp smell_severity_order(:low), do: 1
defp smell_severity_order(_), do: 0
# Truncate from the right for markdown, preserving filenames
defp truncate_from_right_md(text, max_length) when is_binary(text) do
if String.length(text) > max_length do
keep_length = max_length - 1
"…" <> String.slice(text, -keep_length, keep_length)
else
text
end
end
defp truncate_from_right_md(text, _), do: to_string(text)
# Format as text
defp format_text(report) do
if @has_cli_modules do
apply(Ragex.CLI.Output, :format_analysis_report, [report])
else
# Fallback simple text format when CLI modules not available
Jason.encode!(report, pretty: true)
end
end
# Print summary
defp print_summary(config, results) do
# Skip summary in CI mode (output_ci already prints summary)
if config.verbose and not config.ci do
Mix.shell().info("")
header_msg("Summary:")
Enum.each(results, fn {type, data} ->
case type do
:security ->
count = length(data.issues)
msg = " Security Issues: #{count}"
if count > 0, do: error_msg(msg), else: success_msg(msg)
:business_logic ->
count = Map.get(data, :total_issues, 0)
msg = " Business Logic Issues: #{count}"
if count > 0, do: warning_msg(msg), else: success_msg(msg)
:complexity ->
count = length(data.complex_functions)
msg = " Complex Functions: #{count}"
if count > 0, do: warning_msg(msg), else: success_msg(msg)
:smells ->
count =
case data.smells do
%{total_smells: n} -> n
list when is_list(list) -> length(list)
_ -> 0
end
msg = " Code Smells: #{count}"
if count > 0, do: warning_msg(msg), else: success_msg(msg)
:duplicates ->
count = length(data.duplicates)
msg = " Duplicate Blocks: #{count}"
if count > 0, do: warning_msg(msg), else: success_msg(msg)
:dead_code ->
count = length(data.dead_functions)
msg = " Dead Functions: #{count}"
if count > 0, do: info_msg(msg), else: success_msg(msg)
:dependencies ->
count = map_size(data.modules)
info_msg(" Modules Analyzed: #{count}")
:quality ->
score = data.overall_score
msg = " Quality Score: #{score}/100"
cond do
score >= 80 -> success_msg(msg)
score >= 60 -> warning_msg(msg)
true -> error_msg(msg)
end
:circulars ->
count = length(data.cycles)
msg = " Circular Dependencies: #{count}"
if count > 0, do: error_msg(msg), else: success_msg(msg)
:god_modules ->
count = length(data.modules)
msg = " God Modules: #{count}"
if count > 0, do: warning_msg(msg), else: success_msg(msg)
:unstable_modules ->
count = length(data.modules)
msg = " Unstable Modules: #{count}"
if count > 0, do: warning_msg(msg), else: success_msg(msg)
:unused_modules ->
count = length(data.modules)
msg = " Unused Modules: #{count}"
if count > 0, do: info_msg(msg), else: success_msg(msg)
:coupling ->
count = length(data.metrics)
info_msg(" Coupling Metrics: #{count} modules")
_ ->
:ok
end
end)
Mix.shell().info("")
end
end
# Exit code logic for CI/strict mode
@doc false
def count_ci_issues(results) do
Enum.reduce(results, 0, fn {type, data}, acc ->
acc + count_issues_for_type(type, data)
end)
end
defp count_issues_for_type(:security, %{issues: issues}) do
issues |> Enum.flat_map(&Map.get(&1, :vulnerabilities, [])) |> length()
end
defp count_issues_for_type(:business_logic, data), do: Map.get(data, :total_issues, 0)
defp count_issues_for_type(:complexity, %{complex_functions: f}), do: length(f)
defp count_issues_for_type(:smells, %{smells: smells}) do
case smells do
%{total_smells: n} -> n
%{results: r} when is_list(r) -> Enum.flat_map(r, &Map.get(&1, :smells, [])) |> length()
list when is_list(list) -> length(list)
_ -> 0
end
end
defp count_issues_for_type(:duplicates, %{duplicates: d}), do: length(d)
defp count_issues_for_type(:dead_code, %{dead_functions: f}), do: length(f)
defp count_issues_for_type(:circulars, %{cycles: c}), do: length(c)
defp count_issues_for_type(:god_modules, %{modules: m}), do: length(m)
defp count_issues_for_type(:unstable_modules, %{modules: m}), do: length(m)
defp count_issues_for_type(:unused_modules, %{modules: m}), do: length(m)
# Coupling and quality are informational, not "issues"
defp count_issues_for_type(_, _), do: 0
defp maybe_exit(config, results) do
if config.ci or config.strict do
total = count_ci_issues(results)
if total > 0 do
System.halt(1)
end
end
end
# Helper functions for colored output that work with or without CLI modules
defp header_msg(text) do
if @has_cli_modules do
Mix.shell().info(apply(Ragex.CLI.Colors, :header, [text]))
else
Mix.shell().info(text)
end
end
defp success_msg(text) do
if @has_cli_modules do
Mix.shell().info(apply(Ragex.CLI.Colors, :success, [text]))
else
Mix.shell().info(text)
end
end
defp error_msg(text) do
if @has_cli_modules do
Mix.shell().info(apply(Ragex.CLI.Colors, :error, [text]))
else
Mix.shell().info(text)
end
end
defp warning_msg(text) do
if @has_cli_modules do
Mix.shell().info(apply(Ragex.CLI.Colors, :warning, [text]))
else
Mix.shell().info(text)
end
end
defp info_msg(text) do
if @has_cli_modules do
Mix.shell().info(apply(Ragex.CLI.Colors, :info, [text]))
else
Mix.shell().info(text)
end
end
end