feat(certify): add multi-language support for syntax checks and tests
Expand certification checks to support multiple programming languages including Python, JavaScript/TypeScript, Go, Rust, Ruby, Java, and C/C++. This includes: - Language detection based on file extensions. - Syntax validation using language-specific tools (e.g., ast for Python, tsc for TypeScript, cargo check for Rust). - Test suite detection and execution for various frameworks (e.g., go test, cargo test, npm test, pytest/unittest). - Ignore common build/dependency directories during scans. - Update tests to reflect generalized syntax checking. - Add new badge assets for certification levels.
This commit is contained in:
parent
3791859422
commit
26b9299d44
@ -3,12 +3,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
|
||||
# Directories to skip when walking the project tree.
|
||||
_IGNORE_DIRS = {
|
||||
".git", "node_modules", "__pycache__", ".venv", "venv",
|
||||
"target", ".tox", ".eggs", "dist", "build", ".mypy_cache",
|
||||
}
|
||||
|
||||
_EXT_TO_LANG: dict[str, str] = {
|
||||
".py": "python",
|
||||
".js": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
||||
".jsx": "javascript",
|
||||
".ts": "typescript", ".tsx": "typescript",
|
||||
".go": "go",
|
||||
".rs": "rust",
|
||||
".java": "java",
|
||||
".rb": "ruby",
|
||||
".c": "c", ".h": "c",
|
||||
".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp",
|
||||
".cs": "csharp",
|
||||
".kt": "kotlin", ".kts": "kotlin",
|
||||
".sh": "shell", ".bash": "shell",
|
||||
}
|
||||
|
||||
|
||||
def _detect_languages(path: Path) -> set[str]:
|
||||
"""Return the set of programming languages present in the project."""
|
||||
langs: set[str] = set()
|
||||
for f in path.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
if any(part in _IGNORE_DIRS for part in f.parts):
|
||||
continue
|
||||
lang = _EXT_TO_LANG.get(f.suffix.lower())
|
||||
if lang:
|
||||
langs.add(lang)
|
||||
return langs
|
||||
|
||||
|
||||
class Level(IntEnum):
|
||||
"""WOMM certification levels (Section 4.2)."""
|
||||
@ -81,12 +118,7 @@ def _check_project_exists(path: Path) -> CheckResult:
|
||||
|
||||
|
||||
def _check_has_source_files(path: Path) -> CheckResult:
|
||||
extensions = {".py", ".js", ".ts", ".c", ".cpp", ".go", ".rs", ".java", ".rb", ".sh"}
|
||||
found = any(
|
||||
f.suffix in extensions
|
||||
for f in path.rglob("*")
|
||||
if f.is_file() and ".git" not in f.parts
|
||||
)
|
||||
found = bool(_detect_languages(path))
|
||||
return CheckResult(
|
||||
name="Contains source code",
|
||||
passed=found,
|
||||
@ -99,45 +131,232 @@ def _check_has_source_files(path: Path) -> CheckResult:
|
||||
)
|
||||
|
||||
|
||||
def _check_python_syntax(path: Path) -> CheckResult:
|
||||
py_files = [
|
||||
f for f in path.rglob("*.py")
|
||||
if f.is_file() and ".git" not in f.parts
|
||||
]
|
||||
if not py_files:
|
||||
def _check_syntax(path: Path) -> CheckResult:
|
||||
"""Check syntax/compilability for all detected languages."""
|
||||
langs = _detect_languages(path)
|
||||
|
||||
if not langs:
|
||||
return CheckResult(
|
||||
name="Python syntax check",
|
||||
name="Syntax check",
|
||||
passed=True,
|
||||
message="No Python files to check. Blissful ignorance achieved.",
|
||||
message="No source files to check. Blissful ignorance achieved.",
|
||||
level=Level.BRONZE,
|
||||
)
|
||||
|
||||
errors: list[str] = []
|
||||
for py_file in py_files:
|
||||
checked: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
# --- Python ---
|
||||
if "python" in langs:
|
||||
py_files = [
|
||||
f for f in path.rglob("*.py")
|
||||
if f.is_file() and not any(p in _IGNORE_DIRS for p in f.parts)
|
||||
]
|
||||
checked.append("Python")
|
||||
for py_file in py_files:
|
||||
try:
|
||||
ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||
except SyntaxError as exc:
|
||||
errors.append(f"{py_file.name}:{exc.lineno}: {exc.msg}")
|
||||
|
||||
# --- TypeScript (tsc --noEmit) ---
|
||||
if "typescript" in langs and (path / "tsconfig.json").exists():
|
||||
try:
|
||||
ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||
except SyntaxError as exc:
|
||||
errors.append(f"{py_file.name}:{exc.lineno}: {exc.msg}")
|
||||
r = subprocess.run(
|
||||
["tsc", "--noEmit"],
|
||||
cwd=path, capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
checked.append("TypeScript")
|
||||
if r.returncode != 0:
|
||||
snippet = (r.stdout + r.stderr).strip().split("\n")[0]
|
||||
errors.append(f"TypeScript: {snippet}")
|
||||
except FileNotFoundError:
|
||||
skipped.append("TypeScript (tsc not found)")
|
||||
except subprocess.TimeoutExpired:
|
||||
errors.append("TypeScript: tsc timed out")
|
||||
|
||||
# --- JavaScript (node --check) ---
|
||||
elif "javascript" in langs:
|
||||
js_files = [
|
||||
f for f in path.rglob("*.js")
|
||||
if f.is_file() and not any(p in _IGNORE_DIRS for p in f.parts)
|
||||
][:20]
|
||||
if js_files:
|
||||
try:
|
||||
checked.append("JavaScript")
|
||||
for js_file in js_files:
|
||||
r = subprocess.run(
|
||||
["node", "--check", str(js_file)],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
snippet = r.stderr.strip().split("\n")[0]
|
||||
errors.append(f"{js_file.name}: {snippet}")
|
||||
except FileNotFoundError:
|
||||
checked.remove("JavaScript")
|
||||
skipped.append("JavaScript (node not found)")
|
||||
|
||||
# --- Go (go build) ---
|
||||
if "go" in langs and (path / "go.mod").exists():
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["go", "build", "./..."],
|
||||
cwd=path, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
checked.append("Go")
|
||||
if r.returncode != 0:
|
||||
snippet = r.stderr.strip().split("\n")[0]
|
||||
errors.append(f"Go: {snippet}")
|
||||
except FileNotFoundError:
|
||||
skipped.append("Go (go not found)")
|
||||
except subprocess.TimeoutExpired:
|
||||
errors.append("Go: build timed out")
|
||||
|
||||
# --- Rust (cargo check) ---
|
||||
if "rust" in langs and (path / "Cargo.toml").exists():
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["cargo", "check", "--quiet"],
|
||||
cwd=path, capture_output=True, text=True, timeout=180,
|
||||
)
|
||||
checked.append("Rust")
|
||||
if r.returncode != 0:
|
||||
snippet = r.stderr.strip().split("\n")[0]
|
||||
errors.append(f"Rust: {snippet}")
|
||||
except FileNotFoundError:
|
||||
skipped.append("Rust (cargo not found)")
|
||||
except subprocess.TimeoutExpired:
|
||||
errors.append("Rust: cargo check timed out")
|
||||
|
||||
# --- Ruby (ruby -c) ---
|
||||
if "ruby" in langs:
|
||||
rb_files = [
|
||||
f for f in path.rglob("*.rb")
|
||||
if f.is_file() and not any(p in _IGNORE_DIRS for p in f.parts)
|
||||
][:20]
|
||||
if rb_files:
|
||||
try:
|
||||
checked.append("Ruby")
|
||||
for rb_file in rb_files:
|
||||
r = subprocess.run(
|
||||
["ruby", "-c", str(rb_file)],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
snippet = r.stderr.strip().split("\n")[0]
|
||||
errors.append(f"{rb_file.name}: {snippet}")
|
||||
except FileNotFoundError:
|
||||
if "Ruby" in checked:
|
||||
checked.remove("Ruby")
|
||||
skipped.append("Ruby (ruby not found)")
|
||||
|
||||
# --- C / C++ (make build) ---
|
||||
_makefiles = [path / name for name in ("Makefile", "GNUmakefile", "makefile")]
|
||||
if ("c" in langs or "cpp" in langs) and any(m.exists() for m in _makefiles):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["make", "build"],
|
||||
cwd=path, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
checked.append("C/C++")
|
||||
elif "No rule to make target" in (r.stdout + r.stderr):
|
||||
skipped.append("C/C++ (no 'build' target in Makefile)")
|
||||
else:
|
||||
checked.append("C/C++")
|
||||
snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
|
||||
errors.append(f"C/C++: {snippet}")
|
||||
except FileNotFoundError:
|
||||
skipped.append("C/C++ (make not found)")
|
||||
except subprocess.TimeoutExpired:
|
||||
errors.append("C/C++: make build timed out")
|
||||
|
||||
lang_str = ", ".join(checked) if checked else ", ".join(sorted(langs))
|
||||
skip_note = f" (skipped: {', '.join(skipped)})" if skipped else ""
|
||||
|
||||
if errors:
|
||||
return CheckResult(
|
||||
name="Syntax check",
|
||||
passed=False,
|
||||
message=f"Syntax errors in {lang_str}{skip_note} ({len(errors)} issue(s)): {'; '.join(errors[:3])}",
|
||||
level=Level.BRONZE,
|
||||
)
|
||||
return CheckResult(
|
||||
name="Python syntax check",
|
||||
passed=len(errors) == 0,
|
||||
message=(
|
||||
"All Python files parse cleanly. The AST smiles upon you."
|
||||
if not errors
|
||||
else f"Syntax errors found ({len(errors)}): {'; '.join(errors[:3])}"
|
||||
),
|
||||
name="Syntax check",
|
||||
passed=True,
|
||||
message=f"All {lang_str} source parses cleanly{skip_note}. The AST smiles upon you.",
|
||||
level=Level.BRONZE,
|
||||
)
|
||||
|
||||
|
||||
def _is_visible(f: Path) -> bool:
|
||||
return not any(part in _IGNORE_DIRS for part in f.parts)
|
||||
|
||||
|
||||
def _check_tests_exist(path: Path) -> CheckResult:
|
||||
test_indicators = [
|
||||
path / "tests",
|
||||
path / "test",
|
||||
*path.rglob("test_*.py"),
|
||||
*path.rglob("*_test.py"),
|
||||
*path.rglob("tests.py"),
|
||||
]
|
||||
found = any(p.exists() for p in test_indicators)
|
||||
def rglob_visible(pattern: str) -> list[Path]:
|
||||
return [f for f in path.rglob(pattern) if _is_visible(f)]
|
||||
|
||||
# Python
|
||||
python_test = (
|
||||
(path / "tests").is_dir()
|
||||
or (path / "test").is_dir()
|
||||
or bool(rglob_visible("test_*.py"))
|
||||
or bool(rglob_visible("*_test.py"))
|
||||
or bool(rglob_visible("tests.py"))
|
||||
)
|
||||
# JavaScript / TypeScript
|
||||
js_test = (
|
||||
any(
|
||||
f for f in path.rglob("*")
|
||||
if f.is_file() and _is_visible(f)
|
||||
and f.name.endswith((".test.js", ".spec.js", ".test.ts", ".spec.ts",
|
||||
".test.jsx", ".spec.jsx", ".test.tsx", ".spec.tsx"))
|
||||
)
|
||||
or any(
|
||||
d for d in path.rglob("__tests__")
|
||||
if d.is_dir() and _is_visible(d)
|
||||
)
|
||||
)
|
||||
# Go
|
||||
go_test = bool(rglob_visible("*_test.go"))
|
||||
# Rust — tests/ subdir or inline test modules
|
||||
rust_test = bool(
|
||||
f for f in path.rglob("*.rs")
|
||||
if f.is_file() and _is_visible(f) and "tests" in f.parts
|
||||
) or (
|
||||
(path / "tests").is_dir()
|
||||
and any((path / "tests").rglob("*.rs"))
|
||||
)
|
||||
# Java
|
||||
java_test = (
|
||||
(path / "src" / "test").exists()
|
||||
or bool(rglob_visible("*Test.java"))
|
||||
or bool(rglob_visible("*Tests.java"))
|
||||
or bool(rglob_visible("*Spec.java"))
|
||||
)
|
||||
# Ruby
|
||||
ruby_test = (
|
||||
(path / "spec").is_dir()
|
||||
or bool(rglob_visible("*_spec.rb"))
|
||||
or bool(rglob_visible("*_test.rb"))
|
||||
)
|
||||
# C / C++ — Makefile with a 'test' target, or conventional test file patterns
|
||||
_makefiles = [path / name for name in ("Makefile", "GNUmakefile", "makefile")]
|
||||
c_test = (
|
||||
any(m.exists() for m in _makefiles)
|
||||
and (
|
||||
bool(rglob_visible("test_*.c"))
|
||||
or bool(rglob_visible("*_test.c"))
|
||||
or bool(rglob_visible("test_*.cpp"))
|
||||
or bool(rglob_visible("*_test.cpp"))
|
||||
or (path / "tests").is_dir()
|
||||
or (path / "test").is_dir()
|
||||
)
|
||||
)
|
||||
|
||||
found = python_test or js_test or go_test or rust_test or java_test or ruby_test or c_test
|
||||
return CheckResult(
|
||||
name="Test suite exists",
|
||||
passed=found,
|
||||
@ -151,43 +370,156 @@ def _check_tests_exist(path: Path) -> CheckResult:
|
||||
|
||||
|
||||
def _check_tests_pass(path: Path) -> CheckResult:
|
||||
# Try pytest, then unittest
|
||||
for cmd in (["python", "-m", "pytest", "-x", "-q"], ["python", "-m", "unittest", "discover", "-s", "."]):
|
||||
"""Run all applicable test suites and report combined results."""
|
||||
passed_runners: list[str] = []
|
||||
failed_runners: list[str] = []
|
||||
|
||||
# --- Go ---
|
||||
if (path / "go.mod").exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
r = subprocess.run(
|
||||
["go", "test", "./..."],
|
||||
cwd=path, capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return CheckResult(
|
||||
name="Tests pass",
|
||||
passed=True,
|
||||
message="All tests pass. On this machine, at least.",
|
||||
level=Level.SILVER,
|
||||
)
|
||||
elif result.returncode == 5 and "pytest" in cmd[2]:
|
||||
# pytest exit code 5 = no tests collected
|
||||
continue
|
||||
if r.returncode == 0:
|
||||
passed_runners.append("Go")
|
||||
else:
|
||||
snippet = (result.stdout + result.stderr).strip().split("\n")[-1]
|
||||
return CheckResult(
|
||||
name="Tests pass",
|
||||
passed=False,
|
||||
message=f"Tests failed. {snippet}",
|
||||
level=Level.SILVER,
|
||||
snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
|
||||
failed_runners.append(f"Go: {snippet}")
|
||||
except FileNotFoundError:
|
||||
pass # go not installed — skip
|
||||
except subprocess.TimeoutExpired:
|
||||
failed_runners.append("Go: timed out")
|
||||
|
||||
# --- Rust ---
|
||||
if (path / "Cargo.toml").exists():
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["cargo", "test", "--quiet"],
|
||||
cwd=path, capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
passed_runners.append("Rust")
|
||||
else:
|
||||
snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
|
||||
failed_runners.append(f"Rust: {snippet}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except subprocess.TimeoutExpired:
|
||||
failed_runners.append("Rust: timed out")
|
||||
|
||||
# --- Node.js (npm test) ---
|
||||
pkg_json = path / "package.json"
|
||||
if pkg_json.exists():
|
||||
try:
|
||||
scripts = json.loads(pkg_json.read_text(encoding="utf-8")).get("scripts", {})
|
||||
if "test" in scripts:
|
||||
r = subprocess.run(
|
||||
["npm", "test"],
|
||||
cwd=path, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
passed_runners.append("Node.js")
|
||||
else:
|
||||
snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
|
||||
failed_runners.append(f"Node.js: {snippet}")
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
pass
|
||||
except subprocess.TimeoutExpired:
|
||||
failed_runners.append("Node.js: timed out")
|
||||
|
||||
# --- Maven ---
|
||||
if (path / "pom.xml").exists():
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mvn", "test", "-q"],
|
||||
cwd=path, capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
passed_runners.append("Maven")
|
||||
else:
|
||||
snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
|
||||
failed_runners.append(f"Maven: {snippet}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except subprocess.TimeoutExpired:
|
||||
failed_runners.append("Maven: timed out")
|
||||
|
||||
# --- Gradle ---
|
||||
if (path / "build.gradle").exists() or (path / "build.gradle.kts").exists():
|
||||
gradle_cmd = "./gradlew" if (path / "gradlew").exists() else "gradle"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[gradle_cmd, "test"],
|
||||
cwd=path, capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
passed_runners.append("Gradle")
|
||||
else:
|
||||
snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
|
||||
failed_runners.append(f"Gradle: {snippet}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except subprocess.TimeoutExpired:
|
||||
failed_runners.append("Gradle: timed out")
|
||||
|
||||
# --- C / C++ (make test) ---
|
||||
_makefiles = [path / name for name in ("Makefile", "GNUmakefile", "makefile")]
|
||||
_langs = _detect_languages(path)
|
||||
if ("c" in _langs or "cpp" in _langs) and any(m.exists() for m in _makefiles):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["make", "test"],
|
||||
cwd=path, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
passed_runners.append("C/C++ (make test)")
|
||||
elif "No rule to make target" in (r.stdout + r.stderr):
|
||||
pass # no test target — skip gracefully
|
||||
else:
|
||||
snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
|
||||
failed_runners.append(f"C/C++: {snippet}")
|
||||
except FileNotFoundError:
|
||||
pass # make not installed — skip
|
||||
except subprocess.TimeoutExpired:
|
||||
failed_runners.append("C/C++: make test timed out")
|
||||
|
||||
# --- Python (pytest, then unittest) ---
|
||||
for cmd in (
|
||||
["python", "-m", "pytest", "-x", "-q"],
|
||||
["python", "-m", "unittest", "discover", "-s", "."],
|
||||
):
|
||||
try:
|
||||
r = subprocess.run(cmd, cwd=path, capture_output=True, text=True, timeout=120)
|
||||
if r.returncode == 0:
|
||||
passed_runners.append("Python")
|
||||
break
|
||||
elif r.returncode == 5 and "pytest" in cmd[2]:
|
||||
continue # no tests collected — try unittest
|
||||
else:
|
||||
snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
|
||||
failed_runners.append(f"Python: {snippet}")
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except subprocess.TimeoutExpired:
|
||||
return CheckResult(
|
||||
name="Tests pass",
|
||||
passed=False,
|
||||
message="Tests timed out after 120s. The machine grew weary.",
|
||||
level=Level.SILVER,
|
||||
)
|
||||
failed_runners.append("Python: timed out")
|
||||
break
|
||||
|
||||
if failed_runners:
|
||||
return CheckResult(
|
||||
name="Tests pass",
|
||||
passed=False,
|
||||
message=f"Test failures detected: {'; '.join(failed_runners[:3])}",
|
||||
level=Level.SILVER,
|
||||
)
|
||||
if passed_runners:
|
||||
return CheckResult(
|
||||
name="Tests pass",
|
||||
passed=True,
|
||||
message=f"All {', '.join(passed_runners)} tests pass. On this machine, at least.",
|
||||
level=Level.SILVER,
|
||||
)
|
||||
return CheckResult(
|
||||
name="Tests pass",
|
||||
passed=False,
|
||||
@ -300,7 +632,7 @@ def _check_git_clean(path: Path) -> CheckResult:
|
||||
ALL_CHECKS = [
|
||||
_check_project_exists,
|
||||
_check_has_source_files,
|
||||
_check_python_syntax,
|
||||
_check_syntax,
|
||||
_check_tests_exist,
|
||||
_check_tests_pass,
|
||||
_check_readme_exists,
|
||||
|
||||
@ -3,6 +3,9 @@ README.md
|
||||
WOMM-CERTIFICATE.txt
|
||||
WOMM-STANDARD-001.md
|
||||
pyproject.toml
|
||||
assets/womm-seal-bronze.svg
|
||||
assets/womm-seal-gold.svg
|
||||
assets/womm-seal-platinum.svg
|
||||
assets/womm-seal.svg
|
||||
src/womm/__init__.py
|
||||
src/womm/badge.py
|
||||
|
||||
@ -12,7 +12,7 @@ from womm.certify import (
|
||||
certify,
|
||||
_check_has_source_files,
|
||||
_check_project_exists,
|
||||
_check_python_syntax,
|
||||
_check_syntax,
|
||||
_check_readme_exists,
|
||||
_check_no_todo_comments,
|
||||
)
|
||||
@ -76,13 +76,13 @@ class TestIndividualChecks:
|
||||
result = _check_has_source_files(empty_project)
|
||||
assert not result.passed
|
||||
|
||||
def test_python_syntax_pass(self, bronze_project: Path) -> None:
|
||||
result = _check_python_syntax(bronze_project)
|
||||
def test_syntax_pass(self, bronze_project: Path) -> None:
|
||||
result = _check_syntax(bronze_project)
|
||||
assert result.passed
|
||||
|
||||
def test_python_syntax_fail(self, tmp_path: Path) -> None:
|
||||
def test_syntax_fail(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "bad.py").write_text("def oops(\n")
|
||||
result = _check_python_syntax(tmp_path)
|
||||
result = _check_syntax(tmp_path)
|
||||
assert not result.passed
|
||||
|
||||
def test_readme_exists_pass(self, tmp_path: Path) -> None:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user