covisible 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- covisible/__init__.py +3 -0
- covisible/analysis/__init__.py +17 -0
- covisible/analysis/blame.py +232 -0
- covisible/analysis/diff.py +229 -0
- covisible/analysis/grouping.py +218 -0
- covisible/analysis/history.py +130 -0
- covisible/analysis/pr_coverage.py +239 -0
- covisible/analysis/treemap.py +190 -0
- covisible/assets/css/style.css +2890 -0
- covisible/assets/favicon.svg +12 -0
- covisible/assets/js/app.js +203 -0
- covisible/assets/logo.png +0 -0
- covisible/cli.py +622 -0
- covisible/core/__init__.py +17 -0
- covisible/core/ignore.py +240 -0
- covisible/core/models.py +226 -0
- covisible/parsers/__init__.py +6 -0
- covisible/parsers/gcov_json.py +124 -0
- covisible/parsers/lcov.py +149 -0
- covisible/report/__init__.py +5 -0
- covisible/report/generator.py +1002 -0
- covisible/report/markdown.py +72 -0
- covisible/report/templates/base.html +48 -0
- covisible/report/templates/components/authors.html +24 -0
- covisible/report/templates/components/sunburst.html +452 -0
- covisible/report/templates/components/treemap.html +97 -0
- covisible/report/templates/components/trend.html +170 -0
- covisible/report/templates/directory.html +137 -0
- covisible/report/templates/file.html +370 -0
- covisible/report/templates/index.html +846 -0
- covisible/utils/__init__.py +5 -0
- covisible/utils/demangle.py +112 -0
- covisible-0.1.0.dist-info/METADATA +154 -0
- covisible-0.1.0.dist-info/RECORD +37 -0
- covisible-0.1.0.dist-info/WHEEL +4 -0
- covisible-0.1.0.dist-info/entry_points.txt +2 -0
- covisible-0.1.0.dist-info/licenses/LICENSE +21 -0
covisible/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Coverage analysis tools."""
|
|
2
|
+
|
|
3
|
+
from covisible.analysis.blame import GitBlameAnalyzer, get_uncovered_blame_summary
|
|
4
|
+
from covisible.analysis.diff import DiffAnalyzer, DiffHunk, FileDiff
|
|
5
|
+
from covisible.analysis.pr_coverage import PRCoverageAnalyzer
|
|
6
|
+
from covisible.analysis.treemap import TreemapBuilder, build_treemap_data
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DiffAnalyzer",
|
|
10
|
+
"DiffHunk",
|
|
11
|
+
"FileDiff",
|
|
12
|
+
"GitBlameAnalyzer",
|
|
13
|
+
"PRCoverageAnalyzer",
|
|
14
|
+
"TreemapBuilder",
|
|
15
|
+
"build_treemap_data",
|
|
16
|
+
"get_uncovered_blame_summary",
|
|
17
|
+
]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Git blame integration for uncovered code analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class BlameInfo:
|
|
13
|
+
"""Blame information for a single line."""
|
|
14
|
+
|
|
15
|
+
line_number: int
|
|
16
|
+
commit_hash: str
|
|
17
|
+
author: str
|
|
18
|
+
author_email: str
|
|
19
|
+
timestamp: str
|
|
20
|
+
line_content: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AuthorStats:
|
|
25
|
+
"""Statistics for a single author."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
email: str
|
|
29
|
+
total_uncovered_lines: int = 0
|
|
30
|
+
files: set[str] = field(default_factory=set)
|
|
31
|
+
lines: list[tuple[str, int]] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"name": self.name,
|
|
36
|
+
"email": self.email,
|
|
37
|
+
"total_uncovered_lines": self.total_uncovered_lines,
|
|
38
|
+
"files_count": len(self.files),
|
|
39
|
+
"files": sorted(self.files),
|
|
40
|
+
"lines": self.lines[:20],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GitBlameAnalyzer:
|
|
45
|
+
"""Analyzes git blame for uncovered lines."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, repo_path: Path | str | None = None):
|
|
48
|
+
self.repo_path = Path(repo_path) if repo_path else Path.cwd()
|
|
49
|
+
self._cache: dict[Path, dict[int, BlameInfo]] = {}
|
|
50
|
+
|
|
51
|
+
def get_blame_for_file(self, file_path: Path | str) -> dict[int, BlameInfo]:
|
|
52
|
+
"""Get blame information for all lines in a file.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
file_path: Path to the file
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dictionary mapping line numbers to BlameInfo
|
|
59
|
+
"""
|
|
60
|
+
file_path = Path(file_path)
|
|
61
|
+
|
|
62
|
+
if file_path in self._cache:
|
|
63
|
+
return self._cache[file_path]
|
|
64
|
+
|
|
65
|
+
result: dict[int, BlameInfo] = {}
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
cmd = [
|
|
69
|
+
"git", "blame",
|
|
70
|
+
"--line-porcelain",
|
|
71
|
+
str(file_path),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
output = subprocess.run(
|
|
75
|
+
cmd,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
cwd=self.repo_path,
|
|
79
|
+
check=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
result = self._parse_porcelain_blame(output.stdout)
|
|
83
|
+
|
|
84
|
+
except subprocess.CalledProcessError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
self._cache[file_path] = result
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
def _parse_porcelain_blame(self, output: str) -> dict[int, BlameInfo]:
|
|
91
|
+
"""Parse git blame --line-porcelain output."""
|
|
92
|
+
result: dict[int, BlameInfo] = {}
|
|
93
|
+
lines = output.split("\n")
|
|
94
|
+
|
|
95
|
+
i = 0
|
|
96
|
+
while i < len(lines):
|
|
97
|
+
line = lines[i]
|
|
98
|
+
if not line:
|
|
99
|
+
i += 1
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
parts = line.split(" ")
|
|
103
|
+
if len(parts) < 3:
|
|
104
|
+
i += 1
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
commit_hash = parts[0]
|
|
108
|
+
if len(commit_hash) != 40:
|
|
109
|
+
i += 1
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
line_number = int(parts[2])
|
|
113
|
+
|
|
114
|
+
author = ""
|
|
115
|
+
author_email = ""
|
|
116
|
+
timestamp = ""
|
|
117
|
+
content = ""
|
|
118
|
+
|
|
119
|
+
i += 1
|
|
120
|
+
while i < len(lines) and not lines[i].startswith("\t"):
|
|
121
|
+
if lines[i].startswith("author "):
|
|
122
|
+
author = lines[i][7:]
|
|
123
|
+
elif lines[i].startswith("author-mail "):
|
|
124
|
+
author_email = lines[i][12:].strip("<>")
|
|
125
|
+
elif lines[i].startswith("author-time "):
|
|
126
|
+
timestamp = lines[i][12:]
|
|
127
|
+
i += 1
|
|
128
|
+
|
|
129
|
+
if i < len(lines) and lines[i].startswith("\t"):
|
|
130
|
+
content = lines[i][1:]
|
|
131
|
+
i += 1
|
|
132
|
+
|
|
133
|
+
result[line_number] = BlameInfo(
|
|
134
|
+
line_number=line_number,
|
|
135
|
+
commit_hash=commit_hash,
|
|
136
|
+
author=author,
|
|
137
|
+
author_email=author_email,
|
|
138
|
+
timestamp=timestamp,
|
|
139
|
+
line_content=content,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
def get_blame_for_lines(
|
|
145
|
+
self, file_path: Path | str, line_numbers: list[int]
|
|
146
|
+
) -> dict[int, BlameInfo]:
|
|
147
|
+
"""Get blame information for specific lines.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
file_path: Path to the file
|
|
151
|
+
line_numbers: List of line numbers to get blame for
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Dictionary mapping line numbers to BlameInfo
|
|
155
|
+
"""
|
|
156
|
+
all_blame = self.get_blame_for_file(file_path)
|
|
157
|
+
return {ln: all_blame[ln] for ln in line_numbers if ln in all_blame}
|
|
158
|
+
|
|
159
|
+
def analyze_uncovered_lines(
|
|
160
|
+
self,
|
|
161
|
+
uncovered_by_file: dict[Path, list[int]],
|
|
162
|
+
) -> dict[str, AuthorStats]:
|
|
163
|
+
"""Analyze who wrote uncovered lines.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
uncovered_by_file: Dictionary mapping file paths to uncovered line numbers
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Dictionary mapping author emails to their stats
|
|
170
|
+
"""
|
|
171
|
+
author_stats: dict[str, AuthorStats] = {}
|
|
172
|
+
|
|
173
|
+
for file_path, line_numbers in uncovered_by_file.items():
|
|
174
|
+
blame_info = self.get_blame_for_lines(file_path, line_numbers)
|
|
175
|
+
|
|
176
|
+
for line_num, info in blame_info.items():
|
|
177
|
+
email = info.author_email or info.author
|
|
178
|
+
|
|
179
|
+
if email not in author_stats:
|
|
180
|
+
author_stats[email] = AuthorStats(
|
|
181
|
+
name=info.author,
|
|
182
|
+
email=info.author_email,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
stats = author_stats[email]
|
|
186
|
+
stats.total_uncovered_lines += 1
|
|
187
|
+
stats.files.add(str(file_path))
|
|
188
|
+
stats.lines.append((str(file_path), line_num))
|
|
189
|
+
|
|
190
|
+
return author_stats
|
|
191
|
+
|
|
192
|
+
def get_top_authors_by_uncovered(
|
|
193
|
+
self,
|
|
194
|
+
uncovered_by_file: dict[Path, list[int]],
|
|
195
|
+
limit: int = 10,
|
|
196
|
+
) -> list[AuthorStats]:
|
|
197
|
+
"""Get top authors by number of uncovered lines.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
uncovered_by_file: Dictionary mapping file paths to uncovered line numbers
|
|
201
|
+
limit: Maximum number of authors to return
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of AuthorStats sorted by uncovered lines descending
|
|
205
|
+
"""
|
|
206
|
+
stats = self.analyze_uncovered_lines(uncovered_by_file)
|
|
207
|
+
sorted_stats = sorted(
|
|
208
|
+
stats.values(),
|
|
209
|
+
key=lambda x: x.total_uncovered_lines,
|
|
210
|
+
reverse=True,
|
|
211
|
+
)
|
|
212
|
+
return sorted_stats[:limit]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_uncovered_blame_summary(
|
|
216
|
+
uncovered_by_file: dict[Path, list[int]],
|
|
217
|
+
repo_path: Path | str | None = None,
|
|
218
|
+
limit: int = 10,
|
|
219
|
+
) -> list[dict[str, Any]]:
|
|
220
|
+
"""Get summary of who wrote uncovered code.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
uncovered_by_file: Dictionary mapping file paths to uncovered line numbers
|
|
224
|
+
repo_path: Path to git repository
|
|
225
|
+
limit: Maximum number of authors to return
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
List of author stats as dictionaries
|
|
229
|
+
"""
|
|
230
|
+
analyzer = GitBlameAnalyzer(repo_path)
|
|
231
|
+
top_authors = analyzer.get_top_authors_by_uncovered(uncovered_by_file, limit)
|
|
232
|
+
return [author.to_dict() for author in top_authors]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Git diff parsing and analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class DiffHunk:
|
|
13
|
+
"""A single hunk in a diff."""
|
|
14
|
+
|
|
15
|
+
old_start: int
|
|
16
|
+
old_count: int
|
|
17
|
+
new_start: int
|
|
18
|
+
new_count: int
|
|
19
|
+
added_lines: set[int] = field(default_factory=set)
|
|
20
|
+
removed_lines: set[int] = field(default_factory=set)
|
|
21
|
+
context_lines: set[int] = field(default_factory=set)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class FileDiff:
|
|
26
|
+
"""Diff information for a single file."""
|
|
27
|
+
|
|
28
|
+
old_path: Path | None
|
|
29
|
+
new_path: Path | None
|
|
30
|
+
hunks: list[DiffHunk] = field(default_factory=list)
|
|
31
|
+
is_new_file: bool = False
|
|
32
|
+
is_deleted_file: bool = False
|
|
33
|
+
is_renamed: bool = False
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def path(self) -> Path:
|
|
37
|
+
"""Get the current path of the file."""
|
|
38
|
+
return self.new_path or self.old_path or Path("")
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def added_lines(self) -> set[int]:
|
|
42
|
+
"""Get all added line numbers."""
|
|
43
|
+
result: set[int] = set()
|
|
44
|
+
for hunk in self.hunks:
|
|
45
|
+
result.update(hunk.added_lines)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def removed_lines(self) -> set[int]:
|
|
50
|
+
"""Get all removed line numbers (in old file)."""
|
|
51
|
+
result: set[int] = set()
|
|
52
|
+
for hunk in self.hunks:
|
|
53
|
+
result.update(hunk.removed_lines)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def modified_lines(self) -> set[int]:
|
|
58
|
+
"""Get all modified line numbers (added lines in new file)."""
|
|
59
|
+
return self.added_lines
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class DiffAnalyzer:
|
|
64
|
+
"""Analyzes git diffs to determine changed lines."""
|
|
65
|
+
|
|
66
|
+
files: dict[Path, FileDiff] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_git_diff(
|
|
70
|
+
cls,
|
|
71
|
+
diff_range: str,
|
|
72
|
+
repo_path: Path | str | None = None,
|
|
73
|
+
) -> DiffAnalyzer:
|
|
74
|
+
"""Create DiffAnalyzer from git diff command.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
diff_range: Git diff range (e.g., "main..HEAD", "HEAD~1..HEAD")
|
|
78
|
+
repo_path: Path to git repository (defaults to current directory)
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
DiffAnalyzer with parsed diff information
|
|
82
|
+
"""
|
|
83
|
+
cmd = ["git", "diff", "--no-color", "-U0", diff_range]
|
|
84
|
+
cwd = Path(repo_path) if repo_path else None
|
|
85
|
+
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
cmd,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
cwd=cwd,
|
|
91
|
+
check=True,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return cls.from_unified_diff(result.stdout)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_diff_file(cls, path: Path | str) -> DiffAnalyzer:
|
|
98
|
+
"""Create DiffAnalyzer from a diff file.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
path: Path to unified diff file
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
DiffAnalyzer with parsed diff information
|
|
105
|
+
"""
|
|
106
|
+
with open(path) as f:
|
|
107
|
+
return cls.from_unified_diff(f.read())
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_unified_diff(cls, diff_content: str) -> DiffAnalyzer:
|
|
111
|
+
"""Parse unified diff format.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
diff_content: Unified diff content
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
DiffAnalyzer with parsed diff information
|
|
118
|
+
"""
|
|
119
|
+
analyzer = cls()
|
|
120
|
+
current_file: FileDiff | None = None
|
|
121
|
+
current_hunk: DiffHunk | None = None
|
|
122
|
+
new_line_num = 0
|
|
123
|
+
|
|
124
|
+
file_header_re = re.compile(r"^diff --git a/(.+) b/(.+)$")
|
|
125
|
+
old_file_re = re.compile(r"^--- (?:a/)?(.+)$")
|
|
126
|
+
new_file_re = re.compile(r"^\+\+\+ (?:b/)?(.+)$")
|
|
127
|
+
hunk_re = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
128
|
+
new_file_mode_re = re.compile(r"^new file mode")
|
|
129
|
+
deleted_file_mode_re = re.compile(r"^deleted file mode")
|
|
130
|
+
rename_re = re.compile(r"^rename (from|to) (.+)$")
|
|
131
|
+
|
|
132
|
+
for line in diff_content.splitlines():
|
|
133
|
+
if match := file_header_re.match(line):
|
|
134
|
+
if current_file and current_file.new_path:
|
|
135
|
+
analyzer.files[current_file.new_path] = current_file
|
|
136
|
+
current_file = FileDiff(
|
|
137
|
+
old_path=Path(match.group(1)),
|
|
138
|
+
new_path=Path(match.group(2)),
|
|
139
|
+
)
|
|
140
|
+
current_hunk = None
|
|
141
|
+
|
|
142
|
+
elif new_file_mode_re.match(line):
|
|
143
|
+
if current_file:
|
|
144
|
+
current_file.is_new_file = True
|
|
145
|
+
|
|
146
|
+
elif deleted_file_mode_re.match(line):
|
|
147
|
+
if current_file:
|
|
148
|
+
current_file.is_deleted_file = True
|
|
149
|
+
|
|
150
|
+
elif match := rename_re.match(line):
|
|
151
|
+
if current_file:
|
|
152
|
+
current_file.is_renamed = True
|
|
153
|
+
|
|
154
|
+
elif match := old_file_re.match(line):
|
|
155
|
+
if current_file and match.group(1) != "/dev/null":
|
|
156
|
+
current_file.old_path = Path(match.group(1))
|
|
157
|
+
|
|
158
|
+
elif match := new_file_re.match(line):
|
|
159
|
+
if current_file and match.group(1) != "/dev/null":
|
|
160
|
+
current_file.new_path = Path(match.group(1))
|
|
161
|
+
|
|
162
|
+
elif match := hunk_re.match(line):
|
|
163
|
+
if current_file:
|
|
164
|
+
current_hunk = DiffHunk(
|
|
165
|
+
old_start=int(match.group(1)),
|
|
166
|
+
old_count=int(match.group(2) or 1),
|
|
167
|
+
new_start=int(match.group(3)),
|
|
168
|
+
new_count=int(match.group(4) or 1),
|
|
169
|
+
)
|
|
170
|
+
current_file.hunks.append(current_hunk)
|
|
171
|
+
new_line_num = current_hunk.new_start
|
|
172
|
+
|
|
173
|
+
elif current_hunk is not None:
|
|
174
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
175
|
+
current_hunk.added_lines.add(new_line_num)
|
|
176
|
+
new_line_num += 1
|
|
177
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
178
|
+
pass
|
|
179
|
+
elif line.startswith(" "):
|
|
180
|
+
current_hunk.context_lines.add(new_line_num)
|
|
181
|
+
new_line_num += 1
|
|
182
|
+
|
|
183
|
+
if current_file and current_file.new_path:
|
|
184
|
+
analyzer.files[current_file.new_path] = current_file
|
|
185
|
+
|
|
186
|
+
return analyzer
|
|
187
|
+
|
|
188
|
+
def get_file_diff(self, path: Path | str) -> FileDiff | None:
|
|
189
|
+
"""Get diff for a specific file."""
|
|
190
|
+
if isinstance(path, str):
|
|
191
|
+
path = Path(path)
|
|
192
|
+
|
|
193
|
+
if path in self.files:
|
|
194
|
+
return self.files[path]
|
|
195
|
+
|
|
196
|
+
for file_path, diff in self.files.items():
|
|
197
|
+
if file_path.name == path.name or str(file_path).endswith(str(path)):
|
|
198
|
+
return diff
|
|
199
|
+
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def get_added_lines(self, path: Path | str) -> set[int]:
|
|
203
|
+
"""Get added line numbers for a file."""
|
|
204
|
+
diff = self.get_file_diff(path)
|
|
205
|
+
return diff.added_lines if diff else set()
|
|
206
|
+
|
|
207
|
+
def get_modified_files(self) -> list[Path]:
|
|
208
|
+
"""Get list of modified files."""
|
|
209
|
+
return [
|
|
210
|
+
diff.path
|
|
211
|
+
for diff in self.files.values()
|
|
212
|
+
if not diff.is_deleted_file
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
def get_new_files(self) -> list[Path]:
|
|
216
|
+
"""Get list of new files."""
|
|
217
|
+
return [
|
|
218
|
+
diff.path
|
|
219
|
+
for diff in self.files.values()
|
|
220
|
+
if diff.is_new_file
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
def get_deleted_files(self) -> list[Path]:
|
|
224
|
+
"""Get list of deleted files."""
|
|
225
|
+
return [
|
|
226
|
+
diff.old_path
|
|
227
|
+
for diff in self.files.values()
|
|
228
|
+
if diff.is_deleted_file and diff.old_path
|
|
229
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""File grouping by module/directory for coverage analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from covisible.core.models import CoverageData, FileCoverage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ModuleGroup:
|
|
14
|
+
"""A group of files representing a module or directory."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
path: str
|
|
18
|
+
files: list[FileCoverage] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def total_lines(self) -> int:
|
|
22
|
+
return sum(f.total_lines for f in self.files)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def covered_lines(self) -> int:
|
|
26
|
+
return sum(f.covered_lines for f in self.files)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def uncovered_lines(self) -> int:
|
|
30
|
+
return self.total_lines - self.covered_lines
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def coverage_percent(self) -> float:
|
|
34
|
+
if self.total_lines == 0:
|
|
35
|
+
return 100.0
|
|
36
|
+
return (self.covered_lines / self.total_lines) * 100
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def total_functions(self) -> int:
|
|
40
|
+
return sum(f.total_functions for f in self.files)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def covered_functions(self) -> int:
|
|
44
|
+
return sum(f.covered_functions for f in self.files)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def file_count(self) -> int:
|
|
48
|
+
return len(self.files)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"name": self.name,
|
|
53
|
+
"path": self.path,
|
|
54
|
+
"file_count": self.file_count,
|
|
55
|
+
"total_lines": self.total_lines,
|
|
56
|
+
"covered_lines": self.covered_lines,
|
|
57
|
+
"uncovered_lines": self.uncovered_lines,
|
|
58
|
+
"coverage_percent": round(self.coverage_percent, 2),
|
|
59
|
+
"total_functions": self.total_functions,
|
|
60
|
+
"covered_functions": self.covered_functions,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ModuleGrouper:
|
|
65
|
+
"""Groups files by module/directory."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
coverage: CoverageData,
|
|
70
|
+
base_path: Path | str | None = None,
|
|
71
|
+
depth: int = 1,
|
|
72
|
+
):
|
|
73
|
+
"""Initialize grouper.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
coverage: Coverage data
|
|
77
|
+
base_path: Base path to make paths relative to
|
|
78
|
+
depth: Directory depth for grouping (1 = top-level dirs)
|
|
79
|
+
"""
|
|
80
|
+
self.coverage = coverage
|
|
81
|
+
self.base_path = Path(base_path) if base_path else None
|
|
82
|
+
self.depth = depth
|
|
83
|
+
|
|
84
|
+
def _find_common_prefix(self) -> Path | None:
|
|
85
|
+
"""Find common path prefix for all files."""
|
|
86
|
+
paths = list(self.coverage.files.keys())
|
|
87
|
+
if not paths:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# Get all parent directories
|
|
91
|
+
first_parts = paths[0].parts
|
|
92
|
+
common_parts: list[str] = []
|
|
93
|
+
|
|
94
|
+
for i, part in enumerate(first_parts[:-1]): # Exclude filename
|
|
95
|
+
if all(len(p.parts) > i and p.parts[i] == part for p in paths):
|
|
96
|
+
common_parts.append(part)
|
|
97
|
+
else:
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
if common_parts:
|
|
101
|
+
return Path(*common_parts)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def group_by_directory(self) -> list[ModuleGroup]:
|
|
105
|
+
"""Group files by directory at specified depth.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of ModuleGroup sorted by uncovered lines descending
|
|
109
|
+
"""
|
|
110
|
+
groups: dict[str, ModuleGroup] = {}
|
|
111
|
+
|
|
112
|
+
# Auto-detect base path if not provided
|
|
113
|
+
base_path = self.base_path or self._find_common_prefix()
|
|
114
|
+
|
|
115
|
+
for file_path, file_cov in self.coverage.files.items():
|
|
116
|
+
if base_path:
|
|
117
|
+
try:
|
|
118
|
+
rel_path = file_path.relative_to(base_path)
|
|
119
|
+
except ValueError:
|
|
120
|
+
rel_path = file_path
|
|
121
|
+
else:
|
|
122
|
+
rel_path = file_path
|
|
123
|
+
|
|
124
|
+
parts = rel_path.parts
|
|
125
|
+
if len(parts) > self.depth:
|
|
126
|
+
group_path = str(Path(*parts[: self.depth]))
|
|
127
|
+
group_name = parts[self.depth - 1]
|
|
128
|
+
else:
|
|
129
|
+
# File is in root or shallow directory
|
|
130
|
+
if len(parts) > 0:
|
|
131
|
+
group_path = parts[0] if len(parts) > 1 else "."
|
|
132
|
+
group_name = parts[0] if len(parts) > 1 else rel_path.name
|
|
133
|
+
else:
|
|
134
|
+
group_path = "."
|
|
135
|
+
group_name = "root"
|
|
136
|
+
|
|
137
|
+
if group_path not in groups:
|
|
138
|
+
groups[group_path] = ModuleGroup(name=group_name, path=group_path)
|
|
139
|
+
|
|
140
|
+
groups[group_path].files.append(file_cov)
|
|
141
|
+
|
|
142
|
+
return sorted(
|
|
143
|
+
groups.values(),
|
|
144
|
+
key=lambda g: g.uncovered_lines,
|
|
145
|
+
reverse=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def group_by_pattern(self, patterns: dict[str, str]) -> list[ModuleGroup]:
|
|
149
|
+
"""Group files by custom patterns.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
patterns: Dictionary mapping group names to glob patterns
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
List of ModuleGroup
|
|
156
|
+
"""
|
|
157
|
+
import fnmatch
|
|
158
|
+
|
|
159
|
+
groups: dict[str, ModuleGroup] = {}
|
|
160
|
+
unmatched = ModuleGroup(name="Other", path="other")
|
|
161
|
+
|
|
162
|
+
for name, pattern in patterns.items():
|
|
163
|
+
groups[name] = ModuleGroup(name=name, path=pattern)
|
|
164
|
+
|
|
165
|
+
for file_path, file_cov in self.coverage.files.items():
|
|
166
|
+
matched = False
|
|
167
|
+
for name, pattern in patterns.items():
|
|
168
|
+
if fnmatch.fnmatch(str(file_path), pattern):
|
|
169
|
+
groups[name].files.append(file_cov)
|
|
170
|
+
matched = True
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
if not matched:
|
|
174
|
+
unmatched.files.append(file_cov)
|
|
175
|
+
|
|
176
|
+
result = list(groups.values())
|
|
177
|
+
if unmatched.files:
|
|
178
|
+
result.append(unmatched)
|
|
179
|
+
|
|
180
|
+
return sorted(result, key=lambda g: g.uncovered_lines, reverse=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def group_coverage_by_directory(
|
|
184
|
+
coverage: CoverageData,
|
|
185
|
+
base_path: Path | str | None = None,
|
|
186
|
+
depth: int = 1,
|
|
187
|
+
) -> list[dict[str, Any]]:
|
|
188
|
+
"""Group coverage data by directory.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
coverage: Coverage data
|
|
192
|
+
base_path: Base path to make paths relative to
|
|
193
|
+
depth: Directory depth for grouping
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
List of module group dictionaries
|
|
197
|
+
"""
|
|
198
|
+
grouper = ModuleGrouper(coverage, base_path, depth)
|
|
199
|
+
groups = grouper.group_by_directory()
|
|
200
|
+
return [g.to_dict() for g in groups]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def group_coverage_by_pattern(
|
|
204
|
+
coverage: CoverageData,
|
|
205
|
+
patterns: dict[str, str],
|
|
206
|
+
) -> list[dict[str, Any]]:
|
|
207
|
+
"""Group coverage data by custom patterns.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
coverage: Coverage data
|
|
211
|
+
patterns: Dictionary mapping group names to glob patterns
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of module group dictionaries
|
|
215
|
+
"""
|
|
216
|
+
grouper = ModuleGrouper(coverage)
|
|
217
|
+
groups = grouper.group_by_pattern(patterns)
|
|
218
|
+
return [g.to_dict() for g in groups]
|