From dcb1777dbe49242d80b0beb2d7d8995020fa635a Mon Sep 17 00:00:00 2001 From: jmh530 Date: Tue, 13 Oct 2026 18:30:40 -0400 Subject: [PATCH 1/2] fix: preserve exclusion block interiors in upload payloads Expand block boundaries before serializing file fixes as line numbers. The JSON uploader previously discarded marker text without expanding the intervening lines, leaving code inside exclusion blocks counted. Match the legacy worker handling of LCOV_EXCL_START with STOP or END and block-comment boundaries. Keep unmatched markers limited to their own lines and sort the serialized exclusions for deterministic output. Add payload regressions for paired blocks, multiple blocks, unmatched markers, and single-line exclusions. Update the existing block-comment expectation to include its interior lines. AI-Assisted-By: Codex (GPT-6 Astra Medium) --- codecov_cli/services/upload/upload_sender.py | 21 +++++++- tests/helpers/test_upload_sender.py | 50 +++++++++++++++++--- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/codecov_cli/services/upload/upload_sender.py b/codecov_cli/services/upload/upload_sender.py index db8658d3..347fde67 100644 --- a/codecov_cli/services/upload/upload_sender.py +++ b/codecov_cli/services/upload/upload_sender.py @@ -178,12 +178,29 @@ def _get_file_fixers( fixed_lines_with_reason = set( [fixer[0] for fixer in file_fixer.fixed_lines_with_reason] ) - total_fixed_lines = list( + total_fixed_lines = set( file_fixer.fixed_lines_without_reason.union(fixed_lines_with_reason) ) + # The JSON payload has no source text for the worker to expand. + # Match the legacy worker's pairing of sorted block boundaries + # before discarding their reasons. Unpaired markers exclude only + # their own lines, rather than the rest of the file. + starts, stops = [], [] + for line, source in file_fixer.fixed_lines_with_reason: + source = source.strip() + if source.startswith("/*") or "LCOV_EXCL_START" in source: + starts.append(line) + elif ( + source.endswith("*/") + or "LCOV_EXCL_STOP" in source + or "LCOV_EXCL_END" in source + ): + stops.append(line) + for start, stop in zip(sorted(starts), sorted(stops)): + total_fixed_lines.update(range(start + 1, stop)) file_fixers[file_fixer.path.as_posix()] = { "eof": file_fixer.eof, - "lines": total_fixed_lines, + "lines": sorted(total_fixed_lines), } return file_fixers diff --git a/tests/helpers/test_upload_sender.py b/tests/helpers/test_upload_sender.py index 2eb50eec..78fff110 100644 --- a/tests/helpers/test_upload_sender.py +++ b/tests/helpers/test_upload_sender.py @@ -69,9 +69,7 @@ def _upload_ingest_post_json_base(*, file_not_found: bool) -> dict: _report_payload_bytes_coverage = len( - UploadSender()._generate_payload( - upload_collection, {}, ReportType.COVERAGE - ) + UploadSender()._generate_payload(upload_collection, {}, ReportType.COVERAGE) ) request_data = { @@ -90,9 +88,7 @@ def _test_results_ingest_post_json( "commit": random_sha, "service": "github", "report_payload_bytes": len( - UploadSender()._generate_payload( - upload_data, {}, ReportType.TEST_RESULTS - ) + UploadSender()._generate_payload(upload_data, {}, ReportType.TEST_RESULTS) ), } @@ -515,7 +511,7 @@ def test_generate_payload_overall(self, mocked_coverage_file): }, "SwiftExample/Hello.swift": { "eof": 30, - "lines": [1, 17, 3, 22, 7, 9, 12, 14], + "lines": [1, 3, 7, 9, 12, 14, 17, 18, 19, 20, 21, 22], }, "SwiftExample/ViewController.swift": { "eof": None, @@ -606,3 +602,43 @@ def test_coverage_file_format(self, mocker, mocked_coverage_file): "data": "encoded_file_data", "labels": "", } + + +@pytest.mark.parametrize( + "start,stop", + [ + ("// LCOV_EXCL_START", "// LCOV_EXCL_STOP"), + ("// LCOV_EXCL_START", "// LCOV_EXCL_END"), + (" /*", " */"), + ], +) +def test_payload_expands_exclusion_blocks(start, stop): + fixes = UploadCollectionResultFileFixer( + Path("sample.cpp"), {1}, {(3, start), (6, stop)}, None + ) + data = UploadCollectionResult([], [], [fixes]) + payload = json.loads(UploadSender()._generate_payload(data, {})) + assert payload["report_fixes"]["value"]["sample.cpp"]["lines"] == [1, 3, 4, 5, 6] + + +@pytest.mark.parametrize( + "markers,expected", + [ + ({(3, "// LCOV_EXCL_START")}, [3]), + ({(6, "// LCOV_EXCL_STOP")}, [6]), + ({(3, "// LCOV_EXCL_LINE")}, [3]), + ( + { + (3, "// LCOV_EXCL_START"), + (5, "// LCOV_EXCL_STOP"), + (8, "// LCOV_EXCL_START"), + (10, "// LCOV_EXCL_END"), + }, + [3, 4, 5, 8, 9, 10], + ), + ], +) +def test_payload_exclusion_boundaries(markers, expected): + fixes = UploadCollectionResultFileFixer(Path("sample.cpp"), set(), markers, None) + data = UploadCollectionResult([], [], [fixes]) + assert UploadSender()._get_file_fixers(data)["sample.cpp"]["lines"] == expected From d6d7a1cc7531aeae01e793822f5c5662491246fd Mon Sep 17 00:00:00 2001 From: jmh530 Date: Tue, 13 Oct 2026 18:30:40 -0400 Subject: [PATCH 2/2] feat: support coverage exclusion comments in D source files Collect LCOV_EXCL_LINE, START, STOP, and END markers from D line comments, including indented and trailing comments. Upload explicit exclusions as file-fix metadata without changing D coverage reports or filtering unmarked executable lines. Skip ordinary, raw, and backtick strings, character literals, and block comments when scanning for markers. Normalize marker reasons so explanatory text cannot alter block pairing. Conservatively emit no exclusions for files containing token or delimited strings, and document this limitation and the non-nesting block rules. Add scanner and serialized-payload regressions for literals, nested comments, CRLF input, disabled fixes, and neighboring executable lines. Preserve the disable-file-fixes option. AI-Assisted-By: Codex (GPT-6 Astra Medium) --- README.md | 28 +++++++ codecov_cli/services/upload/d_file_fixes.py | 77 ++++++++++++++++++ .../services/upload/upload_collector.py | 11 +++ tests/services/upload/test_d_file_fixes.py | 64 +++++++++++++++ .../services/upload/test_upload_collector.py | 79 +++++++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 codecov_cli/services/upload/d_file_fixes.py create mode 100644 tests/services/upload/test_d_file_fixes.py diff --git a/README.md b/README.md index 08375142..895d393e 100644 --- a/README.md +++ b/README.md @@ -293,3 +293,31 @@ The version tag must match the regex defined on the Makefile (`tag_regex := ^v([ >**Note**: \ >Releases with `test` word in them are created as `draft`. \ >Releases with `beta` word in them are created as `pre-release`. + +### D coverage exclusions + +When file fixes are enabled, the CLI recognizes these markers in D `//` +comments, including indented comments and comments after executable code: + +```d +return double.nan; // LCOV_EXCL_LINE + +// LCOV_EXCL_START +fallback(); +// LCOV_EXCL_STOP +``` + +`LCOV_EXCL_LINE` excludes only the marked line. `LCOV_EXCL_START` and +`LCOV_EXCL_STOP` delimit an excluded block; `LCOV_EXCL_END` is also accepted +as a closing marker. Pair block markers in source order and do not nest them. +Branch-only exclusion markers are not supported for D's line-based reports. + +Markers must appear immediately after `//` and whitespace. Text in ordinary, +raw, or backtick strings, character literals, and block comments is ignored. +As a conservative limitation, files containing D token or delimited strings +(`q{...}` or `q"..."`) receive no exclusions. These forms require a fuller D +lexer to distinguish their contents safely. + +The source files must be available to the CLI. Exclusions are uploaded as +file-fix metadata; the original `.lst` reports are not modified. Setting +`--disable-file-fixes` disables this processing. diff --git a/codecov_cli/services/upload/d_file_fixes.py b/codecov_cli/services/upload/d_file_fixes.py new file mode 100644 index 00000000..341a6bd6 --- /dev/null +++ b/codecov_cli/services/upload/d_file_fixes.py @@ -0,0 +1,77 @@ +"""Collect explicit coverage markers from D line comments. + +This is deliberately not a D parser. Token/delimited strings are skipped +conservatively by returning no fixes for the file. +""" + +import re + +_MARKER = re.compile(r"//\s+LCOV_EXCL_(LINE|START|STOP|END)\b") + + +def d_exclusion_markers(source: str) -> set[tuple[int, str]]: + markers = set() + index, line = 0, 1 + quote = None + raw = False + block = False + nested = 0 + while index < len(source): + char = source[index] + pair = source[index : index + 2] + if char == "\n": + line += 1 + if block: + if pair == "*/": + block = False + index += 2 + continue + elif nested: + if pair == "/+": + nested += 1 + index += 2 + continue + if pair == "+/": + nested -= 1 + index += 2 + continue + elif quote: + if char == "\\" and not raw: + # Escapes cannot start comments or close a quoted literal. + if index + 1 < len(source) and source[index + 1] == "\n": + line += 1 + index += 2 + continue + if char == quote: + quote = None + elif pair == "//": + end = source.find("\n", index) + if end < 0: + end = len(source) + match = _MARKER.match(source[index:end]) + if match: + # Only send the marker, not explanatory text that might name + # another marker and confuse legacy block expansion. + markers.add((line, "// LCOV_EXCL_" + match[1] + "\n")) + index = end + continue + elif pair == "/*": + block = True + index += 2 + continue + elif pair == "/+": + nested = 1 + index += 2 + continue + elif pair in ('q"', "q{"): + # D token/delimited strings require a fuller lexer. Never risk + # treating their contents as instructions to exclude real code. + return set() + elif pair == 'r"': + quote, raw = '"', True + index += 2 + continue + elif char in ('"', "'", "`"): + quote, raw = char, char == "`" + index += 1 + return markers diff --git a/codecov_cli/services/upload/upload_collector.py b/codecov_cli/services/upload/upload_collector.py index 22310e2f..358fcd6a 100644 --- a/codecov_cli/services/upload/upload_collector.py +++ b/codecov_cli/services/upload/upload_collector.py @@ -83,6 +83,9 @@ def _produce_file_fixes( ) file_regex_patterns = { + # D reports already identify executable lines. Only collect explicit + # exclusion markers, including those indented inside functions. + "*.d": fix_patterns_to_apply([], [], False), "*.kt": kt_patterns_to_apply, "*.go": go_patterns_to_apply, "*.dart": dart_patterns_to_apply, @@ -116,6 +119,14 @@ def _get_file_fixes( try: with open(filename, "r", encoding="utf-8") as f: + if path.suffix == ".d": + from codecov_cli.services.upload.d_file_fixes import ( + d_exclusion_markers, + ) + + return UploadCollectionResultFileFixer( + path, set(), d_exclusion_markers(f.read()), None + ) # If lineno is unset that means that the # file is empty thus the eof should be 0 # so lineno will be set to -1 here diff --git a/tests/services/upload/test_d_file_fixes.py b/tests/services/upload/test_d_file_fixes.py new file mode 100644 index 00000000..d50f8dba --- /dev/null +++ b/tests/services/upload/test_d_file_fixes.py @@ -0,0 +1,64 @@ +import pytest + +from codecov_cli.services.upload.d_file_fixes import d_exclusion_markers + + +@pytest.mark.parametrize( + "literal", + [ + '"// LCOV_EXCL_LINE"', + '"escaped \\" // LCOV_EXCL_LINE"', + "`// LCOV_EXCL_LINE`", + 'r"// LCOV_EXCL_LINE"', + '"first\n// LCOV_EXCL_START\nlast"', + "`first\n// LCOV_EXCL_START\nlast`", + ], +) +def test_markers_in_strings_are_not_exclusions(literal): + assert d_exclusion_markers(f"auto text = {literal};\n") == set() + + +@pytest.mark.parametrize( + "comment", + [ + "/*\n// LCOV_EXCL_START\n*/", + "/+ outer /+ inner +/\n// LCOV_EXCL_START\n+/", + ], +) +def test_markers_in_block_comments_are_not_exclusions(comment): + assert d_exclusion_markers(comment) == set() + + +@pytest.mark.parametrize("ending", ["STOP", "END"]) +def test_line_and_block_markers(ending): + source = ( + 'auto text = "// LCOV_EXCL_START"; // LCOV_EXCL_LINE reason\r\n' + "keep();\r\n" + " // LCOV_EXCL_START\r\n" + "excluded();\r\n" + f" // LCOV_EXCL_{ending}" + ) + assert d_exclusion_markers(source) == { + (1, "// LCOV_EXCL_LINE\n"), + (3, "// LCOV_EXCL_START\n"), + (5, f"// LCOV_EXCL_{ending}\n"), + } + + +def test_explanation_cannot_open_a_block(): + assert d_exclusion_markers("return; // LCOV_EXCL_LINE unlike LCOV_EXCL_START") == { + (1, "// LCOV_EXCL_LINE\n") + } + + +@pytest.mark.parametrize( + "source", ['auto text = q"[text]";', "auto text = q{ int n; };"] +) +def test_token_strings_disable_exclusions_conservatively(source): + assert d_exclusion_markers("// LCOV_EXCL_START\n" + source) == set() + + +def test_character_literal_and_invalid_marker(): + assert d_exclusion_markers( + "auto c = '\\''; // LCOV_EXCL_LINE\n// LCOV_EXCL_LINES\n" + ) == {(1, "// LCOV_EXCL_LINE\n")} diff --git a/tests/services/upload/test_upload_collector.py b/tests/services/upload/test_upload_collector.py index 38d2f3e4..fc1bd97d 100644 --- a/tests/services/upload/test_upload_collector.py +++ b/tests/services/upload/test_upload_collector.py @@ -222,3 +222,82 @@ def test_generate_network_with_no_versioning_system(tmp_path): (tmp_path / "coverage.xml").touch() found_files = versioning_system.list_relevant_files(tmp_path) assert len(found_files) == 1 + + +def test_fix_d_exclusion_blocks(tmp_path): + source = tmp_path / "sample.d" + source.write_text( + "void main() {\n" + " before();\n" + " // LCOV_EXCL_START\n" + " excluded();\n" + " // LCOV_EXCL_STOP\n" + " after();\n" + "}\n", + encoding="utf-8", + ) + collector = UploadCollector(None, None, None, None) + fixes = collector._produce_file_fixes([source]) + assert len(fixes) == 1 + assert fixes[0].eof is None + assert fixes[0].fixed_lines_without_reason == set() + assert fixes[0].fixed_lines_with_reason == { + (3, "// LCOV_EXCL_START\n"), + (5, "// LCOV_EXCL_STOP\n"), + } + + +def test_fix_d_exclusions_disabled(tmp_path): + source = tmp_path / "sample.d" + source.write_text("// LCOV_EXCL_START\nreturn;\n// LCOV_EXCL_END\n") + collector = UploadCollector(None, None, None, None, True) + assert collector._produce_file_fixes([source]) == [] + + +def test_fix_d_preserves_unmarked_code(tmp_path): + source = tmp_path / "sample.d" + source.write_text('void main() {\n auto text = "// LCOV_EXCL_START";\n}\n') + collector = UploadCollector(None, None, None, None) + fixes = collector._produce_file_fixes([source]) + assert fixes[0].fixed_lines_without_reason == set() + assert fixes[0].fixed_lines_with_reason == set() + + +def test_d_exclusion_block_in_upload_payload(tmp_path): + import json + from codecov_cli.services.upload.upload_sender import UploadSender + from codecov_cli.types import UploadCollectionResult + + source = tmp_path / "sample.d" + source.write_text( + "void main() {\n" + " before();\n" + " // LCOV_EXCL_START\n" + " excluded();\n" + " // LCOV_EXCL_STOP\n" + " after();\n" + "}\n" + ) + fixes = UploadCollector(None, None, None, None)._produce_file_fixes([source]) + payload = json.loads( + UploadSender()._generate_payload(UploadCollectionResult([], [], fixes), {}) + ) + assert payload["report_fixes"]["value"][source.as_posix()]["lines"] == [3, 4, 5] + + +def test_d_single_line_exclusion_in_upload_payload(tmp_path): + import json + from codecov_cli.services.upload.upload_sender import UploadSender + from codecov_cli.types import UploadCollectionResult + + source = tmp_path / "sample.d" + source.write_text( + 'auto text = "// LCOV_EXCL_LINE";\n' + "return double.nan; // LCOV_EXCL_LINE\n" + "keep();\n" + ) + fixes = UploadCollector(None, None, None, None)._produce_file_fixes([source]) + payload = json.loads( + UploadSender()._generate_payload(UploadCollectionResult([], [], fixes), {}) + ) + assert payload["report_fixes"]["value"][source.as_posix()]["lines"] == [2]