Skip to content

(feat): Report mode toggle and top-level directory coverage for large codebases#153

Open
toipa wants to merge 27 commits intoclearlyip:mainfrom
coalition-sre:main
Open

(feat): Report mode toggle and top-level directory coverage for large codebases#153
toipa wants to merge 27 commits intoclearlyip:mainfrom
coalition-sre:main

Conversation

@toipa
Copy link

@toipa toipa commented Feb 9, 2026

Summary

Adds optional report modes and path filtering for large codebases: coverage can be shown per file, by parent directory, by path depth, or by top-level directory only. New option exclude_paths lets you drop paths from the report and from overall coverage. The configured negative_difference_threshold is now shown in the generated report.


Motivation

In projects with many files, the default per-file coverage table can make the report very large and hard to scan. This PR adds toggles to show coverage aggregated by directory (top-level, by depth, or by parent dir) and the ability to exclude paths (e.g. tests, generated code) so reports stay compact and focused.


Changes

1. Show negative_difference_threshold in the report

When negative_difference_threshold is set (non-zero), the report now includes:

  • Maximum allowed coverage drop is -X%

so users can see the configured threshold directly in the generated markdown.

2. Exclude paths from coverage

New input exclude_paths (optional, comma-separated path prefixes):

  • Files whose relative path starts with any prefix are excluded from the report and from overall coverage.
  • Examples: tests/, e2e/, docs/, generated/.
  • Overall coverage is recomputed from the remaining files (line-weighted when Cobertura line counts are available).

Useful to ignore test code, fixtures, or generated files that you don’t want in the coverage report.

3. Report mode: per-file vs aggregated by directory

Four ways to show coverage (mutually exclusive; first applicable option wins):

Input Behaviour
show_coverage_by_top_dir: true One table: coverage aggregated by first path segment only (e.g. src/, reports/, (root)). No per-file rows. Takes precedence over depth and parent dir.
coverage_depth: N (e.g. 2) One table: coverage aggregated by path to depth N (1 = first segment, 2 = first two segments, e.g. src/common/). Ignored if show_coverage_by_top_dir is true. Takes precedence over parent dir.
show_coverage_by_parent_dir: true One table: coverage aggregated by parent directory of each file (e.g. src/common/, src/aws/). Ignored if show_coverage_by_top_dir or coverage_depth is set.
default (all false / unset) Current behaviour: per-file coverage table + overall row.
  • Cobertura: Directory aggregation uses line-weighted coverage (parser now exposes lines_covered / lines_valid per file).
  • Clover: Directory aggregation uses the average of file percentages.
  • Root-level files appear as (root).

4. New inputs summary

Input Default Description
show_coverage_by_top_dir false When true, report is one table by first path segment only.
coverage_depth (empty) When set (e.g. 2), report is one table by path to this depth. Ignored if show_coverage_by_top_dir is true.
show_coverage_by_parent_dir false When true, report is one table by parent dir. Ignored if show_coverage_by_top_dir or coverage_depth is set.
exclude_paths (empty) Comma-separated path prefixes to exclude from report and overall coverage (e.g. tests/, e2e/, docs/).

Usage example:

- uses: clearlyip/code-coverage-report-action@v1
  with:
    filename: 'coverage/clover.xml'
    show_coverage_by_top_dir: true   # or use coverage_depth: 2, or show_coverage_by_parent_dir: true
    exclude_paths: 'tests/, e2e/, docs/'

Implementation notes

  • action.yml: New inputs show_coverage_by_top_dir, coverage_depth, show_coverage_by_parent_dir, exclude_paths.
  • src/interfaces.ts: CoverageFile gains optional lines_covered / lines_valid; Inputs extended for new options.
  • src/utils.ts: getInputs() reads new options; getTopDirFromFile(), getParentDirFromFile(), getPathAtDepth(); isPathExcluded(), filterCoverageByExcludePaths().
  • src/functions.ts: Passes negative_difference_threshold and directory aggregation into context; show_package_coverage and aggregation respect toggles; aggregateCoverageByTopDir() and depth/parent aggregation; exclude applied to coverage before report.
  • src/reports/cobertura/parser/index.ts: Computes and attaches lines_covered / lines_valid per file.
  • Templates: Threshold line; either/or blocks for per-file vs directory table; overall row in both.
  • README: Report contents, new inputs, and exclude_paths documented.
  • Tests: Snapshots and tests updated; new tests for toggle, aggregation, depth, parent dir, and excludes.

