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
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,

View File

@ -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

View File

@ -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: