Skip to content

Commit 6b359b9

Browse files
authored
feat: add test coverage exclusion for Yul preprocessor (#1037)
# Rationale for this change Currently we do not do test coverage exclusion for the Yul preprocessor hence code coverage fails. Now we are fixing this.
1 parent 530d96a commit 6b359b9

File tree

2 files changed

+118
-7
lines changed

2 files changed

+118
-7
lines changed

solidity/preprocessor/test_yul_preprocessor.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@ def test_self_import(self):
237237
# Verify it's usable in the second block
238238
assert "let value := utilFunc(10)" in result
239239

240+
# IMPORTANT: When importing from self (different assembly block in same file),
241+
# the imported function SHOULD have coverage exclusion markers
242+
assert "function exclude_coverage_start_utilFunc() {}" in result
243+
assert "function exclude_coverage_stop_utilFunc() {}" in result
244+
240245
def test_self_import_multiple(self):
241246
"""Test importing multiple functions from a different assembly block in the same file."""
242247
test_dir = self.test_files_dir / "self_import"
@@ -254,6 +259,13 @@ def test_self_import_multiple(self):
254259
assert "let doubled := helper(5)" in result
255260
assert "let increased := anotherHelper(doubled)" in result
256261

262+
# IMPORTANT: When importing from self (different assembly block in same file),
263+
# the imported functions SHOULD have coverage exclusion markers
264+
assert "function exclude_coverage_start_helper() {}" in result
265+
assert "function exclude_coverage_stop_helper() {}" in result
266+
assert "function exclude_coverage_start_anotherHelper() {}" in result
267+
assert "function exclude_coverage_stop_anotherHelper() {}" in result
268+
257269
def test_circular_with_external_import(self):
258270
"""Test that C can import from a circular group A-B."""
259271
test_dir = self.test_files_dir / "circular_regular"
@@ -602,17 +614,90 @@ def test_slither_comments_preserved(self):
602614
# Should have slither-disable-next-line before simpleFunction
603615
assert "// slither-disable-next-line write-after-write" in result
604616

617+
# Verify coverage exclusion markers are added
618+
assert "function exclude_coverage_start_complexFunction() {}" in result
619+
assert "function exclude_coverage_stop_complexFunction() {}" in result
620+
assert "function exclude_coverage_start_simpleFunction() {}" in result
621+
assert "function exclude_coverage_stop_simpleFunction() {}" in result
622+
605623
# Verify the ordering is correct (disable-start comes before the function)
606624
start_idx = result.find("// slither-disable-start cyclomatic-complexity")
625+
cov_start_idx = result.find("function exclude_coverage_start_complexFunction")
607626
func_idx = result.find("function complexFunction")
627+
cov_stop_idx = result.find("function exclude_coverage_stop_complexFunction")
608628
end_idx = result.find("// slither-disable-end cyclomatic-complexity")
609629
assert (
610-
start_idx < func_idx < end_idx
611-
), "Slither comments should wrap the function"
630+
start_idx < cov_start_idx < func_idx < cov_stop_idx < end_idx
631+
), "Slither comments and coverage markers should properly wrap the function"
612632

613633
# Verify no duplicate disable-end comments
614634
assert result.count("// slither-disable-end cyclomatic-complexity") == 1
615635