toipa and others added 22 commits February 6, 2026 23:04
Co-authored-by: Cursor <cursoragent@cursor.com>
- Maximum allowed coverage drop line now includes 'this run produced z%'
- Uses overall_coverage.difference when base coverage exists
- Keeps minimum/maximum lines consistent (threshold + actual)

Co-authored-by: Cursor <cursoragent@cursor.com>
Summary and overall row difference now show e.g. 0% or -2% without
color/emoji; single difference value instead of separate plain variant.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Templates and README: 'Maximum allowed coverage drop' -> 'Maximum allowed coverage difference'
- Update test expectation and snapshots for plain overall difference and new wording

Co-authored-by: Cursor <cursoragent@cursor.com>
- Table row: difference column keeps colorized value (e.g. ⚪ 0%, 🔴 -1.51%)
- Summary line 'Maximum allowed coverage difference... this run produced':
  use difference_plain (no emoji) for the produced value only
- Add difference_plain to overall row context and use in templates

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Use overall_coverage.difference for #if so the summary line shows even
when difference_plain is 0% (Handlebars treats 0 as falsy)

Co-authored-by: Cursor <cursoragent@cursor.com>
- utils.test: empty base path, colorize edge case, getInputs validation/options,
  formatArtifactName with slashes, parseCoverage non-xml branch
- functions.test: addOverallRow difference_plain, badge and skip_package_coverage,
  fail on negative diff by package, template missing (setFailed)
- Add cobertura-two-packages-same-file.xml fixture and test for mergeFileEntry
- Cobertura parser coverage at 100%; overall coverage ~77%

Co-authored-by: Cursor <cursoragent@cursor.com>
Handlebars renders the number 0 as empty. Use String() so we always pass
a string (e.g. '0%') to the template. Add test that summary line shows
'this run produced `0%`' when coverage difference is zero.

Co-authored-by: Cursor <cursoragent@cursor.com>
When difference_plain is falsy (e.g. Handlebars receives number 0), use
{{else}}0% so backticks always show a value instead of empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
ENG-84585: threshold in report, coverage by top-level dir, and report-mode toggle
…rent directory

- Add input show_coverage_by_parent_dir (replaces top_dir naming)
- Add getParentDirFromFile() to get parent dir of each file
- When enabled, report shows one row per parent dir (e.g. src/common/, src/aws/)
  instead of per-file or a single src/ row

Co-authored-by: Cursor <cursoragent@cursor.com>
…lusive

- Add show_coverage_by_top_dir: aggregate by first path segment (src/, tests/)
- Keep show_coverage_by_parent_dir: aggregate by parent dir (src/common/, src/aws/)
- Validate mutually exclusive: fail if both are true
- Add getTopDirFromFile(), tests for both helpers

Co-authored-by: Cursor <cursoragent@cursor.com>
- Keep show_coverage_by_parent_dir, show_coverage_by_top_dir (mutually exclusive)
- Keep buildCoverageRows (file/top_dir/parent_dir), getTopDirFromFile, getParentDirFromFile
- Integrate main: negative_difference_threshold in context, difference_plain in addOverallRow,
  CoverageFile lines_covered/lines_valid, coverage_by_top_dir + aggregateCoverageByTopDir for template compat
- Resolve utils.test: keep our imports and getTopDir/getParentDir tests, add showCoverageByTopDir/showCoverageByParentDir to getInputs expected

Co-authored-by: Cursor <cursoragent@cursor.com>
…s.ts

Co-authored-by: Cursor <cursoragent@cursor.com>
- functions.test: assert report is grouped by parent dir when
  show_coverage_by_parent_dir is true, and per-file when false
- utils.test: assert getInputs() reads INPUT_SHOW_COVERAGE_BY_PARENT_DIR
  and throws when both top_dir and parent_dir are true

Co-authored-by: Cursor <cursoragent@cursor.com>
The committed dist/ was built before parent_dir was added, so the
action was still running per-file coverage. Rebuild so runs use the
new logic when show_coverage_by_parent_dir is true.

Co-authored-by: Cursor <cursoragent@cursor.com>
- New input coverage_depth (optional): aggregate by path to N segments
  (e.g. 2 -> src/common/, src/aws/). Default unset (no depth grouping).
- Priority: show_coverage_by_top_dir > coverage_depth > show_coverage_by_parent_dir > per-file.
- Add getPathAtDepth() in utils; buildCoverageRows uses depth when set.
- Remove mutual-exclusion throw; priority alone decides when multiple set.
- Tests: getPathAtDepth, getInputs coverageDepth, markdown by depth,
  depth 1 same as top_dir, top_dir overrides depth.
- Lint: fix no-shadow (rename path param), prefer-template in utils.

