Skip to content

Commit 8333fae

Browse files
committed
Add patch based code coverage tool
1 parent 8f06d7a commit 8333fae

12 files changed

Lines changed: 870 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Patch based Code Coverage git integration
2+
3+
This tool provides patch-level code coverage integration for Git workflows in the LLVM project.
4+
It helps developers and reviewers to understand which lines introduced by patch are
5+
covered by tests and which are not.
6+
7+
It is designed to integrate with LLVM’s existing infrastructure (e.g., llvm-lit,
8+
coverage instrumentation, and CI), and can be used in both local system system and LLVM GitHub
9+
CI to provide patch coverage for pull requests.
10+
11+
# Overview
12+
Given a set of recent commits:
13+
Extracts the patch (git diff)
14+
Identifies modified source files and lines
15+
Detects modified tests (lit-based)
16+
Rebuilds LLVM with coverage instrumentation
17+
Executes relevant tests using llvm-lit --per-test-coverage
18+
Collects coverage data using llvm-profdata and llvm-cov
19+
Reports:
20+
Covered lines introduced by the patch
21+
Uncovered lines introduced by the patch
22+
23+
24+
# Setup
25+
Build LLVM (standard workflow):
26+
```shell
27+
$ git clone https://github.com/llvm/llvm-project.git
28+
$ cd llvm-project
29+
$ mkdir build
30+
$ cmake -G Ninja -B build -S llvm \
31+
-DCMAKE_BUILD_TYPE=Release \
32+
-DLLVM_ENABLE_ASSERTIONS=ON
33+
-DLLVM_INCLUDE_TESTS=ON
34+
-DLLVM_BUILD_TESTS=ON
35+
36+
$ ninja -C build
37+
```
38+
39+
Add the tool into your path so that you can use it as git subcommand.
40+
```shell
41+
$ export PATH=$PATH:llvm/utils/patch-coverage/
42+
```
43+
44+
# Usage
45+
Run patch coverage for recent commits:
46+
```shell
47+
$ git patch-coverage -b build opt build/test
48+
```
49+
Arguments
50+
Argument Description
51+
-b, --build-dir Path to build directory (default: build)
52+
<binary> Instrumented LLVM binary (e.g., bin/opt)
53+
<test-path> One or more test suite paths (default: build/test)
54+
55+
Optional Arguments
56+
Argument Description
57+
-n, --num-commits Number of recent commits to analyze (default: 1)
58+
-i, --instrumented-build-dir Use pre-instrumented build directory
59+
60+
# Example
61+
```shell
62+
$ git patch-coverage -b build -n 3 bin/opt llvm/test
63+
```
64+
65+
This will:
66+
Generate a patch for the last 3 commits
67+
Identify modified source and test files
68+
Rebuild LLVM with coverage instrumentation (if needed)
69+
Run modified lit tests
70+
Produce per-line coverage for modified code
71+
72+
# Output
73+
The tool reports:
74+
Modified files and lines
75+
Covered lines (executed by tests)
76+
Uncovered lines (not exercised)
77+
Example output:
78+
```
79+
Modified File: llvm/lib/IR/Example.cpp
80+
Covered Line: 42 : foo();
81+
Uncovered Line: 45 : bar();
82+
```

llvm/utils/patch-coverage/__init__.py

Whitespace-only changes.