636+
def test_coverage_exclusion_for_external_imports(self):
637+
"""Test that functions imported from external files get coverage exclusion markers."""
638+
test_dir = self.test_files_dir / "basic_import"
639+
target_file = test_dir / "main.presl"
640+
641+
preprocessor = YulPreprocessor(root_dir=test_dir)
642+
result = preprocessor.process_file(target_file)
643+
644+
# Verify the imported function has coverage exclusion markers
645+
assert "function exclude_coverage_start_add5() {}" in result
646+
assert "function exclude_coverage_stop_add5() {}" in result
647+
648+
# Verify the markers wrap the function
649+
start_idx = result.find("function exclude_coverage_start_add5")
650+
func_idx = result.find("function add5(x) -> result")
651+
stop_idx = result.find("function exclude_coverage_stop_add5")
652+
assert (
653+
start_idx < func_idx < stop_idx
654+
), "Coverage markers should wrap the imported function"
655+
656+
def test_coverage_exclusion_circular_dependencies(self):
657+
"""Test that functions in their own file are NOT coverage-excluded in circular dependencies."""
658+
test_dir = self.test_files_dir / "circular_regular"
659+
file_a = test_dir / "a.presl"
660+
file_b = test_dir / "b.presl"
661+
662+
preprocessor = YulPreprocessor(root_dir=test_dir)
663+
result_a = preprocessor.process_file(file_a)
664+
result_b = preprocessor.process_file(file_b)
665+
666+
# In a.post.sol, funcA is defined locally so it should NOT have coverage exclusion
667+
# but funcB is imported so it SHOULD have coverage exclusion
668+
669+
# funcB imported into a.post.sol should have coverage exclusion
670+
assert "function exclude_coverage_start_funcB() {}" in result_a
671+
assert "function exclude_coverage_stop_funcB() {}" in result_a
672+
673+
# funcA imported into b.post.sol should have coverage exclusion
674+
assert "function exclude_coverage_start_funcA() {}" in result_b
675+
assert "function exclude_coverage_stop_funcA() {}" in result_b
676+
677+
# IMPORTANT: funcA in a.post.sol should NOT have coverage exclusion
678+
# because it's defined in a.presl (the source file for a.post.sol)
679+
assert "function exclude_coverage_start_funcA() {}" not in result_a
680+
assert "function exclude_coverage_stop_funcA() {}" not in result_a
681+
682+
# Similarly, funcB in b.post.sol should NOT have coverage exclusion
683+
# because it's defined in b.presl (the source file for b.post.sol)
684+
assert "function exclude_coverage_start_funcB() {}" not in result_b
685+
assert "function exclude_coverage_stop_funcB() {}" not in result_b
686+
687+
def test_coverage_exclusion_with_dependencies(self):
688+
"""Test that transitive dependencies also get coverage exclusion markers."""
689+
test_dir = self.test_files_dir / "multiple_imports"
690+
target_file = test_dir / "calculator.presl"
691+
692+
preprocessor = YulPreprocessor(root_dir=test_dir)
693+
result = preprocessor.process_file(target_file)
694+
695+
# Both imported functions should have coverage exclusion markers
696+
assert "function exclude_coverage_start_multiply() {}" in result
697+
assert "function exclude_coverage_stop_multiply() {}" in result
698+
assert "function exclude_coverage_start_divide() {}" in result
699+
assert "function exclude_coverage_stop_divide() {}" in result
700+
616701

617702
if __name__ == "__main__":
618703
pytest.main([__file__, "-v"])