Co-authored-by: Cursor <cursoragent@cursor.com>
…all %

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ENG-84729 Add parent dir and configurable depth for coverage aggregation
@stacklane-pr-stack-visualizer
Copy link

stacklane-pr-stack-visualizer bot commented Mar 2, 2026

🧱 Stack PR · Standalone PR

@tm1000
Copy link
Member

tm1000 commented Mar 9, 2026

@toipa Thanks for making those changes. I realize its just nit-picking but they did cause true errors in the past :-)

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds directory-level coverage aggregation modes, path exclusion, and threshold display to a GitHub Actions code coverage report action. It enables compact reports for large codebases by grouping coverage by top-level directory, path depth, or parent directory, and allows excluding specified path prefixes from reports.

Changes:

  • Added four new action inputs (show_coverage_by_top_dir, coverage_depth, show_coverage_by_parent_dir, exclude_paths) to control report aggregation and path filtering.
  • Extended Cobertura and Clover parsers to track lines_covered/lines_valid per file, enabling line-weighted directory aggregation and supporting a new filterCoverageZeroLineFiles filter applied unconditionally.
  • Added template support for top-level directory coverage tables and display of negative_difference_threshold in generated markdown reports.

Reviewed changes

Copilot reviewed 15 out of 19 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
action.yml Defines 4 new inputs for report mode/filtering
src/interfaces.ts Adds lines_covered/lines_valid to CoverageFile; extends Inputs and HandlebarContext
src/utils.ts New path helpers, path exclusion, zero-line filtering, and input parsing for new options
src/functions.ts buildCoverageRows for grouped coverage, aggregateCoverageByTopDir, template context with threshold
src/reports/cobertura/parser/index.ts Counts lines per class, merges duplicate files, computes coverage from line counts
src/reports/clover/parser/index.ts Adds lines_covered/lines_valid from file metrics
templates/with-base-coverage.hbs Adds top-dir toggle and threshold display
templates/without-base-coverage.hbs Adds top-dir toggle and threshold display
dist/with-base-coverage.hbs Compiled template copy
dist/without-base-coverage.hbs Compiled template copy
dist/index.js Compiled JS bundle
README.md Documents new inputs and report contents
tests/functions.test.ts Tests for aggregation, top-dir, depth, parent-dir, and threshold display
tests/utils.test.ts Tests for new path helpers, filtering, and input parsing
tests/fixtures/cobertura-parsed.json Updated with line counts and recalculated coverage values
tests/fixtures/cobertura-two-packages-same-file.xml New fixture for same-file-in-two-packages scenario
tests/snapshots/utils.test.ts.snap Updated snapshots with line count fields
tests/snapshots/functions.test.ts.snap Updated snapshots with threshold line and coverage changes

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +243 to +262
const byDir: Record<
string,
{ headSum: number; baseSum: number; count: number }
> = {};
for (const [hash, file] of fileEntries) {
const key = getGroupKey(file.relative);
if (!byDir[key]) {
byDir[key] = { headSum: 0, baseSum: 0, count: 0 };
}
byDir[key].headSum += file.coverage;
byDir[key].count += 1;
if (baseCoverage?.files[hash]) {
byDir[key].baseSum += baseCoverage.files[hash].coverage;
}
}

