Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
77 changes: 77 additions & 0 deletions codecov_cli/services/upload/d_file_fixes.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions codecov_cli/services/upload/upload_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions codecov_cli/services/upload/upload_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 43 additions & 7 deletions tests/helpers/test_upload_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)
),
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
64 changes: 64 additions & 0 deletions tests/services/upload/test_d_file_fixes.py
Original file line number Diff line number Diff line change
@@ -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")}
79 changes: 79 additions & 0 deletions tests/services/upload/test_upload_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]