diff --git a/src/womm/certify.py b/src/womm/certify.py index efade16..800e019 100644 --- a/src/womm/certify.py +++ b/src/womm/certify.py @@ -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, diff --git a/src/womm_certification.egg-info/SOURCES.txt b/src/womm_certification.egg-info/SOURCES.txt index e73ec4e..d3ee30f 100644 --- a/src/womm_certification.egg-info/SOURCES.txt +++ b/src/womm_certification.egg-info/SOURCES.txt @@ -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 diff --git a/tests/test_certify.py b/tests/test_certify.py index a7b671d..89c0541 100644 --- a/tests/test_certify.py +++ b/tests/test_certify.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: