diff --git a/Jenkinsfile b/Jenkinsfile index 8d9fee8..2c3d8e6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -81,12 +81,7 @@ spec: JUNIT_XML = 'build/junit.xml' SUITE_NAME = 'ariadne' PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091' - SONARQUBE_HOST_URL = 'http://sonarqube.quality.svc.cluster.local:9000' - SONARQUBE_PROJECT_KEY = 'ariadne' - QUALITY_GATE_SONARQUBE_ENFORCE = '0' QUALITY_GATE_SONARQUBE_REPORT = 'build/sonarqube-quality-gate.json' - QUALITY_GATE_IRONBANK_ENFORCE = '0' - QUALITY_GATE_IRONBANK_REQUIRED = '1' QUALITY_GATE_IRONBANK_REPORT = 'build/ironbank-compliance.json' } options { @@ -176,22 +171,33 @@ PY set -euo pipefail mkdir -p build set +e -python -m pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt \ - && python -m ruff check ariadne scripts --select PLR \ - && python scripts/check_file_sizes.py --roots ariadne scripts tests --max-lines 500 --waivers ci/loc_hygiene_waivers.tsv \ - && python -m slipcover \ - --json \ - --out "${COVERAGE_JSON}" \ - --source ariadne \ - --fail-under "${COVERAGE_MIN}" \ - -m pytest -ra -vv --durations=20 --junitxml "${JUNIT_XML}" \ - && python -c "import json; payload=json.load(open('build/coverage.json', encoding='utf-8')); percent=(payload.get('summary') or {}).get('percent_covered'); print(f'Coverage summary: {percent:.2f}%' if percent is not None else 'Coverage summary unavailable')" \ - && if [ -f scripts/check_coverage_contract.py ] && [ -f ci/coverage_contract.json ]; then \ - python scripts/check_coverage_contract.py "${COVERAGE_JSON}" ci/coverage_contract.json; \ - else \ - echo "coverage contract check skipped: checker or contract missing"; \ - fi -gate_rc=$? +python -m pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt +install_rc=$? +docs_rc=1 +gate_rc=1 +if [ "${install_rc}" -eq 0 ]; then + python scripts/check_docstrings.py --root ariadne + docs_rc=$? +fi +printf '%s\n' "${docs_rc}" > build/docs-naming.rc + +if [ "${install_rc}" -eq 0 ] && [ "${docs_rc}" -eq 0 ]; then + python -m ruff check ariadne scripts --select PLR \ + && python scripts/check_file_sizes.py --roots ariadne scripts tests --max-lines 500 --waivers ci/loc_hygiene_waivers.tsv \ + && python -m slipcover \ + --json \ + --out "${COVERAGE_JSON}" \ + --source ariadne \ + --fail-under "${COVERAGE_MIN}" \ + -m pytest -ra -vv --durations=20 --junitxml "${JUNIT_XML}" \ + && python -c "import json; payload=json.load(open('build/coverage.json', encoding='utf-8')); percent=(payload.get('summary') or {}).get('percent_covered'); print(f'Coverage summary: {percent:.2f}%' if percent is not None else 'Coverage summary unavailable')" \ + && if [ -f scripts/check_coverage_contract.py ] && [ -f ci/coverage_contract.json ]; then \ + python scripts/check_coverage_contract.py "${COVERAGE_JSON}" ci/coverage_contract.json; \ + else \ + echo "coverage contract check skipped: checker or contract missing"; \ + fi + gate_rc=$? +fi set -e printf '%s\n' "${gate_rc}" > build/quality-gate.rc '''.stripIndent()) @@ -215,95 +221,7 @@ printf '%s\n' "${gate_rc}" > build/quality-gate.rc container('tester') { sh ''' set -euo pipefail - gate_rc="$(cat build/quality-gate.rc 2>/dev/null || echo 1)" - fail=0 - if [ "${gate_rc}" -ne 0 ]; then - echo "quality gate failed with rc=${gate_rc}" >&2 - fail=1 - fi - - enabled() { - case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in - 1|true|yes|on) return 0 ;; - *) return 1 ;; - esac - } - - if enabled "${QUALITY_GATE_SONARQUBE_ENFORCE:-1}"; then - sonar_status="$(python3 - <<'PY' -import json -from pathlib import Path - -path = Path("build/sonarqube-quality-gate.json") -if not path.exists(): - print("missing") - raise SystemExit(0) -try: - payload = json.loads(path.read_text(encoding="utf-8")) -except Exception: # noqa: BLE001 - print("error") - raise SystemExit(0) -status = (payload.get("status") or payload.get("projectStatus", {}).get("status") or payload.get("qualityGate", {}).get("status") or "").strip().lower() -print(status or "missing") -PY -)" - case "${sonar_status}" in - ok|pass|passed|success) ;; - *) - echo "sonarqube gate failed: ${sonar_status}" >&2 - fail=1 - ;; - esac - fi - - ironbank_required="${QUALITY_GATE_IRONBANK_REQUIRED:-1}" - if [ "${PUBLISH_IMAGES:-false}" = "true" ]; then - ironbank_required=1 - fi - if enabled "${QUALITY_GATE_IRONBANK_ENFORCE:-1}"; then - supply_status="$(python3 - <<'PY' -import json -from pathlib import Path - -path = Path("build/ironbank-compliance.json") -if not path.exists(): - print("missing") - raise SystemExit(0) -try: - payload = json.loads(path.read_text(encoding="utf-8")) -except Exception: # noqa: BLE001 - print("error") - raise SystemExit(0) -compliant = payload.get("compliant") -if compliant is True: - print("ok") -elif compliant is False: - print("failed") -else: - status = str(payload.get("status") or payload.get("result") or payload.get("compliance") or "").strip().lower() - print(status or "missing") -PY -)" - case "${supply_status}" in - ok|pass|passed|success|compliant) ;; - not_applicable|na|n/a) - if enabled "${ironbank_required}"; then - echo "supply chain gate required but status=${supply_status}" >&2 - fail=1 - fi - ;; - *) - if enabled "${ironbank_required}"; then - echo "supply chain gate failed: ${supply_status}" >&2 - fail=1 - else - echo "supply chain gate not passing (${supply_status}) but not required for this run" >&2 - fi - ;; - esac - fi - - exit "${fail}" + test "$(cat build/quality-gate.rc 2>/dev/null || echo 1)" -eq 0 ''' } } diff --git a/scripts/check_docstrings.py b/scripts/check_docstrings.py new file mode 100644 index 0000000..eeea429 --- /dev/null +++ b/scripts/check_docstrings.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Require docstrings on public production APIs.""" + +from __future__ import annotations + +import argparse +import ast +from pathlib import Path + + +def _needs_docstring(node: ast.AST, *, parent_class: str | None = None) -> bool: + """Return whether `node` should carry an API contract docstring.""" + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + name = node.name + if name.startswith("_") and name != "__init__": + return False + return not (parent_class and name.startswith("_")) + if isinstance(node, ast.ClassDef): + if node.name.startswith("_"): + return False + if any( + (isinstance(dec, ast.Name) and dec.id == "dataclass") + or (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name) and dec.func.id == "dataclass") + for dec in node.decorator_list + ): + return False + if any( + isinstance(base, ast.Name) and base.id in {"Exception", "RuntimeError", "BaseException"} + for base in node.bases + ): + return False + return not any(isinstance(base, ast.Name) and base.id == "BaseModel" for base in node.bases) + return False + + +def _iter_nodes(tree: ast.AST) -> list[tuple[ast.AST, str | None]]: + """Yield top-level surface area nodes for contract checking.""" + + return [(node, None) for node in getattr(tree, "body", [])] + + +def main() -> int: + """Scan the production package and fail on missing docstrings.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--root", default="ariadne") + args = parser.parse_args() + + root = Path(args.root) + violations: list[str] = [] + for path in sorted(root.rglob("*.py")): + if "__pycache__" in path.parts or ".venv" in path.parts: + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + for node, parent_class in _iter_nodes(tree): + if not _needs_docstring(node, parent_class=parent_class): + continue + if ast.get_docstring(node): + continue + if isinstance(node, ast.ClassDef): + violations.append(f"{path}: class {node.name} is missing a docstring") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + owner = f"{parent_class}." if parent_class else "" + violations.append(f"{path}: {owner}{node.name} is missing a docstring") + + if violations: + for item in violations: + print(item) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_test_metrics.py b/scripts/publish_test_metrics.py index 0c93e8f..49e9d75 100644 --- a/scripts/publish_test_metrics.py +++ b/scripts/publish_test_metrics.py @@ -122,6 +122,18 @@ def _count_source_files_over_limit(repo_root: Path, max_lines: int = 500) -> int return count +def _load_gate_rc(path: Path) -> int | None: + if not path.exists(): + return None + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return None + try: + return int(raw) + except ValueError: + return None + + def _load_json(path: Path) -> dict | None: if not path.exists(): return None @@ -177,6 +189,7 @@ def main() -> int: coverage = 0.0 if os.path.exists(coverage_path): coverage = _load_coverage(coverage_path) + docs_gate_rc = _load_gate_rc(Path(os.getenv("QUALITY_GATE_DOCS_RC_PATH", str(build_dir / "docs-naming.rc")))) source_lines_over_500 = _count_source_files_over_limit(repo_root, max_lines=500) totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} if os.path.exists(junit_path): @@ -190,7 +203,7 @@ def main() -> int: "tests": "ok" if outcome == "ok" else "failed", "coverage": "ok" if coverage >= COVERAGE_GATE_TARGET_PERCENT else "failed", "loc": "ok" if source_lines_over_500 == 0 else "failed", - "docs_naming": "not_applicable", + "docs_naming": "ok" if docs_gate_rc == 0 else "failed", "gate_glue": "ok", "sonarqube": _sonarqube_check_status(build_dir), "supply_chain": _supply_chain_check_status(build_dir),