llvm/utils/patch-coverage/build.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import os
2+
import sys
3+
import subprocess
4+
from utils import compute_patch_hash
5+
from utils import log
6+
7+
8+
def is_configured_build(build_dir):
9+
return os.path.exists(os.path.join(build_dir, "build.ninja")) or os.path.exists(
10+
os.path.join(build_dir, "CMakeCache.txt")
11+
)
12+
13+
14+
def configure_llvm_build(build_dir):
15+
print(f"[patch-coverage] Configuring LLVM build in {build_dir}...")
16+
17+
subprocess.check_call(
18+
[
19+
"cmake",
20+
"-G",
21+
"Ninja",
22+
"-S",
23+
"llvm",
24+
"-B",
25+
build_dir,
26+
"-DCMAKE_BUILD_TYPE=Release",
27+
"-DLLVM_ENABLE_ASSERTIONS=ON",
28+
"-DLLVM_INCLUDE_TESTS=ON",
29+
"-DLLVM_BUILD_TESTS=ON",
30+
]
31+
)
32+
33+
34+
def ensure_llvm_tools(build_dir):
35+
if not is_configured_build(build_dir):
36+
configure_llvm_build(build_dir)
37+
38+
required_tools = [
39+
"bin/llvm-lit",
40+
"bin/FileCheck",
41+
"bin/count",
42+
"bin/not",
43+
"bin/llvm-config",
44+
]
45+
46+
missing = [
47+
tool
48+
for tool in required_tools
49+
if not os.path.exists(os.path.join(build_dir, tool))
50+
]
51+
52+
if not missing:
53+
return
54+
55+
subprocess.check_call(
56+
[
57+
"ninja",
58+
"-C",
59+
build_dir,
60+
"FileCheck",
61+
"count",
62+
"not",
63+
"llvm-config",
64+
]
65+
)
66+
67+
68+
def should_rebuild(build_dir, patch_path):
69+
new_hash = compute_patch_hash(patch_path)
70+
hash_file = os.path.join(build_dir, ".last_patch_hash")
71+
72+
if os.path.exists(hash_file):
73+
old_hash = open(hash_file).read().strip()
74+
if old_hash == new_hash:
75+
return False
76+
77+
with open(hash_file, "w") as f:
78+
f.write(new_hash)
79+
80+
return True
81+
82+
83+
def build_llvm(build_dir, binary):
84+
try:
85+
86+
cwd = os.getcwd()
87+
88+
os.chdir(build_dir)
89+
90+
command = 'find . -type f -name "*.profraw" -delete'
91+
92+
try:
93+
subprocess.run(command, shell=True, check=True)
94+
log(
95+
"Files in build directory with '.profraw' extension deleted successfully."
96+
)
97+
except subprocess.CalledProcessError as e:
98+
log(f"Error: {e}")
99+
log("")
100+
101+
needs_cmake = True
102+
cache_path = os.path.join(build_dir, "CMakeCache.txt")
103+
if os.path.exists(cache_path):
104+
with open(cache_path, "r") as f:
105+
if "LLVM_BUILD_INSTRUMENTED_COVERAGE:BOOL=ON" in f.read():
106+
needs_cmake = False
107+
108+
if needs_cmake:
109+
cmake_command = [
110+
"cmake",
111+
"-DLLVM_BUILD_INSTRUMENTED_COVERAGE=ON",
112+
"-DLLVM_INDIVIDUAL_TEST_COVERAGE=ON",
113+
f"-DCMAKE_C_FLAGS=-fprofile-list={os.path.abspath('fun.list')}",
114+
f"-DCMAKE_CXX_FLAGS=-fprofile-list={os.path.abspath('fun.list')}",
115+
".",
116+
]
117+
118+
subprocess.check_call(cmake_command)
119+
120+
target_name = os.path.basename(binary)
121+
try:
122+
print()
123+
subprocess.check_call(
124+
[
125+
"ninja",
126+
"FileCheck",
127+
"count",
128+
"not",
129+
"llvm-config",
130+
"llvm-cov",
131+
"llvm-profdata",
132+
"llvm-dwarfdump",
133+
"llvm-readobj",
134+
target_name,
135+
]
136+
)
137+
138+
except subprocess.CalledProcessError as ninja_error:
139+
log(f"Error during Ninja build: {ninja_error}")
140+
log("Attempting to build with 'make' using the available processors.")
141+
num_processors = os.cpu_count() or 1
142+
make_command = ["make", f"-j{num_processors}"]
143+
subprocess.check_call(
144+
make_command,
145+
"FileCheck",
146+
"count",
147+
"not",
148+
"llvm-config",
149+
"llvm-cov",
150+
"llvm-profdata",
151+
"llvm-dwarfdump",
152+
"llvm-readobj",
153+
target_name,
154+
)
155+
156+
os.chdir(cwd)
157+
158+
log("LLVM build completed successfully.")
159+
log("")
160+
161+
except subprocess.CalledProcessError as e:
162+
log("Error during LLVM build:", e)
163+
sys.exit(1)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/bash
2+
python3 "$(dirname "$0")/main.py" "$@"

llvm/utils/patch-coverage/lit.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import subprocess
2+
import os
3+
4+
5+
def parse_suite_info(s):
6+
curr_suite = None
7+
res = {}
8+
9+
for line in s.decode().splitlines():
10+
leading_spaces = len(line) - len(line.lstrip(" "))
11+
12+
if leading_spaces == 2 and line.split():
13+
curr_suite = line.split()[0]
14+
elif curr_suite is not None and leading_spaces == 4 and "Source Root:" in line:
15+
assert curr_suite not in res
16+
17+
res[curr_suite] = line.split()[-1]
18+
19+
return res
20+
21+
22+
def find_lit_tests(lit_path, test_paths):
23+
suites_cmd = [lit_path, "--show-suites"] + test_paths
24+
output = subprocess.check_output(suites_cmd)
25+
26+
test_suites = parse_suite_info(output)
27+
28+
tests_cmd = [lit_path, "--show-tests"] + test_paths
29+
output = subprocess.check_output(tests_cmd)
30+
31+
lines = [line.decode() for line in output.splitlines()]
32+
33+
test_info = [line.split() for line in lines if "::" in line]
34+
35+
if test_info is not None and len(test_info) > 0:
36+
if len(test_info[0]) == 3:
37+
return [
38+
os.path.join(test_suites[suite], test_case)
39+
for (suite, sep, test_case) in test_info
40+
]
41+
elif len(test_info[0]) == 4:
42+
return [
43+
os.path.join(test_suites[suite1], test_case)
44+
for (suite1, suite2, sep, test_case) in test_info
45+
]
46+
else:
47+
return []

0 commit comments

Comments
 (0)