diff --git a/lib/codeqa/engine/analyzer.ex b/lib/codeqa/engine/analyzer.ex index d1f0acf..99fc5d2 100644 --- a/lib/codeqa/engine/analyzer.ex +++ b/lib/codeqa/engine/analyzer.ex @@ -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) @@ -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 diff --git a/lib/codeqa/health_report/categories.ex b/lib/codeqa/health_report/categories.ex index 7146cf5..fedf87a 100644 --- a/lib/codeqa/health_report/categories.ex +++ b/lib/codeqa/health_report/categories.ex @@ -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: [ @@ -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 }, %{ @@ -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 }, %{ @@ -95,7 +99,7 @@ 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 }, %{ @@ -103,12 +107,48 @@ defmodule CodeQA.HealthReport.Categories do 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: [ diff --git a/lib/codeqa/health_report/grader.ex b/lib/codeqa/health_report/grader.ex index 4216d60..693c1be 100644 --- a/lib/codeqa/health_report/grader.ex +++ b/lib/codeqa/health_report/grader.ex @@ -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) diff --git a/test/codeqa/engine/analyzer_test.exs b/test/codeqa/engine/analyzer_test.exs index ce655f1..daacfb1 100644 --- a/test/codeqa/engine/analyzer_test.exs +++ b/test/codeqa/engine/analyzer_test.exs @@ -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) diff --git a/test/codeqa/health_report/categories_test.exs b/test/codeqa/health_report/categories_test.exs index 6b4e282..504b0ee 100644 --- a/test/codeqa/health_report/categories_test.exs +++ b/test/codeqa/health_report/categories_test.exs @@ -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