Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions lib/codeqa/engine/analyzer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,15 @@ defmodule CodeQA.Engine.Analyzer do
"mean_#{key}" => stats.mean,
"std_#{key}" => stats.std,
"min_#{key}" => stats.min,
"max_#{key}" => stats.max
"max_#{key}" => stats.max,
"p90_#{key}" => stats.p90
})

Map.put(acc, metric, updated)
end)
end

defp compute_stats([]), do: %{max: 0.0, mean: 0.0, min: 0.0, std: 0.0}
defp compute_stats([]), do: %{max: 0.0, mean: 0.0, min: 0.0, p90: 0.0, std: 0.0}

defp compute_stats(values) do
n = length(values)
Expand All @@ -250,7 +251,15 @@ defmodule CodeQA.Engine.Analyzer do
max: Float.round(Enum.max(values) * 1.0, 4),
mean: Float.round(mean * 1.0, 4),
min: Float.round(Enum.min(values) * 1.0, 4),
p90: Float.round(percentile(values, 0.9) * 1.0, 4),
std: Float.round(std * 1.0, 4)
}
end

# Nearest-rank percentile: the value the worst (1 - q) share of files stays under.
defp percentile(values, q) do
sorted = Enum.sort(values)
index = (length(sorted) * q) |> Float.floor() |> trunc() |> min(length(sorted) - 1)
Enum.at(sorted, index)
end
end
48 changes: 44 additions & 4 deletions lib/codeqa/health_report/categories.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ defmodule CodeQA.HealthReport.Categories do
],
name: "Readability"
},
# Halstead runs per FILE here, not per function, so these are anchored on measured
# file sizes rather than the function-level figures from the literature: a = a
# 100-LOC file (the common case), d = a 500-LOC file (the largest still tolerated).
# Past d the score falls off a cliff, which is where files over 500 LOC land.
%{
key: :complexity,
metrics: [
Expand All @@ -77,7 +81,7 @@ defmodule CodeQA.HealthReport.Categories do
good: :low,
name: "difficulty",
source: "halstead",
thresholds: %{a: 10, b: 20, c: 35, d: 50},
thresholds: %{a: 35, b: 50, c: 60, d: 70},
weight: 0.35
},
%{
Expand All @@ -86,7 +90,7 @@ defmodule CodeQA.HealthReport.Categories do
good: :low,
name: "effort",
source: "halstead",
thresholds: %{a: 5000, b: 20_000, c: 50_000, d: 100_000},
thresholds: %{a: 130_000, b: 440_000, c: 920_000, d: 1_850_000},
weight: 0.3
},
%{
Expand All @@ -95,20 +99,56 @@ defmodule CodeQA.HealthReport.Categories do
good: :low,
name: "volume",
source: "halstead",
thresholds: %{a: 300, b: 1000, c: 3000, d: 8000},
thresholds: %{a: 4_000, b: 8_700, c: 16_600, d: 25_000},
weight: 0.2
},
%{
fix_hint: "High defect estimate — reduce complexity; simpler code has fewer bugs",
good: :low,
name: "estimated_bugs",
source: "halstead",
thresholds: %{a: 0.1, b: 0.5, c: 1.0, d: 3.0},
thresholds: %{a: 1.35, b: 2.9, c: 5.5, d: 8.4},
weight: 0.15
}
],
name: "Complexity"
},
# The mean says how big a typical file is; this says how bad the tail is. p90 is
# the value the worst 10% of files stay under, so a repo of uniformly sized files
# scores the same here as on Complexity, while one with a handful of monsters does
# not. Anchored on the same LOC bands: a = 200 LOC, d = 800 LOC.
%{
key: :complexity_outliers,
metrics: [
%{
fix_hint:
"The largest 10% of files carry far more logic than the rest — split the worst offenders",
good: :low,
name: "p90_volume",
source: "halstead",
thresholds: %{a: 8_700, b: 16_600, c: 25_000, d: 38_000},
weight: 0.4
},
%{
fix_hint:
"The largest files repeat the same operands heavily — extract named intermediates there",
good: :low,
name: "p90_difficulty",
source: "halstead",
thresholds: %{a: 50, b: 60, c: 70, d: 85},
weight: 0.35
},
%{
fix_hint: "The largest files dominate total implementation effort — decompose them",
good: :low,
name: "p90_effort",
source: "halstead",
thresholds: %{a: 440_000, b: 920_000, c: 1_850_000, d: 3_200_000},
weight: 0.25
}
],
name: "Complexity Outliers"
},
%{
key: :structure,
metrics: [
Expand Down
12 changes: 9 additions & 3 deletions lib/codeqa/health_report/grader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,19 @@ defmodule CodeQA.HealthReport.Grader do
aggregate,
scale \\ Categories.default_grade_scale()
) do
# Convert aggregate format (mean_X keys) to file-metric-like format
# Convert aggregate format to file-metric-like format. mean_X is exposed as X so
# categories read it as a plain metric; p90_X keeps its prefix so a category can
# ask for the outlier tail instead of the average.
file_like =
Map.new(aggregate, fn {source, stats} ->
values =
stats
|> Enum.filter(fn {k, _v} -> String.starts_with?(k, "mean_") end)
|> Map.new(fn {"mean_" <> key, v} -> {key, v} end)
|> Enum.flat_map(fn
{"mean_" <> key, v} -> [{key, v}]
{"p90_" <> _ = key, v} -> [{key, v}]
_other -> []
end)
|> Map.new()

{source, values}
end)
Expand Down
3 changes: 2 additions & 1 deletion test/codeqa/engine/analyzer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ defmodule CodeQA.Engine.AnalyzerTest do
keys
|> Enum.each(fn {key, val} ->
assert String.starts_with?(key, "mean_") or String.starts_with?(key, "std_") or
String.starts_with?(key, "min_") or String.starts_with?(key, "max_")
String.starts_with?(key, "min_") or String.starts_with?(key, "max_") or
String.starts_with?(key, "p90_")

assert is_float(val) or is_integer(val)
end)
Expand Down
6 changes: 3 additions & 3 deletions test/codeqa/health_report/categories_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,14 @@ defmodule CodeQA.HealthReport.CategoriesTest do
end)
end

test "has exactly 24 metrics across 6 categories" do
test "has exactly 27 metrics across 7 categories" do
categories = Categories.defaults()

assert length(categories) == 6
assert length(categories) == 7

metrics = categories |> Enum.flat_map(& &1.metrics)

assert length(metrics) == 24
assert length(metrics) == 27
end
end
end
Loading