pycmdcheck 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.
Files changed (41) hide show
  1. pycmdcheck/__init__.py +104 -0
  2. pycmdcheck/__main__.py +6 -0
  3. pycmdcheck/ast_cache.py +45 -0
  4. pycmdcheck/checks/__init__.py +1 -0
  5. pycmdcheck/checks/base.py +143 -0
  6. pycmdcheck/checks/build.py +101 -0
  7. pycmdcheck/checks/changelog.py +101 -0
  8. pycmdcheck/checks/ci.py +87 -0
  9. pycmdcheck/checks/citation.py +102 -0
  10. pycmdcheck/checks/community.py +108 -0
  11. pycmdcheck/checks/dependencies.py +309 -0
  12. pycmdcheck/checks/docs.py +305 -0
  13. pycmdcheck/checks/doctests.py +129 -0
  14. pycmdcheck/checks/formatting.py +200 -0
  15. pycmdcheck/checks/imports.py +239 -0
  16. pycmdcheck/checks/license.py +304 -0
  17. pycmdcheck/checks/linting.py +304 -0
  18. pycmdcheck/checks/metadata.py +233 -0
  19. pycmdcheck/checks/py_typed.py +207 -0
  20. pycmdcheck/checks/python_versions.py +188 -0
  21. pycmdcheck/checks/structure.py +282 -0
  22. pycmdcheck/checks/tests.py +230 -0
  23. pycmdcheck/checks/typing.py +257 -0
  24. pycmdcheck/checks/urls.py +124 -0
  25. pycmdcheck/checks/version.py +184 -0
  26. pycmdcheck/cli.py +296 -0
  27. pycmdcheck/config.py +194 -0
  28. pycmdcheck/constants.py +29 -0
  29. pycmdcheck/discovery.py +127 -0
  30. pycmdcheck/package_layout.py +234 -0
  31. pycmdcheck/profiles.py +158 -0
  32. pycmdcheck/py.typed +0 -0
  33. pycmdcheck/pyproject_reader.py +226 -0
  34. pycmdcheck/results.py +320 -0
  35. pycmdcheck/runner.py +236 -0
  36. pycmdcheck/subprocess_runner.py +166 -0
  37. pycmdcheck-0.1.0.dist-info/METADATA +346 -0
  38. pycmdcheck-0.1.0.dist-info/RECORD +41 -0
  39. pycmdcheck-0.1.0.dist-info/WHEEL +4 -0
  40. pycmdcheck-0.1.0.dist-info/entry_points.txt +24 -0
  41. pycmdcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,305 @@