solidity/preprocessor/yul_preprocessor.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __init__(
4040
full_text: str,
4141
pre_comments: str = "",
4242
post_comments: str = "",
43+
source_file: Optional[Path] = None,
4344
):
4445
self.name = name
4546
self.signature = signature # function name(...) -> ...
@@ -51,6 +52,7 @@ def __init__(
5152
self.post_comments = (
5253
post_comments # Comments after function (e.g., slither-disable-end)
5354
)
55+
self.source_file = source_file # The .presl file where this function is defined
5456

5557
def __eq__(self, other):
5658
if not isinstance(other, YulFunction):
@@ -120,7 +122,9 @@ def find_assembly_blocks(self, content: str) -> List[Tuple[int, int, str]]:
120122

121123
return blocks
122124

123-
def extract_yul_functions(self, assembly_content: str) -> Dict[str, YulFunction]:
125+
def extract_yul_functions(
126+
self, assembly_content: str, source_file: Optional[Path] = None
127+
) -> Dict[str, YulFunction]:
124128
"""
125129
Extract all Yul function definitions from an assembly block.
126130
Returns dict mapping function name to YulFunction object.
@@ -238,6 +242,7 @@ def extract_yul_functions(self, assembly_content: str) -> Dict[str, YulFunction]
238242
full_text=full_text,
239243
pre_comments=pre_comments,
240244
post_comments=post_comments,
245+
source_file=source_file,
241246
)
242247
else:
243248
i += 1
@@ -390,7 +395,7 @@ def collect_all_functions_in_cycle(
390395
assembly_blocks = self.find_assembly_blocks(content)
391396

392397
for _, _, block_content in assembly_blocks:
393-
functions = self.extract_yul_functions(block_content)
398+
functions = self.extract_yul_functions(block_content, file_path)
394399
for func_name, func in functions.items():
395400
if func_name in all_functions:
396401
# Check for signature conflicts
@@ -425,6 +430,9 @@ def process_file(
425430
Returns the processed content.
426431
Handles circular dependencies by processing dependency cycles as unified groups.
427432
"""
433+
# Ensure file_path is resolved to an absolute path for consistent comparison
434+
file_path = file_path.resolve()
435+
428436
if processing_stack is None:
429437
processing_stack = []
430438

@@ -523,7 +531,9 @@ def process_assembly_block(
523531
current_cycle_key = frozenset(cycle_group)
524532

525533
# Extract local functions from this block to detect what needs to be deduplicated
526-
local_functions = self.extract_yul_functions(block_content)
534+
# These are functions defined in THIS specific assembly block
535+
local_functions = self.extract_yul_functions(block_content, current_file)
536+
local_function_names = set(local_functions.keys())
527537

528538
i = 0
529539
while i < len(lines):
@@ -623,11 +633,27 @@ def process_assembly_block(
623633
if imported_functions:
624634
func_lines = []
625635
for func in imported_functions.values():
636+
# A function should NOT have coverage exclusion if it's defined in THIS assembly block
637+
# It SHOULD have coverage exclusion if it's imported from:
638+
# 1. A different file (func.source_file != current_file)
639+
# 2. The same file but a different assembly block (func.name not in local_function_names)
640+
is_truly_local = func.name in local_function_names
641+
626642
# Include pre-comments (e.g., slither-disable-start)
627643
if func.pre_comments:
628644
func_lines.append(func.pre_comments)
645+
# Add coverage exclusion start marker only for non-local functions
646+
if not is_truly_local:
647+
func_lines.append(
648+
f" function exclude_coverage_start_{func.name}() {{}} // solhint-disable-line no-empty-blocks"
649+
)
629650
# Add the function itself
630651
func_lines.append(func.full_text)
652+
# Add coverage exclusion stop marker only for non-local functions
653+
if not is_truly_local:
654+
func_lines.append(
655+
f" function exclude_coverage_stop_{func.name}() {{}} // solhint-disable-line no-empty-blocks"
656+
)
631657
# Include post-comments (e.g., slither-disable-end)
632658
if func.post_comments:
633659
func_lines.append(func.post_comments)
@@ -666,7 +692,7 @@ def resolve_import(
666692
external_deps = {} # Track functions imported from external files
667693

668694
for _, _, block_content in assembly_blocks:
669-
functions = self.extract_yul_functions(block_content)
695+
functions = self.extract_yul_functions(block_content, current_file)
670696
all_functions.update(functions)
671697

672698
# Also resolve any imports within this block to get external dependencies
@@ -771,7 +797,7 @@ def resolve_import(
771797
# Extract all functions from all assembly blocks
772798
all_functions = {}
773799
for _, _, block_content in assembly_blocks:
774-
functions = self.extract_yul_functions(block_content)
800+
functions = self.extract_yul_functions(block_content, target_file)
775801
all_functions.update(functions)
776802

777803
# Check if the requested function exists

0 commit comments

Comments
 (0)