return Object.entries(byDir)
.map(([pkg, { headSum, baseSum, count }]) => {
const headAvg = roundPercentage(headSum / count);
const baseAvg = roundPercentage(baseSum / count);
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

Bug in directory aggregation: baseAvg is computed as roundPercentage(baseSum / count), but count is the number of head files in the group, while baseSum only includes coverage for files that exist in both head and base. When a directory has files in head that don't exist in base (e.g., new files), the base average will be artificially deflated because baseSum is divided by a count that includes those new files.

For example, if a directory has 3 head files (2 existing in base at 80% each, 1 new), baseAvg = (80+80) / 3 = 53.33% instead of the correct (80+80) / 2 = 80%.

Consider tracking a separate baseCount per group to divide baseSum correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +456 to +494
export function filterCoverageZeroLineFiles(coverage: Coverage): Coverage {
const filtered: Files = {};
for (const [hash, file] of Object.entries(coverage.files)) {
const valid = file.lines_valid ?? 0;
if (valid > 0) {
filtered[hash] = file;
}
}

const fileList = Object.values(filtered);
const hasLineCounts =
fileList.length > 0 &&
fileList.every(
(f: CoverageFile) =>
f.lines_covered !== undefined && f.lines_valid !== undefined
);
let newOverall: number;
if (hasLineCounts && fileList.length > 0) {
const totalCovered = fileList.reduce(
(s, f) => s + (f.lines_covered ?? 0),
0
);
const totalValid = fileList.reduce((s, f) => s + (f.lines_valid ?? 0), 0);
newOverall =
totalValid > 0 ? roundPercentage((totalCovered / totalValid) * 100) : 0;
} else if (fileList.length > 0) {
const sum = fileList.reduce((s, f) => s + f.coverage, 0);
newOverall = roundPercentage(sum / fileList.length);
} else {
newOverall = 0;
}

return {
files: filtered,
coverage: newOverall,
timestamp: coverage.timestamp,
basePath: coverage.basePath
};
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The overall-coverage recomputation logic (lines 421-442 and 465-486) is nearly identical between filterCoverageByExcludePaths and filterCoverageZeroLineFiles. Consider extracting a helper function like recomputeOverallCoverage(files: Files): number to avoid this duplication and reduce the risk of the two implementations drifting apart.

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +294
/**
* Return the first path segment (top-level dir). e.g. "src/common/foo.py" -> "src/", "main.ts" -> "(root)"
*/
export function getTopDirFromFile(relativePath: string): string {
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The JSDoc comment at lines 284-290 (describing determineCommonBasePath parameters/return) is now orphaned — it sits above getTopDirFromFile instead of above determineCommonBasePath. It should be removed or moved to its correct position above determineCommonBasePath (line 339).

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +40
/**
* Merge two file entries for the same path (e.g. multiple classes per file or same file in multiple packages).
* Sums lines_covered and lines_valid; recomputes coverage from aggregated lines when both have line counts.
*/
function mergeFileEntry(
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The JSDoc comment "Parse Packages" at lines 36-39 (in the diff, lines 30-35 in the file) originally belonged to parsePackages but is now orphaned above mergeFileEntry. It should be removed since mergeFileEntry already has its own docstring immediately below.

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +74
headCoverage = filterCoverageZeroLineFiles(headCoverage);
if (baseCoverage !== null) {
baseCoverage = filterCoverageZeroLineFiles(baseCoverage);
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

filterCoverageZeroLineFiles is applied unconditionally to all coverage data (both head and base), even when no new features are opted into. This silently removes files with lines_valid === 0 (or undefined) from reports, changing behavior for all existing users. Previously, such files appeared in coverage reports with 0% coverage. Consider making this filtering opt-in or at least only filtering when lines_valid is explicitly 0 (not undefined), to avoid breaking existing behavior for files that don't have line counts.

Copilot uses AI. Check for mistakes.
Comment on lines +443 to +446
function getTopDir(relative: string): string {
const i = relative.indexOf('/');
return i >= 0 ? relative.slice(0, i + 1) : '(root)';
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

getTopDir is a duplicate of getTopDirFromFile in utils.ts, but without backslash normalization. On Windows paths (or paths with mixed separators), this could produce different grouping keys than getTopDirFromFile (which is used in buildCoverageRows). When showCoverageByTopDir is true, buildCoverageRows groups using getTopDirFromFile (for coverage), while aggregateCoverageByTopDir groups using the local getTopDir (for coverage_by_top_dir), potentially producing inconsistent groups. Use getTopDirFromFile from utils here instead to ensure consistent behavior and avoid duplication.

Copilot uses AI. Check for mistakes.

for (const cls of classes || []) {
const path = cls['@_filename'];
const lineRate = cls['@_line-rate'];
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The variable lineRate is assigned from cls['@_line-rate'] but never used. This appears to be left over from the refactor that switched from using the line rate attribute to counting lines directly. Remove it to avoid confusion.

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +77
const coveredSum =
parseInt(fileMetrics['@_coveredconditionals']) +
parseInt(fileMetrics['@_coveredstatements']) +
parseInt(fileMetrics['@_coveredmethods']);
const codeSum =
parseInt(fileMetrics['@_conditionals']) +
parseInt(fileMetrics['@_statements']) +
parseInt(fileMetrics['@_methods']);
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

These parseInt calls don't specify a radix (second argument). While this matches the existing style in processCoverageMetrics, note that if any of these Clover XML attributes are missing or malformed, parseInt returns NaN, and the resulting coveredSum/codeSum will be NaN. This could cause issues downstream when used in arithmetic (e.g., filterCoverageZeroLineFiles would keep the file since NaN > 0 is false). Consider adding || 0 fallbacks: parseInt(fileMetrics['@_coveredconditionals'], 10) || 0.

Copilot uses AI. Check for mistakes.
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.

3 participants