1
+ """Documentation check.
2
+
3
+ This module provides the DocsCheck class which verifies that a package
4
+ has proper documentation including a README file and optionally checks
5
+ for docstrings in the code.
6
+ """
7
+
8
+ import ast
9
+ import logging
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pycmdcheck.ast_cache import parse_file
15
+ from pycmdcheck.checks.base import BaseCheck
16
+ from pycmdcheck.constants import MAX_DOCSTRING_FILES, MIN_README_WORDS
17
+ from pycmdcheck.package_layout import PackageLayout
18
+ from pycmdcheck.results import CheckResult, CheckStatus
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class DocsCheck(BaseCheck):
24
+ """Check for documentation presence and quality.
25
+
26
+ Verifies that the package has a README file and optionally checks
27
+ that public modules, classes, and functions have docstrings.
28
+
29
+ Attributes:
30
+ name: The check identifier ("docs").
31
+ description: Human-readable description of this check.
32
+ README_FILENAMES: List of recognized README file names.
33
+
34
+ Configuration Options:
35
+ require_readme (bool): Whether README is required. Defaults to True.
36
+ check_docstrings (bool): Whether to check for docstrings in code.
37
+ Defaults to False.
38
+ check_readme_sections (bool): Whether to check for common README
39
+ sections (Installation, Usage). Defaults to False.
40
+ min_docstring_coverage (float): Minimum docstring coverage percentage
41
+ (0.0-100.0). When > 0 and coverage is below threshold, an issue
42
+ is reported. Defaults to 0.0 (disabled).
43
+
44
+ Examples:
45
+ Run the docs check with defaults:
46
+
47
+ >>> from pathlib import Path
48
+ >>> check = DocsCheck()
49
+ >>> result = check.run(Path("."), {"enabled": True})
50
+ >>> result.name
51
+ 'docs'
52
+
53
+ Enable docstring checking:
54
+
55
+ >>> config = {"enabled": True, "check_docstrings": True}
56
+ >>> result = check.run(Path("."), config)
57
+
58
+ Make README optional:
59
+
60
+ >>> config = {"enabled": True, "require_readme": False}
61
+ >>> result = check.run(Path("."), config)
62
+ """
63
+
64
+ name = "docs"
65
+ description = "Check documentation (README, docstrings)"
66
+
67
+ README_FILENAMES = [
68
+ "README.md",
69
+ "README.rst",
70
+ "README.txt",
71
+ "README",
72
+ ]
73
+
74
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
75
+ """Check documentation presence and quality.
76
+
77
+ Looks for a README file and optionally scans code for missing
78
+ docstrings on public items.
79
+
80
+ Args:
81
+ package_path: Path to the package directory to check.
82
+ config: Configuration dictionary with options:
83
+
84
+ - require_readme (bool): Require README (default: True)
85
+ - check_docstrings (bool): Check code docstrings (default: False)
86
+ - check_readme_sections (bool): Check for common README
87
+ sections (default: False)
88
+ - min_docstring_coverage (float): Minimum coverage %
89
+ (default: 0.0, disabled)
90
+
91
+ Returns:
92
+ A CheckResult with:
93
+
94
+ - OK status if all documentation requirements met
95
+ - NOTE status if minor improvements suggested
96
+ - WARNING status if README missing (when required)
97
+ """
98
+ details: list[str] = []
99
+ issues: list[str] = []
100
+
101
+ # Check for README
102
+ require_readme = config.get("require_readme", True)
103
+ readme_found = self._check_readme(package_path, details, issues, require_readme)
104
+
105
+ # Check README sections (opt-in)
106
+ if config.get("check_readme_sections", False) and readme_found:
107
+ self._check_readme_sections(package_path, details, issues)
108
+
109
+ # Check for docstrings (optional)
110
+ check_docstrings = config.get("check_docstrings", False)
111
+ if check_docstrings:
112
+ min_coverage = config.get("min_docstring_coverage", 0.0)
113
+ self._check_docstrings(package_path, details, issues, min_coverage)
114
+
115
+ # Determine status
116
+ if issues and require_readme and not readme_found:
117
+ return CheckResult(
118
+ name=self.name,
119
+ status=CheckStatus.WARNING,
120
+ message="Documentation incomplete",
121
+ details=details + issues,
122
+ )
123
+
124
+ if issues:
125
+ return CheckResult(
126
+ name=self.name,
127
+ status=CheckStatus.NOTE,
128
+ message="Some documentation improvements suggested",
129
+ details=details + issues,
130
+ )
131
+
132
+ return CheckResult(
133
+ name=self.name,
134
+ status=CheckStatus.OK,
135
+ message="Documentation present",
136
+ details=details,
137
+ )
138
+
139
+ def _check_readme(
140
+ self,
141
+ package_path: Path,
142
+ details: list[str],
143
+ issues: list[str],
144
+ require_readme: bool,
145
+ ) -> bool:
146
+ """Check for README file."""
147
+ for filename in self.README_FILENAMES:
148
+ readme_path = package_path / filename
149
+ if readme_path.exists():
150
+ details.append(f"Found README: {filename}")
151
+
152
+ # Check README is not too short
153
+ try:
154
+ content = readme_path.read_text(encoding="utf-8")
155
+ word_count = len(content.split())
156
+ if word_count < MIN_README_WORDS:
157
+ issues.append(
158
+ f"README is very short (< {MIN_README_WORDS} words)"
159
+ )
160
+ else:
161
+ details.append(f"README has {word_count} words")
162
+ except (OSError, UnicodeDecodeError) as exc:
163
+ logger.debug("Could not read README %s: %s", readme_path, exc)
164
+
165
+ return True
166
+
167
+ if require_readme:
168
+ issues.append("No README file found")
169
+ return False
170
+
171
+ def _check_readme_sections(
172
+ self,
173
+ package_path: Path,
174
+ details: list[str],
175
+ issues: list[str],
176
+ ) -> None:
177
+ """Check that the README mentions common sections.
178
+
179
+ Searches the whole README body (case-insensitive) for Installation
180
+ and Usage keywords rather than only Markdown ``#`` headings, so that
181
+ RST/Setext/plain-text READMEs and synonyms (Quick Start, Getting
182
+ Started, Example, Tutorial) are recognized and not falsely flagged.
183
+ """
184
+ readme_content = ""
185
+ for filename in self.README_FILENAMES:
186
+ readme_path = package_path / filename
187
+ if readme_path.exists():
188
+ try:
189
+ readme_content = readme_path.read_text(encoding="utf-8")
190
+ except (OSError, UnicodeDecodeError) as exc:
191
+ logger.debug("Could not read README %s: %s", readme_path, exc)
192
+ break
193
+
194
+ if not readme_content:
195
+ return
196
+
197
+ text = readme_content.lower()
198
+
199
+ expected_sections = {
200
+ "Installation": r"install",
201
+ "Usage": (
202
+ r"usage|quick[\s-]?start|getting[\s-]?started"
203
+ r"|example|tutorial|how to use"
204
+ ),
205
+ }
206
+
207
+ for section_name, pattern in expected_sections.items():
208
+ if not re.search(pattern, text):
209
+ issues.append(f"README missing section: {section_name}")
210
+
211
+ def _check_docstrings(
212
+ self,
213
+ package_path: Path,
214
+ details: list[str],
215
+ issues: list[str],
216
+ min_coverage: float = 0.0,
217
+ ) -> None:
218
+ """Check for docstrings in public modules and functions."""
219
+ # Find Python files
220
+ layout = PackageLayout(package_path)
221
+ py_files = layout.python_files()
222
+
223
+ # Exclude test files
224
+ py_files = [f for f in py_files if not f.name.startswith("test_")]
225
+
226
+ missing_docstrings: list[str] = []
227
+ total_public_items = 0
228
+
229
+ for py_file in py_files[:MAX_DOCSTRING_FILES]: # Limit to first N files
230
+ try:
231
+ total, missing = self._check_file_docstrings_counted(py_file)
232
+ total_public_items += total
233
+ missing_docstrings.extend(missing)
234
+ except (OSError, UnicodeDecodeError) as exc:
235
+ logger.debug("Could not parse %s: %s", py_file, exc)
236
+
237
+ if missing_docstrings:
238
+ issues.append(
239
+ f"Missing docstrings in {len(missing_docstrings)} public items"
240
+ )
241
+ for missing_item in missing_docstrings[:5]:
242
+ issues.append(f" - {missing_item}")
243
+ if len(missing_docstrings) > 5:
244
+ issues.append(f" ... and {len(missing_docstrings) - 5} more")
245
+ else:
246
+ details.append("All public items have docstrings")
247
+
248
+ # Report coverage percentage
249
+ documented = total_public_items - len(missing_docstrings)
250
+ if total_public_items > 0:
251
+ pct = documented / total_public_items * 100
252
+ details.append(
253
+ f"Docstring coverage: {documented}/{total_public_items}"
254
+ f" public items ({pct:.0f}%)"
255
+ )
256
+
257
+ # Check minimum coverage threshold
258
+ if min_coverage > 0 and pct < min_coverage:
259
+ issues.append(
260
+ f"Docstring coverage {pct:.0f}% is below"
261
+ f" minimum {min_coverage:.0f}%"
262
+ )
263
+ else:
264
+ details.append("Docstring coverage: 0/0 public items (100%)")
265
+
266
+ def _check_file_docstrings(self, py_file: Path) -> list[str]:
267
+ """Check docstrings in a single Python file."""
268
+ _total, missing = self._check_file_docstrings_counted(py_file)
269
+ return missing
270
+
271
+ def _check_file_docstrings_counted(self, py_file: Path) -> tuple[int, list[str]]:
272
+ """Check docstrings in a single Python file.
273
+
274
+ Returns:
275
+ A tuple of (total_public_items, missing_items) where
276
+ *total_public_items* is the count of public items inspected
277
+ and *missing_items* is the list of items without docstrings.
278
+ """
279
+ tree = parse_file(py_file)
280
+ if tree is None:
281
+ return 0, []
282
+ missing: list[str] = []
283
+ total = 0
284
+
285
+ # Check module docstring
286
+ total += 1
287
+ if not ast.get_docstring(tree):
288
+ missing.append(f"{py_file.name}: module")
289
+
290
+ for node in ast.walk(tree):
291
+ # Check class docstrings
292
+ if isinstance(node, ast.ClassDef):
293
+ if not node.name.startswith("_"):
294
+ total += 1
295
+ if not ast.get_docstring(node):
296
+ missing.append(f"{py_file.name}: class {node.name}")
297
+
298
+ # Check function docstrings
299
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
300
+ if not node.name.startswith("_"):
301
+ total += 1
302
+ if not ast.get_docstring(node):
303
+ missing.append(f"{py_file.name}: function {node.name}")
304
+
305
+ return total, missing
@@ -0,0 +1,129 @@
1
+ """Doctest execution check.
2
+
3
+ Runs doctests embedded in module docstrings using pytest's
4
+ ``--doctest-modules`` flag.
5
+ """
6
+
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pycmdcheck.checks.base import BaseCheck
12
+ from pycmdcheck.constants import DEFAULT_TIMEOUT, MAX_DETAIL_LINES
13
+ from pycmdcheck.results import CheckResult, CheckStatus
14
+ from pycmdcheck.subprocess_runner import run_tool, tool_available
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class DoctestsCheck(BaseCheck):
20
+ """Run doctests via pytest --doctest-modules.
21
+
22
+ Executes all doctests embedded in module docstrings and reports
23
+ failures.
24
+
25
+ Attributes:
26
+ name: The check identifier ("doctests").
27
+ description: Human-readable description of this check.
28
+ """
29
+
30
+ name = "doctests"
31
+ description = "Run doctests in package modules"
32
+
33
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
34
+ """Run doctests for the package.
35
+
36
+ Args:
37
+ package_path: Path to the package directory to check.
38
+ config: Configuration dictionary for this check.
39
+ Supports ``args`` (list[str]) for additional pytest args
40
+ and ``timeout`` (int) to override default.
41
+
42
+ Returns:
43
+ A CheckResult with:
44
+
45
+ - OK status if all doctests pass or no doctests found
46
+ - WARNING status if some doctests fail
47
+ - ERROR status if doctest runner failed to execute
48
+ - SKIPPED status if pytest is not installed
49
+ """
50
+ if not tool_available("pytest"):
51
+ return CheckResult(
52
+ name=self.name,
53
+ status=CheckStatus.SKIPPED,
54
+ message="pytest not installed",
55
+ details=["Install pytest to run doctests"],
56
+ )
57
+
58
+ # Find the package source directory
59
+ src_dir = package_path / "src"
60
+ target = src_dir if src_dir.is_dir() else package_path
61
+
62
+ timeout = config.get("timeout", DEFAULT_TIMEOUT)
63
+ extra_args = config.get("args", [])
64
+
65
+ cmd = [
66
+ "pytest",
67
+ "--doctest-modules",
68
+ "--tb=short",
69
+ "-q",
70
+ str(target),
71
+ *extra_args,
72
+ ]
73
+
74
+ details = ["Running: pytest --doctest-modules"]
75
+
76
+ result = run_tool(cmd, cwd=package_path, timeout=timeout)
77
+
78
+ if result.error:
79
+ return CheckResult(
80
+ name=self.name,
81
+ status=CheckStatus.ERROR,
82
+ message="Failed to run pytest for doctests",
83
+ details=[f"Error: {result.error}"],
84
+ )
85
+
86
+ if result.timed_out:
87
+ return CheckResult(
88
+ name=self.name,
89
+ status=CheckStatus.ERROR,
90
+ message="Doctest execution timed out",
91
+ details=[f"Timeout after {timeout}s"],
92
+ )
93
+
94
+ stdout = result.stdout or ""
95
+ lines = stdout.strip().splitlines()
96
+
97
+ # pytest exit code 5 means "no tests were collected" — for a package
98
+ # with no doctests this is success, not a failure.
99
+ if result.returncode == 5 or "no tests ran" in stdout.lower():
100
+ details.append("No doctests found in package modules")
101
+ return CheckResult(
102
+ name=self.name,
103
+ status=CheckStatus.OK,
104
+ message="No doctests found",
105
+ details=details,
106
+ )
107
+
108
+ if result.returncode == 0:
109
+ details.append("All doctests passed")
110
+ return CheckResult(
111
+ name=self.name,
112
+ status=CheckStatus.OK,
113
+ message="All doctests passed",
114
+ details=details,
115
+ )
116
+
117
+ # Doctests failed — extract failure info
118
+ failed_lines = [ln for ln in lines if "FAILED" in ln or "ERROR" in ln]
119
+ details.extend(failed_lines[:MAX_DETAIL_LINES])
120
+ if len(failed_lines) > MAX_DETAIL_LINES:
121
+ details.append(f"... and {len(failed_lines) - MAX_DETAIL_LINES} more")
122
+
123
+ fail_count = len(failed_lines) if failed_lines else "some"
124
+ return CheckResult(
125
+ name=self.name,
126
+ status=CheckStatus.WARNING,
127
+ message=f"{fail_count} doctest(s) failed",
128
+ details=details,
129
+ )
@@ -0,0 +1,200 @@
1
+ """Code formatting check."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pycmdcheck.checks.base import BaseCheck
7
+ from pycmdcheck.constants import DEFAULT_TIMEOUT, MAX_DETAIL_LINES
8
+ from pycmdcheck.pyproject_reader import read_pyproject
9
+ from pycmdcheck.results import CheckResult, CheckStatus
10
+ from pycmdcheck.subprocess_runner import run_tool, sanitize_args, tool_available
11
+
12
+
13
+ class FormattingCheck(BaseCheck):
14
+ """Check code formatting without modifying files."""
15
+
16
+ name = "formatting"
17
+ description = "Check code formatting (ruff format/black)"
18
+
19
+ SUPPORTED_TOOLS = ["ruff", "black"]
20
+
21
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
22
+ """Run formatting check.
23
+
24
+ The formatter defaults to ``"auto"``: the package's configured
25
+ formatter is detected from pyproject.toml / ``ruff.toml``. A package
26
+ that has not opted into a formatter is SKIPPED rather than being held
27
+ to one tool's default style (pyOpenSci does not mandate a specific
28
+ formatter).
29
+ """
30
+ tool = config.get("tool", "auto")
31
+
32
+ if tool == "auto":
33
+ detected = self._detect_formatter(package_path)
34
+ if detected is None:
35
+ return CheckResult(
36
+ name=self.name,
37
+ status=CheckStatus.SKIPPED,
38
+ message="No formatter configured",
39
+ details=[
40
+ "No [tool.ruff]/[tool.black] config or ruff.toml found; "
41
+ "skipping formatting check"
42
+ ],
43
+ )
44
+ tool = detected
45
+
46
+ if tool not in self.SUPPORTED_TOOLS:
47
+ return CheckResult(
48
+ name=self.name,
49
+ status=CheckStatus.ERROR,
50
+ message=f"Unsupported formatting tool: {tool}",
51
+ details=[f"Supported tools: {', '.join(self.SUPPORTED_TOOLS)}"],
52
+ )
53
+
54
+ if tool == "ruff":
55
+ return self._run_ruff_format(package_path, config)
56
+ else:
57
+ return self._run_black(package_path, config)
58
+
59
+ @staticmethod
60
+ def _detect_formatter(package_path: Path) -> str | None:
61
+ """Detect the formatter a package has opted into, or ``None``.
62
+
63
+ Returns ``"ruff"`` if ``[tool.ruff]`` / ``ruff.toml`` is present,
64
+ ``"black"`` if ``[tool.black]`` is present, otherwise ``None``.
65
+ """
66
+ if (package_path / "ruff.toml").exists() or (
67
+ package_path / ".ruff.toml"
68
+ ).exists():
69
+ return "ruff"
70
+ data = read_pyproject(package_path)
71
+ tool_table = data.get("tool", {}) if data else {}
72
+ if "ruff" in tool_table:
73
+ return "ruff"
74
+ if "black" in tool_table:
75
+ return "black"
76
+ return None
77
+
78
+ def _run_ruff_format(
79
+ self, package_path: Path, config: dict[str, Any]
80
+ ) -> CheckResult:
81
+ """Run ruff format --check."""
82
+ if not tool_available("ruff"):
83
+ return CheckResult(
84
+ name=self.name,
85
+ status=CheckStatus.SKIPPED,
86
+ message="ruff not installed",
87
+ details=["Install ruff: pip install ruff"],
88
+ )
89
+
90
+ args = sanitize_args(config.get("args", []))
91
+ result = run_tool(
92
+ ["ruff", "format", "--check", ".", *args],
93
+ cwd=package_path,
94
+ timeout=DEFAULT_TIMEOUT,
95
+ )
96
+
97
+ if result.timed_out:
98
+ return CheckResult(
99
+ name=self.name,
100
+ status=CheckStatus.ERROR,
101
+ message="Formatting check timed out",
102
+ )
103
+ if result.error is not None:
104
+ return CheckResult(
105
+ name=self.name,
106
+ status=CheckStatus.ERROR,
107
+ message="Failed to run ruff format",
108
+ details=[result.error],
109
+ )
110
+ if result.success:
111
+ return CheckResult(
112
+ name=self.name,
113
+ status=CheckStatus.OK,
114
+ message="Code is properly formatted",
115
+ details=["Using formatter: ruff"],
116
+ )
117
+
118
+ # Files need reformatting
119
+ lines = result.output_lines()
120
+ reformat_lines = [ln for ln in lines if "would reformat" in ln.lower()]
121
+ file_count = len(reformat_lines)
122
+ details = ["Using formatter: ruff"]
123
+ if reformat_lines:
124
+ details.extend(reformat_lines[:MAX_DETAIL_LINES])
125
+ if file_count > MAX_DETAIL_LINES:
126
+ details.append(f"... and {file_count - MAX_DETAIL_LINES} more files")
127
+ else:
128
+ # Tool failed but output doesn't list specific files
129
+ details.extend(lines[:MAX_DETAIL_LINES])
130
+
131
+ return CheckResult(
132
+ name=self.name,
133
+ status=CheckStatus.WARNING,
134
+ message=(
135
+ f"{file_count} file(s) need reformatting"
136
+ if file_count
137
+ else "Code formatting issues found"
138
+ ),
139
+ details=details,
140
+ )
141
+
142
+ def _run_black(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
143
+ """Run black --check."""
144
+ if not tool_available("black"):
145
+ return CheckResult(
146
+ name=self.name,
147
+ status=CheckStatus.SKIPPED,
148
+ message="black not installed",
149
+ details=["Install black: pip install black"],
150
+ )
151
+
152
+ args = sanitize_args(config.get("args", []))
153
+ result = run_tool(
154
+ ["black", "--check", ".", *args],
155
+ cwd=package_path,
156
+ timeout=DEFAULT_TIMEOUT,
157
+ )
158
+
159
+ if result.timed_out:
160
+ return CheckResult(
161
+ name=self.name,
162
+ status=CheckStatus.ERROR,
163
+ message="Formatting check timed out",
164
+ )
165
+ if result.error is not None:
166
+ return CheckResult(
167
+ name=self.name,
168
+ status=CheckStatus.ERROR,
169
+ message="Failed to run black",
170
+ details=[result.error],
171
+ )
172
+ if result.success:
173
+ return CheckResult(
174
+ name=self.name,
175
+ status=CheckStatus.OK,
176
+ message="Code is properly formatted",
177
+ details=["Using formatter: black"],
178
+ )
179
+
180
+ lines = result.output_lines()
181
+ reformat_lines = [ln for ln in lines if "would reformat" in ln.lower()]
182
+ file_count = len(reformat_lines)
183
+ details = ["Using formatter: black"]
184
+ if reformat_lines:
185
+ details.extend(reformat_lines[:MAX_DETAIL_LINES])
186
+ if file_count > MAX_DETAIL_LINES:
187
+ details.append(f"... and {file_count - MAX_DETAIL_LINES} more files")
188
+ else:
189
+ details.extend(lines[:MAX_DETAIL_LINES])
190
+
191
+ return CheckResult(
192
+ name=self.name,
193
+ status=CheckStatus.WARNING,
194
+ message=(
195
+ f"{file_count} file(s) need reformatting"
196
+ if file_count
197
+ else "Code formatting issues found"
198
+ ),
199
+ details=details,
200
+ )