ci(ariadne): enforce docs gate before loc/coverage and publish docs_naming

This commit is contained in:
codex 2026-04-20 08:12:22 -03:00
parent c64aca3869
commit 3c157b9523
3 changed files with 117 additions and 111 deletions

138
Jenkinsfile vendored
View File

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

View File

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

View File

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