fix(metrics) + perf(health-report): correct separator LOO, scope block-impact to changed files#58
Conversation
separator_counts.analyze_loo/2 subtracted separator counts from an original-file baseline using a block string rebuilt from normalized structural tokens, which collapse inter-token whitespace. The baseline came from analyze(original) while the block came from the normalized token stream, so the subtraction mixed two different tokenizations. Empirically the LOO value diverged from a full re-analyze of the reconstructed file in 0/50 real blocks (pricing.ex) and 15/15 in mega_service.ex — every refactoring-potential delta for these counts has been wrong since the metric gained analyze_loo in #30. Drop analyze_loo/2 so the block-impact analyzer falls back to a full re-analyze, which is consistent with the other 22 file metrics. Add a regression guard (subtractive_loo_test) that runs every analyze_loo metric against a full re-analyze on real sample files with deep nesting, plus a non-vacuous check asserting the fixtures actually collapse whitespace on reconstruction. Verified the guard goes red on the reintroduced bug and green on the fix.
The block-impact analyzer ran a full per-node leave-one-out (each node: a whole-file re-analyze plus two cosine passes over ~1400 behaviors) for every file in the repo, then TopBlocks discarded the nodes of every file not in the diff. On a 647-file project this meant ~30min+ of work per run, almost all of it thrown away — and health-report does it twice (head and base snapshot). Scope per-node computation to the PR's changed files. The codebase aggregate, baseline cosines, similarity and near-duplicate metrics are still computed over every file, so the codebase scope, overall grade and each changed block's codebase-scope delta are unchanged. Only the per-file node output — read for changed files alone — is skipped for the rest. Wiring: resolve changed_files up front (before analysis) and pass them as :node_paths through analyze_codebase to BlockImpactAnalyzer, which feeds out-of-scope files "" so compute_nodes_timed short-circuits to no nodes. nil (no base ref / standalone run) keeps the all-files behavior. Measured on a 647-file project with 18 changed files: head analysis 135s vs an extrapolated ~30min unscoped. Tests assert the scoped run's nodes are bit-identical to the unscoped run for in-scope files, empty for the rest, and that the codebase aggregate is untouched.
🟠 Code Health: C+ (65/100)
%%{init: {'theme': 'neutral'}}%%
xychart-beta
title "Code Health Scores"
x-axis ["Readability", "Complexity", "Structure", "Duplication", "Naming", "Magic Numbers", "Combined Metrics"]
y-axis "Score" 0 --> 100
bar [95, 31, 87, 48, 96, 100, 67]
|
🔍 Top Likely Issues (cosine similarity)
🟢 Readability — A (95/100)Codebase averages: flesch_adapted=97.76, fog_adapted=4.81, avg_tokens_per_line=9.48, avg_line_length=35.65
🔴 Complexity — D- (31/100)Codebase averages: difficulty=41.15, effort=237517.76, volume=4071.80, estimated_bugs=1.36
🟢 Structure — A- (87/100)Codebase averages: branching_density=0.14, mean_depth=3.87, avg_function_lines=8.36, max_depth=9.16, max_function_lines=20.27, variance=6.90, avg_param_count=1.15, max_param_count=2.01
🟠 Duplication — C- (48/100)Codebase averages: redundancy=0.59, bigram_repetition_rate=0.54, trigram_repetition_rate=0.37
🟢 Naming — A (96/100)Codebase averages: entropy=0.89, mean=6.66, variance=18.94, avg_sub_words_per_id=1.18
🟢 Magic Numbers — A (100/100)Codebase averages: density=0.00
🔴 Combined Metrics — D+ (67/100)
🔴 Code Smells — D+ (44/100)
🟡 Consistency — B+ (80/100)
🔴 Dependencies — E+ (19/100)
🟡 Documentation — B+ (83/100)
🟢 Error Handling — A- (92/100)
🟠 File Structure — C- (48/100)
🟡 Function Design — B+ (81/100)
🟢 Naming Conventions — A- (90/100)
🔴 Scope And Assignment — D- (29/100)
🟡 Testing — B+ (83/100)
🟢 Type And Value — A- (90/100)
🟡 Variable Naming — B (74/100)
|
9 medium-severity blocks (expand)🟡 lib/codeqa/block_impact/codebase_impact.ex:14-22 — Name Contains Type SuffixIssues:
14 │ """
15 │ @spec compute(String.t(), String.t(), Node.t(), map()) :: map()
16 │ def compute(path, content, node, files_map) do
17 │ root_tokens = TokenNormalizer.normalize_structural(content)
18 │ reconstructed = FileImpact.reconstruct_without(root_tokens, node)
19 │ updated_files = Map.put(files_map, path, reconstructed)
20 │ Analyzer.analyze_codebase_aggregate(updated_files)
21 │ end
22 │ end
🟡 lib/codeqa/metrics/post_processing/post_processing_metric.ex:19-21 — Name Contains Type SuffixIssues:
19 │ """
20 │ @callback analyze(pipeline_result :: map(), files_map :: map(), opts :: keyword()) :: map()
21 │ end🟡 lib/codeqa/analysis/run_context.ex:7-15 — Name Contains Type SuffixIssues:
7 │ """
8 │
9 │ defstruct [:behavior_config_pid, :file_context_pid]
10 │
11 │ @type t :: %__MODULE__{
12 │ behavior_config_pid: pid(),
13 │ file_context_pid: pid()
14 │ }
15 │ end
🟡 lib/codeqa/ast/nodes/shared.ex:11-27 — Name Contains Type SuffixIssues:
11 │ """
12 │
13 │ alias CodeQA.AST.Enrichment.Node
14 │
15 │ @spec cast_shared(module(), Node.t()) :: struct()
16 │ def cast_shared(target_struct, %Node{} = node),
17 │ do:
18 │ target_struct
19 │ |> struct(
20 │ tokens: node.tokens,
21 │ line_count: node.line_count,
22 │ children: node.children,
23 │ start_line: node.start_line,
24 │ end_line: node.end_line,
25 │ label: node.label
26 │ )
27 │ end
🟡 lib/codeqa/ast/classification/node_type_detector.ex:15-19 — Name Contains Type SuffixIssues:
15 │ """
16 │ @spec detect_types([Node.t()], module()) :: [term()]
17 │ def detect_types(blocks, lang_mod),
18 │ do: blocks |> Enum.map(&NodeClassifier.classify(&1, lang_mod))
19 │ end
🟡 lib/codeqa/metrics/codebase/codebase_metric.ex:1-10 — Name Contains Type SuffixIssues:
1 │ defmodule CodeQA.Metrics.Codebase.CodebaseMetric do
2 │ @moduledoc """
3 │ Behaviour for metrics that operate across an entire codebase.
4 │
5 │ Unlike `FileMetric`, which analyzes a single file, codebase metrics receive
6 │ a map of all source files and can compute cross-file properties such as
7 │ duplication or structural similarity.
8 │
9 │ See [software metrics](https://en.wikipedia.org/wiki/Software_metric).
10 │ """🟡 lib/codeqa/metrics/file/identifier_length_variance.ex:39-44 — Variable used only onceIssues:
39 │ %{
40 │ "mean" => Float.round(mean, 4),
41 │ "variance" => Float.round(variance, 4),
42 │ "std_dev" => Float.round(std_dev, 4),
43 │ "max" => lengths |> Enum.max()
44 │ }🟡 lib/codeqa/ast/signals/structural/colon_indent_signal.ex:1-17 — Variable used only onceIssues:
1 │ defmodule CodeQA.AST.Signals.Structural.ColonIndentSignal do
2 │ alias CodeQA.AST.Lexing.NewlineToken
3 │ alias CodeQA.AST.Lexing.WhitespaceToken
4 │
5 │ @moduledoc """
6 │ Emits `:colon_indent_enclosure` for colon-indented blocks (Python).
7 │
8 │ Only active when `opts[:language_module]` returns true for `uses_colon_indent?/0`. Replaces
9 │ `ParseRules.ColonIndentationRule`.
10 │
11 │ ## Limitation
12 │
13 │ The original rule flushes open blocks at EOF via `close_all_open/1`. Since
14 │ `emit/3` has no end-of-stream callback, open blocks are instead flushed at
15 │ each `<NL>` token. This correctly handles single-statement blocks; multi-line
16 │ blocks are closed at the first newline (conservative).
17 │ """🟡 lib/codeqa/ast/parsing/signal_stream.ex:1-20 — Boolean function missing ? suffixIssues:
1 │ defmodule CodeQA.AST.Parsing.SignalStream do
2 │ @moduledoc """
3 │ Runs a list of `Signal` implementations over a token stream.
4 │
5 │ Each signal receives its own full pass over the token stream and accumulates
6 │ its own state. Signals are independent — no shared state, no cross-signal
7 │ coordination.
8 │
9 │ ## Return value
10 │
11 │ Returns a list of emission lists, one per signal, in the same order as the
12 │ input signal list. Each emission is a 4-tuple:
13 │
14 │ {source, group, name, value}
15 │
16 │ ## Usage
17 │
18 │ SignalStream.run(tokens, [%BlankLineSignal{}, %KeywordSignal{}], [])
19 │ # => [[{BlankLineSignal, :split, :blank_split, 5}, ...], [...]]
20 │ """ |
Why
health-reporttakes ages on real codebases (a 647-file project ran 30min+ per invocation, and it runs the analysis twice — head + base snapshot). While diagnosing that, a pre-existing correctness bug in the leave-one-out (LOO) path surfaced. This PR fixes both.1. fix(metrics): remove incorrect
separator_countssubtractive LOOseparator_counts.analyze_loo/2(shipped since #30) computed the wrong leave-one-out values fordot_count/underscore_count/etc. in refactoring potentials.Root cause: the block-impact analyzer reconstructs "file minus block" from normalized structural tokens, which collapse inter-token whitespace (
normalize(content) joined != content).analyze_loosubtracted separator counts of that whitespace-collapsed block string from a baseline computed on the original file — mixing two different tokenizations.Evidence: the subtractive value diverged from a full re-analyze of the reconstructed file in 0/50 real blocks (
pricing.ex) and 15/15 inmega_service.ex.Fix: drop
analyze_loo/2so the analyzer falls back to a full re-analyze (consistent with the other 22 file metrics). Verified the fix yields 50/50 matches.Adds a regression guard (
test/codeqa/metrics/file/subtractive_loo_test.exs) that runs everyanalyze_loometric against a full re-analyze on real, deeply-nested sample files, plus a non-vacuous check asserting the fixtures actually collapse whitespace on reconstruction. Proven to go red on the reintroduced bug and green on the fix.2. perf(health-report): scope per-node block impact to changed files
The block-impact analyzer ran a full per-node LOO (each node: a whole-file re-analyze + two cosine passes over ~1400 behaviors) for every file in the repo, then
TopBlocksdiscarded the nodes of every file not in the diff — almost all the work, thrown away, twice per run.Fix: scope per-node computation to the PR's changed files. The codebase aggregate, baseline cosines, similarity and near-duplicate metrics are still computed over every file, so the codebase scope, overall grade, and each changed block's codebase-scope delta are unchanged. Only the per-file node output — read for changed files alone — is skipped for the rest.
nil(no base ref / standalone run) keeps the all-files behavior.Measured on a 647-file project with 18 changed files: head analysis 135s vs an extrapolated ~30min unscoped (~13×). The remaining time is now dominated by
near_duplicate_blocks_codebase(43s, runs over all files) — a separate future lever.Tests assert the scoped run's nodes are bit-identical to the unscoped run for in-scope files, empty for the rest, and that the codebase aggregate is untouched.
Verification
nodesfor non-changed files in a way that reaches rendered output (the two repo-wide readers feedworst_offenders/top_nodes, which no formatter renders and which the report path sets to[])