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:
Greg Gauthier 2026-04-03 10:47:22 +01:00
parent 3791859422
commit 26b9299d44
3 changed files with 404 additions and 69 deletions

View File

@ -3,12 +3,49 @@
from __future__ import annotations from __future__ import annotations
import ast import ast
import json
import os import os
import subprocess import subprocess
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import IntEnum from enum import IntEnum
from pathlib import Path 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): class Level(IntEnum):
"""WOMM certification levels (Section 4.2).""" """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: def _check_has_source_files(path: Path) -> CheckResult:
extensions = {".py", ".js", ".ts", ".c", ".cpp", ".go", ".rs", ".java", ".rb", ".sh"} found = bool(_detect_languages(path))
found = any(
f.suffix in extensions
for f in path.rglob("*")
if f.is_file() and ".git" not in f.parts
)
return CheckResult( return CheckResult(
name="Contains source code", name="Contains source code",
passed=found, passed=found,
@ -99,45 +131,232 @@ def _check_has_source_files(path: Path) -> CheckResult:
) )
def _check_python_syntax(path: Path) -> CheckResult: def _check_syntax(path: Path) -> CheckResult:
py_files = [ """Check syntax/compilability for all detected languages."""
f for f in path.rglob("*.py") langs = _detect_languages(path)
if f.is_file() and ".git" not in f.parts
] if not langs:
if not py_files:
return CheckResult( return CheckResult(
name="Python syntax check", name="Syntax check",
passed=True, passed=True,
message="No Python files to check. Blissful ignorance achieved.", message="No source files to check. Blissful ignorance achieved.",
level=Level.BRONZE, level=Level.BRONZE,
) )
errors: list[str] = [] 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: try:
ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) r = subprocess.run(
except SyntaxError as exc: ["tsc", "--noEmit"],
errors.append(f"{py_file.name}:{exc.lineno}: {exc.msg}") 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( return CheckResult(
name="Python syntax check", name="Syntax check",
passed=len(errors) == 0, passed=True,
message=( message=f"All {lang_str} source parses cleanly{skip_note}. The AST smiles upon you.",
"All Python files parse cleanly. The AST smiles upon you."
if not errors
else f"Syntax errors found ({len(errors)}): {'; '.join(errors[:3])}"
),
level=Level.BRONZE, 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: def _check_tests_exist(path: Path) -> CheckResult:
test_indicators = [ def rglob_visible(pattern: str) -> list[Path]:
path / "tests", return [f for f in path.rglob(pattern) if _is_visible(f)]
path / "test",
*path.rglob("test_*.py"), # Python
*path.rglob("*_test.py"), python_test = (
*path.rglob("tests.py"), (path / "tests").is_dir()
] or (path / "test").is_dir()
found = any(p.exists() for p in test_indicators) 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( return CheckResult(
name="Test suite exists", name="Test suite exists",
passed=found, passed=found,
@ -151,43 +370,156 @@ def _check_tests_exist(path: Path) -> CheckResult:
def _check_tests_pass(path: Path) -> CheckResult: def _check_tests_pass(path: Path) -> CheckResult:
# Try pytest, then unittest """Run all applicable test suites and report combined results."""
for cmd in (["python", "-m", "pytest", "-x", "-q"], ["python", "-m", "unittest", "discover", "-s", "."]): passed_runners: list[str] = []
failed_runners: list[str] = []
# --- Go ---
if (path / "go.mod").exists():
try: try:
result = subprocess.run( r = subprocess.run(
cmd, ["go", "test", "./..."],
cwd=path, cwd=path, capture_output=True, text=True, timeout=300,
capture_output=True,
text=True,
timeout=120,
) )
if result.returncode == 0: if r.returncode == 0:
return CheckResult( passed_runners.append("Go")
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
else: else:
snippet = (result.stdout + result.stderr).strip().split("\n")[-1] snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
return CheckResult( failed_runners.append(f"Go: {snippet}")
name="Tests pass", except FileNotFoundError:
passed=False, pass # go not installed — skip
message=f"Tests failed. {snippet}", except subprocess.TimeoutExpired:
level=Level.SILVER, 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: except FileNotFoundError:
continue continue
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return CheckResult( failed_runners.append("Python: timed out")
name="Tests pass", break
passed=False,
message="Tests timed out after 120s. The machine grew weary.", if failed_runners:
level=Level.SILVER, 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( return CheckResult(
name="Tests pass", name="Tests pass",
passed=False, passed=False,
@ -300,7 +632,7 @@ def _check_git_clean(path: Path) -> CheckResult:
ALL_CHECKS = [ ALL_CHECKS = [
_check_project_exists, _check_project_exists,
_check_has_source_files, _check_has_source_files,
_check_python_syntax, _check_syntax,
_check_tests_exist, _check_tests_exist,
_check_tests_pass, _check_tests_pass,
_check_readme_exists, _check_readme_exists,

View File

@ -3,6 +3,9 @@ README.md
WOMM-CERTIFICATE.txt WOMM-CERTIFICATE.txt
WOMM-STANDARD-001.md WOMM-STANDARD-001.md
pyproject.toml pyproject.toml
assets/womm-seal-bronze.svg
assets/womm-seal-gold.svg
assets/womm-seal-platinum.svg
assets/womm-seal.svg assets/womm-seal.svg
src/womm/__init__.py src/womm/__init__.py
src/womm/badge.py src/womm/badge.py

View File

@ -12,7 +12,7 @@ from womm.certify import (
certify, certify,
_check_has_source_files, _check_has_source_files,
_check_project_exists, _check_project_exists,
_check_python_syntax, _check_syntax,
_check_readme_exists, _check_readme_exists,
_check_no_todo_comments, _check_no_todo_comments,
) )
@ -76,13 +76,13 @@ class TestIndividualChecks:
result = _check_has_source_files(empty_project) result = _check_has_source_files(empty_project)
assert not result.passed assert not result.passed
def test_python_syntax_pass(self, bronze_project: Path) -> None: def test_syntax_pass(self, bronze_project: Path) -> None:
result = _check_python_syntax(bronze_project) result = _check_syntax(bronze_project)
assert result.passed 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") (tmp_path / "bad.py").write_text("def oops(\n")
result = _check_python_syntax(tmp_path) result = _check_syntax(tmp_path)
assert not result.passed assert not result.passed
def test_readme_exists_pass(self, tmp_path: Path) -> None: def test_readme_exists_pass(self, tmp_path: Path) -> None: