Skip to content

perf(coverage): remove quadratic work in lcov metrics and untested files - #1810

Open
ricardodalarme wants to merge 4 commits into
mainfrom
perf/coverage-hotspots
Open

ricardodalarme wants to merge 4 commits into
mainfrom
perf/coverage-hotspots

Conversation

@ricardodalarme

@ricardodalarme ricardodalarme commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Description

Removes two sources of quadratic work in the coverage post-processing:

  • _addUntestedFiles (--collect-coverage-from all): matched every candidate file against every covered path, calling p.normalize inside the inner loop. Covered paths are now normalized once and indexed by separator-aligned suffix, so each candidate is a single set lookup.
  • CoverageMetrics.fromLcovRecords (--min-coverage / --show-uncovered): copied the whole uncovered-lines map for every record. It now accumulates into one mutable map.

Results

Compiled harness around FlutterCoverageReport.finalize, measured with hyperfine, lcov restored before each run. Synthetic packages: 40 lines per file, 2/3 of the files in the lcov, about a third of lines uncovered.

Scenario Files main This PR
--collect-coverage-from all 1,000 0.13s 0.05s
4,000 1.50s 0.18s
8,000 5.99s 0.33s
16,000 21.06s 1.07s
--min-coverage + --show-uncovered 4,000 0.10s 0.08s
16,000 0.87s 0.31s
Both options 6,000 3.01s 0.41s
Real lcov (app, 914 files), all 914 147ms 94ms
Real lcov (app, 914 files), both options 914 227ms 171ms

On main, the all scenario about quadruples each time the file count doubles; with this PR it grows roughly linearly. On a normal-sized project the gain is small (last two rows), so this mainly helps very large codebases.

Correctness

  • The generated lcov and the printed output are byte-identical to main in all three scenarios (all, metrics, both) on the real lcov and on two synthetic packages (9/9 comparisons).
  • Existing coverage tests pass (47).
  • Behavior difference: the old endsWith match had no path boundary, so a covered xlib/a.dart also counted as covering lib/a.dart. Covered paths now match on a separator boundary. In a randomized comparison of the old and new matching (24,000 cases), 23,511 were identical. The other 489 were all cases where the old code reported "covered" and the new one doesn't, and all were explained by the missing boundary. The new code never marks a file covered that the old one didn't.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

Experiment: accumulate uncovered lines in a mutable map instead of copying it
per record, and match covered paths through a suffix set instead of a nested
loop that normalizes inside the inner iteration.
@github-actions

Copy link
Copy Markdown
Contributor

📊 Cognitive Complexity Analysis

Net Delta: +13 | Added: 0 | Increased: 2 | Improved: 0 | Removed: 1 | Violations: 0

Status Declaration Location Delta Score
🟡 _addUntestedFiles lib/src/coverage/untested_files.dart:L26-74 +8 4 -> 12
🟡 CoverageMetrics.fromLcovRecords lib/src/coverage/coverage_metrics.dart:L22-43 +7 0 -> 7
🗑️ CoverageMetrics._uncoveredLinesWith lib/src/coverage/coverage_metrics.dart -2 2 -> deleted

@ricardodalarme ricardodalarme self-assigned this Nov 13, 2026
@ricardodalarme
ricardodalarme marked this pull request as ready for review November 13, 2026 01:51
@ricardodalarme
ricardodalarme requested a review from a team as a code owner November 13, 2026 01:51
@unicoderbot

unicoderbot Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Heads up — I'm auto-updating this PR by merging the latest main into this branch. No action needed; I'll comment again only if this update hits a conflict or an unexpected error.

totalFound += lines?.found ?? 0;
final file = record.file;
if (file == null) continue;
final missed = _uncoveredLineNumbersOf(record);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make uncoveredLineNumbers a property or getter of record?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean an extension on Record? Personally, I prefer a private function, especially since we’re only using it here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant a getter inside the class, no need for extensions, but not sure if we depend in variables inside coverage metrics for that

@unicoderbot

unicoderbot Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Heads up — I'm auto-updating this PR by merging the latest main into this branch. No action needed; I'll comment again only if this update hits a conflict or an unexpected error.

@unicoderbot

unicoderbot Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Heads up — I'm auto-updating this PR by merging the latest main into this branch. No action needed; I'll comment again only if this update hits a conflict or an unexpected error.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants