(feat): Report mode toggle and top-level directory coverage for large codebases#153
(feat): Report mode toggle and top-level directory coverage for large codebases#153toipa wants to merge 27 commits intoclearlyip:mainfrom
Conversation
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
|
🧱 Stack PR · Standalone PR |
Made-with: Cursor
chore: sync upstream clearlyip/main (dependabot deps)
Made-with: Cursor
DRIVEBY: expand single-line if to block form in isPathExcluded
|
@toipa Thanks for making those changes. I realize its just nit-picking but they did cause true errors in the past :-) |
There was a problem hiding this comment.
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_validper file, enabling line-weighted directory aggregation and supporting a newfilterCoverageZeroLineFilesfilter applied unconditionally. - Added template support for top-level directory coverage tables and display of
negative_difference_thresholdin 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.
| 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); |
There was a problem hiding this comment.
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.
| 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 | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| /** | ||
| * Return the first path segment (top-level dir). e.g. "src/common/foo.py" -> "src/", "main.ts" -> "(root)" | ||
| */ | ||
| export function getTopDirFromFile(relativePath: string): string { |
There was a problem hiding this comment.
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).
| /** | ||
| * 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( |
There was a problem hiding this comment.
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.
| headCoverage = filterCoverageZeroLineFiles(headCoverage); | ||
| if (baseCoverage !== null) { | ||
| baseCoverage = filterCoverageZeroLineFiles(baseCoverage); | ||
| } |
There was a problem hiding this comment.
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.
| function getTopDir(relative: string): string { | ||
| const i = relative.indexOf('/'); | ||
| return i >= 0 ? relative.slice(0, i + 1) : '(root)'; | ||
| } |
There was a problem hiding this comment.
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.
|
|
||
| for (const cls of classes || []) { | ||
| const path = cls['@_filename']; | ||
| const lineRate = cls['@_line-rate']; |
There was a problem hiding this comment.
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.
| const coveredSum = | ||
| parseInt(fileMetrics['@_coveredconditionals']) + | ||
| parseInt(fileMetrics['@_coveredstatements']) + | ||
| parseInt(fileMetrics['@_coveredmethods']); | ||
| const codeSum = | ||
| parseInt(fileMetrics['@_conditionals']) + | ||
| parseInt(fileMetrics['@_statements']) + | ||
| parseInt(fileMetrics['@_methods']); |
There was a problem hiding this comment.
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.
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_thresholdin the reportWhen
negative_difference_thresholdis set (non-zero), the report now includes:-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):tests/,e2e/,docs/,generated/.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):
show_coverage_by_top_dir: truesrc/,reports/,(root)). No per-file rows. Takes precedence over depth and parent dir.coverage_depth: N(e.g.2)src/common/). Ignored ifshow_coverage_by_top_diris true. Takes precedence over parent dir.show_coverage_by_parent_dir: truesrc/common/,src/aws/). Ignored ifshow_coverage_by_top_dirorcoverage_depthis set.lines_covered/lines_validper file).(root).4. New inputs summary
show_coverage_by_top_dirfalsecoverage_depth2), report is one table by path to this depth. Ignored ifshow_coverage_by_top_diris true.show_coverage_by_parent_dirfalseshow_coverage_by_top_dirorcoverage_depthis set.exclude_pathstests/, e2e/, docs/).Usage example:
Implementation notes
action.yml: New inputsshow_coverage_by_top_dir,coverage_depth,show_coverage_by_parent_dir,exclude_paths.src/interfaces.ts:CoverageFilegains optionallines_covered/lines_valid;Inputsextended for new options.src/utils.ts:getInputs()reads new options;getTopDirFromFile(),getParentDirFromFile(),getPathAtDepth();isPathExcluded(),filterCoverageByExcludePaths().src/functions.ts: Passesnegative_difference_thresholdand directory aggregation into context;show_package_coverageand aggregation respect toggles;aggregateCoverageByTopDir()and depth/parent aggregation; exclude applied to coverage before report.src/reports/cobertura/parser/index.ts: Computes and attacheslines_covered/lines_validper file.exclude_pathsdocumented.