From 6806db97f0121d79f830d1117802de151466248e Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Tue, 1 Sep 2026 14:16:19 +0530 Subject: [PATCH 01/49] feat: pyansys-quality-report hook --- .pre-commit-hooks.yaml | 6 + setup.py | 1 + .../pyansys_quality_report.py | 116 ++++++++++++++++++ tests/test_pyansys_quality_report.py | 39 ++++++ 4 files changed, 162 insertions(+) create mode 100644 src/ansys/pre_commit_hooks/pyansys_quality_report.py create mode 100644 tests/test_pyansys_quality_report.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 428852f2..2d4c4749 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -10,4 +10,10 @@ description: "Perform initial technical review on a repository" entry: tech-review language: python + pass_filenames: false +- id: "pyansys-quality-report" + name: "PyAnsys Quality Report" + description: "Generate a PyAnsys repository quality summary" + entry: pyansys-quality-report + language: python pass_filenames: false \ No newline at end of file diff --git a/setup.py b/setup.py index 617f340c..0ede5ecd 100644 --- a/setup.py +++ b/setup.py @@ -74,6 +74,7 @@ "console_scripts": [ "add-license-headers=ansys.pre_commit_hooks.add_license_headers:main", "tech-review=ansys.pre_commit_hooks.tech_review:main", + "pyansys-quality-report=ansys.pre_commit_hooks.pyansys_quality_report:main", ], }, ) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py new file mode 100644 index 00000000..086fc412 --- /dev/null +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -0,0 +1,116 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Generate a PyAnsys repository quality report for the current project.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from pyansys_review._traversable import MemoryTraversable +from pyansys_review.checks import repo_review_checks +from pyansys_review.fixtures import is_mcp, readme_path, workflow_map +from pyansys_review.server import _run_checks + +_PATHS_TO_FETCH = [ + "AUTHORS", + "CHANGELOG.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "CONTRIBUTORS.md", + "LICENSE", + "README.rst", + "README.md", + "SECURITY.md", + ".github/CODEOWNERS", + ".github/dependabot.yml", + ".github/labeler.yml", + ".github/labels.yml", + ".github/zizmor.yml", + ".github/workflows/ci_cd_main.yml", + ".github/workflows/ci_cd_pr.yml", + ".github/workflows/ci_cd_release.yml", + ".pre-commit-config.yaml", + "pyproject.toml", + "setup.py", + "setup.cfg", + "doc/.vale.ini", + "doc/source/index.rst", + "doc/source/conf.py", + "doc/styles/config/vocabularies/ANSYS/accept.txt", + "doc/styles/config/vocabularies/ANSYS/reject.txt", +] + + +def _load_files(repo_root: Path) -> dict[str, str | None]: + """Collect the repository files most relevant to the quality review.""" + files: dict[str, str | None] = {} + for relative_path in _PATHS_TO_FETCH: + candidate = repo_root / relative_path + if candidate.is_file(): + files[relative_path] = candidate.read_text(encoding="utf-8", errors="replace") + else: + files[relative_path] = None + + workflows = repo_root / ".github" / "workflows" + if workflows.is_dir(): + for workflow in workflows.glob("*.y*ml"): + files[workflow.relative_to(repo_root).as_posix()] = workflow.read_text( + encoding="utf-8", errors="replace" + ) + + return files + + +def _print_report(review: dict[str, Any]) -> None: + """Print a short repo-quality summary to stdout.""" + results = review["results"] + tally = review["tally"] + score = review["score"] + + print("PyAnsys quality report") + print("=" * 24) + print(f"Score: {score}%") + print( + "Summary: " + f"pass={tally['pass']} fail={tally['fail']} warn={tally['warn']} na={tally['na']}" + ) + + for item in results: + if item["status"] == "pass": + continue + label = item["label"] + detail = item["detail"] or "" + print(f"- [{item['status'].upper()}] {item['id']} - {label}") + if detail: + print(f" {detail}") + + +def main(argv: list[str] | None = None) -> int: + """Run all configured PyAnsys quality checks against a repository.""" + parser = argparse.ArgumentParser(description="Run the PyAnsys repository quality report.") + parser.add_argument("--repo-root", default=".", help="Repository root to review.") + parser.add_argument("--json", action="store_true", help="Emit a JSON report instead of a text summary.") + args = parser.parse_args(argv) + + repo_root = Path(args.repo_root).resolve() + if not repo_root.exists(): + raise FileNotFoundError(f"Repo root not found: {repo_root}") + + files = _load_files(repo_root) + root = MemoryTraversable(files) + review = _run_checks(files, is_mcp_flag=is_mcp(root)) + + if args.json: + print(json.dumps(review, indent=2)) + return 1 if review["tally"]["fail"] else 0 + + _print_report(review) + return 1 if review["tally"]["fail"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py new file mode 100644 index 00000000..64eecd2b --- /dev/null +++ b/tests/test_pyansys_quality_report.py @@ -0,0 +1,39 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import os +from pathlib import Path + +import git + +import ansys.pre_commit_hooks.pyansys_quality_report as hook + + +def test_main_reports_quality_summary(tmp_path, capsys): + """The quality report hook should run and print a summary for the repo.""" + repo_path = tmp_path / "quality-demo" + repo_path.mkdir() + os.chdir(repo_path) + git.Repo.init(repo_path) + + (repo_path / ".pre-commit-config.yaml").write_text("repos: []\n", encoding="utf-8") + (repo_path / "README.rst").write_text("Demo\n=====\n", encoding="utf-8") + (repo_path / "pyproject.toml").write_text( + """ +[project] +name = "ansys-demo-library" +version = "0.1.0" +authors = [{name = "Example", email = "example@example.com"}] +maintainers = [{name = "Example", email = "example@example.com"}] +""".strip(), + encoding="utf-8", + ) + + exit_code = hook.main(["--repo-root", str(repo_path)]) + output = capsys.readouterr().out + + assert exit_code in (0, 1) + assert "PyAnsys quality report" in output + assert "Score" in output or "Summary" in output From d3312598ff6bfbb062247a0fbe5f091c043e5bb8 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Tue, 1 Sep 2026 14:26:00 +0530 Subject: [PATCH 02/49] feat: migrate the checks --- .../pyansys_quality_report.py | 1477 ++++++++++++++++- 1 file changed, 1457 insertions(+), 20 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 086fc412..5560615e 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -1,19 +1,26 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT -"""Generate a PyAnsys repository quality report for the current project.""" +"""Generate a PyAnsys repository quality report for the current project. + +This implementation keeps the quality logic bundled in the hook repo itself so it +works in a standalone pre-commit environment without the separate +``pyansys-repo-review`` package being installed. +""" from __future__ import annotations import argparse import json +import re +from io import BytesIO, StringIO from pathlib import Path -from typing import Any +from typing import Any, Iterator -from pyansys_review._traversable import MemoryTraversable -from pyansys_review.checks import repo_review_checks -from pyansys_review.fixtures import is_mcp, readme_path, workflow_map -from pyansys_review.server import _run_checks +try: + from importlib.resources.abc import Traversable +except ImportError: # pragma: no cover + from importlib.abc import Traversable _PATHS_TO_FETCH = [ "AUTHORS", @@ -45,8 +52,1447 @@ ] +class MemoryTraversable(Traversable): + """In-memory Traversable backed by a flat dict mapping path -> content.""" + + def __init__(self, files: dict[str, str | None], path: str = "") -> None: + self._files = files + self._path = path.strip("/") + + @property + def name(self) -> str: + return self._path.split("/")[-1] if self._path else "" + + def is_file(self) -> bool: + return self._path in self._files and self._files[self._path] is not None + + def is_dir(self) -> bool: + if not self._path: + return True + prefix = self._path + "/" + return any(key.startswith(prefix) for key in self._files) + + def iterdir(self) -> Iterator["MemoryTraversable"]: + prefix = (self._path + "/") if self._path else "" + seen: set[str] = set() + for key in self._files: + if not key.startswith(prefix): + continue + rest = key[len(prefix) :] + child_name = rest.split("/")[0] + if child_name and child_name not in seen: + seen.add(child_name) + yield MemoryTraversable(self._files, f"{prefix}{child_name}") + + def joinpath(self, *parts: str) -> "MemoryTraversable": + combined = "/".join(filter(None, [self._path, *parts])) + return MemoryTraversable(self._files, combined) + + __truediv__ = joinpath + + def open(self, mode: str = "r", encoding: str = "utf-8", **_) -> StringIO | BytesIO: + content = self._files.get(self._path) + if content is None: + raise FileNotFoundError(self._path) + if "b" in mode: + return BytesIO(content.encode(encoding)) + return StringIO(content) + + def read_bytes(self) -> bytes: + return self.open("rb").read() + + def read_text(self, encoding: str = "utf-8") -> str: + content = self._files.get(self._path) + if content is None: + raise FileNotFoundError(self._path) + return content + + def __repr__(self) -> str: + return f"MemoryTraversable({self._path!r})" + + def __str__(self) -> str: + return self._path + + +def file_exists(root: Traversable, path: str) -> bool: + try: + return root.joinpath(path).is_file() + except Exception: + return False + + +def file_content(root: Traversable, path: str) -> str: + try: + f = root.joinpath(path) + if f.is_file(): + return f.read_text(encoding="utf-8") + except Exception: + pass + return "" + + +def file_contains(root: Traversable, path: str, pattern: str | re.Pattern) -> bool: + content = file_content(root, path) + if not content: + return False + if isinstance(pattern, str): + return pattern in content + return bool(pattern.search(content)) + + +CANONICAL_WF = { + "main": ".github/workflows/ci_cd_main.yml", + "pr": ".github/workflows/ci_cd_pr.yml", + "release": ".github/workflows/ci_cd_release.yml", +} + + +def all_workflows_content(root: Traversable) -> str: + return _merge_all_workflows(root) + + +def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, str]: + canonical = CANONICAL_WF[role] + if file_exists(root, canonical): + return True, file_content(root, canonical) + + entry = workflow_map.get(role) + if entry and not entry.get("is_fallback"): + path = entry.get("path", "") + return False, file_content(root, path) if path else "" + return False, _merge_all_workflows(root) + + +def _merge_all_workflows(root: Traversable) -> str: + try: + entries = [ + e for e in root.joinpath(".github/workflows").iterdir() if e.name.endswith((".yml", ".yaml")) + ] + except Exception: + return "" + parts = [] + for entry in entries: + try: + c = entry.read_text(encoding="utf-8") + if c: + parts.append(c) + except Exception: + pass + return "\n\n".join(parts) + + +def wf_label(role: str, workflow_map: dict) -> str: + entry = workflow_map.get(role) + if not entry: + return CANONICAL_WF.get(role, role) + if entry.get("is_fallback"): + sources = entry.get("sources", []) + return f"{len(sources)} workflow file(s) ({', '.join(sources)})" + return entry.get("name", role) + + +def workflow_map(root: Traversable) -> dict[str, dict]: + wf_dir = root.joinpath(".github/workflows") + try: + entries = [e for e in wf_dir.iterdir() if e.name.endswith((".yml", ".yaml"))] + except Exception: + entries = [] + + result: dict[str, dict] = {} + for entry in entries: + role = _classify_workflow(entry.name) + if role != "unknown" and role not in result: + result[role] = { + "name": entry.name, + "path": f".github/workflows/{entry.name}", + "is_fallback": False, + "sources": [entry.name], + } + + for role in ("main", "pr", "release"): + if role not in result and entries: + result[role] = { + "name": f"{len(entries)} workflow(s)", + "path": None, + "is_fallback": True, + "sources": [e.name for e in entries], + } + + return result + + +def _classify_workflow(name: str) -> str: + n = name.lower() + if re.search(r"release|publish|deploy", n): + return "release" + if re.search(r"\bpr\b|pull.?request|pull_request", n): + return "pr" + if re.search(r"main|push|branch|nightly|schedule|ci_cd_main|ci.main", n): + return "main" + if re.search(r"\bci\b|build|test", n): + return "pr" + return "unknown" + + +def readme_path(root: Traversable) -> str | None: + if file_exists(root, "README.rst"): + return "README.rst" + if file_exists(root, "README.md"): + return "README.md" + return None + + +def is_mcp(root: Traversable) -> bool: + try: + pyproject_text = root.joinpath("pyproject.toml").read_text() + return bool(re.search(r"\b(fastmcp|mcp)\b", pyproject_text, re.IGNORECASE)) + except Exception: + pass + try: + return file_exists(root, "src/server.py") or file_exists(root, "server.py") + except Exception: + return False + + +class ProjectMetadata: + family = "project_metadata" + + +class PM001(ProjectMetadata): + "AUTHORS exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "AUTHORS") + + +class PM002(ProjectMetadata): + "CHANGELOG.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "CHANGELOG.md") + + +class PM003(ProjectMetadata): + "CODE_OF_CONDUCT.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "CODE_OF_CONDUCT.md") + + +class PM004(ProjectMetadata): + "CONTRIBUTING.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "CONTRIBUTING.md") + + +class PM005(ProjectMetadata): + "CONTRIBUTORS.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "CONTRIBUTORS.md") + + +class PM006(ProjectMetadata): + "LICENSE exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "LICENSE") + + +class PM007(ProjectMetadata): + "README exists (.rst preferred)" + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | str: + if readme_path is None: + return False + if readme_path == "README.md": + return "⚠️ README.md found — README.rst is the preferred format." + return True + + +class PM008(ProjectMetadata): + "SECURITY.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "SECURITY.md") + + +class PM009(ProjectMetadata): + ".github/CODEOWNERS exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, ".github/CODEOWNERS") + + +class PM010(ProjectMetadata): + "pyproject.toml references README file" + + requires = {"PM007"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not file_exists(root, "pyproject.toml"): + return None + content = file_content(root, "pyproject.toml") + if re.search(r"poetry\.core|poetry-core", content): + m = re.search(r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M) + if m: + return True + return False + rm = readme_path or "README.rst" + if rm in content: + return True + if "README" in content: + return "⚠️ readme key found but exact README filename not confirmed." + return False + + +class PM011(ProjectMetadata): + "pyproject.toml references LICENSE file" + + requires = {"PM006"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "pyproject.toml"): + return None + c = file_content(root, "pyproject.toml") + if re.search(r"poetry\.core|poetry-core", c): + return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", c, re.M)) + return bool( + re.search(r"license-files\s*=", c) + or re.search(r'license\s*=\s*\{[^}]*file', c) + or re.search(r'license\s*=\s*["\']LICENSE["\']', c) + ) + + +class CICDFiles: + family = "cicd_files" + + +class CI001(CICDFiles): + "ci_cd_main.yml exists" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | str: + if file_exists(root, CANONICAL_WF["main"]): + return True + lbl = wf_label("main", workflow_map) + return f"⚠️ Canonical ci_cd_main.yml not found — detected: {lbl}" + + +class CI002(CICDFiles): + "ci_cd_pr.yml exists" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | str: + if file_exists(root, CANONICAL_WF["pr"]): + return True + lbl = wf_label("pr", workflow_map) + return f"⚠️ Canonical ci_cd_pr.yml not found — detected: {lbl}" + + +class CI003(CICDFiles): + "ci_cd_release.yml exists" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | str: + if file_exists(root, CANONICAL_WF["release"]): + return True + lbl = wf_label("release", workflow_map) + return f"⚠️ Canonical ci_cd_release.yml not found — detected: {lbl}" + + +class CICD: + family = "cicd" + + +class CI004(CICD): + "Workflows use concurrency blocks" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | None | str: + roles = [("pr", "ci_cd_pr.yml"), ("main", "ci_cd_main.yml")] + present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + if not present: + return None + missing = [lbl for role, lbl in present if "concurrency:" not in wf_content(root, role, workflow_map)[1]] + if not missing: + return True + return f"⚠️ concurrency: block missing in: {', '.join(missing)}" + + +class CI005(CICD): + "Workflows set root `permissions: {}`" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | None | str: + roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] + present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + if not present: + return None + missing = [lbl for role, lbl in present if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M)] + return True if not missing else f"Missing root permissions: {{}} in: {', '.join(missing)}" + + +class CI006(CICD): + "checkout uses persist-credentials: false" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | None | str: + roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] + present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + if not present: + return None + missing = [lbl for role, lbl in present if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1]] + if not missing: + return True + return f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" + + +class CI007(CICD): + "Labeler job present across workflows" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/[^\s]*label|\blabeler\b", content, re.IGNORECASE)) + + +class CI008(CICD): + "ansys/actions/check-vulnerabilities used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return "ansys/actions/check-vulnerabilities" in content + + +class CI009(CICD): + "ansys/actions/code-style used" + + @staticmethod + def check(root: Traversable) -> bool | None | str: + content = all_workflows_content(root) + if not content: + return None + if "ansys/actions/code-style" in content: + return True + return "⚠️ ansys/actions/code-style not found in any workflow file." + + +class CI010(CICD): + "check-pr-title step present across workflows" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE)) + + +class CI011(CICD): + "changelog-fragment step present across workflows" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE)) + + +class CI012(CICD): + "ansys/actions/check-doc-style used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/check-doc-style|doc-style", content, re.IGNORECASE)) + + +class CI013(CICD): + "ansys/actions/doc-build used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/doc-build|\bdoc-build\b", content, re.IGNORECASE)) + + +class CI014(CICD): + "ansys/actions/build-wheelhouse used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE)) + + +class CI015(CICD): + "ansys/actions/tests-pytest (or pytest) used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", content, re.IGNORECASE)) + + +class CI016(CICD): + "update-changelog step present across workflows" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE)) + + +class Dependabot: + family = "dependabot" + + +_PATH_DEPENDABOT = ".github/dependabot.yml" + + +class DB001(Dependabot): + ".github/dependabot.yml exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, _PATH_DEPENDABOT) + + +class DB002(Dependabot): + "dependabot.yml sets version: 2" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, _PATH_DEPENDABOT): + return None + return file_contains(root, _PATH_DEPENDABOT, re.compile(r"^version:\s*2\s*$", re.M)) + + +class DB003(Dependabot): + "pip or uv ecosystem configured" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) + has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) + if has_pip: + return True + if has_uv: + return "⚠️ uv ecosystem configured (pip preferred for PyAnsys standard)." + return False + + +class DB004(Dependabot): + "github-actions ecosystem configured" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, _PATH_DEPENDABOT): + return None + return file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?")) + + +class DB005(Dependabot): + "Weekly update interval set" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + content = file_content(root, _PATH_DEPENDABOT) + count = len(re.findall(r"interval:\s*[\"']?weekly[\"']?", content)) + if count >= 2: + return True + return f"⚠️ Only {count} ecosystem(s) use weekly interval (expected ≥2)." + + +class DB006(Dependabot): + "Cooldown default-days: 7 configured" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + if file_contains(root, _PATH_DEPENDABOT, re.compile(r"default-days:\s*7")): + return True + return "⚠️ Cooldown default-days: 7 not found in dependabot.yml." + + +class DB007(Dependabot): + "pip uses versioning-strategy: lockfile-only" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) + has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) + if has_uv and not has_pip: + return None + if file_contains(root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?")): + return True + return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." + + +class DB008(Dependabot): + "pip groups all dependencies together" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): + return True + return '⚠️ pip groups wildcard pattern "- \"*\"" not found in dependabot.yml.' + + +class Documentation: + family = "documentation" + + +class DOC001(Documentation): + "doc/source/ structure exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "doc/source/index.rst") + + +class DOC002(Documentation): + "conf.py exists" + + requires = {"DOC001"} + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "doc/source/conf.py") + + +class DOC003(Documentation): + "conf.py includes numpydoc" + + requires = {"DOC002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/conf.py"): + return None + return file_contains(root, "doc/source/conf.py", "numpydoc") + + +class DOC004(Documentation): + "conf.py includes sphinx_design" + + requires = {"DOC002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/conf.py"): + return None + return file_contains(root, "doc/source/conf.py", "sphinx_design") + + +class DOC005(Documentation): + "conf.py includes intersphinx" + + requires = {"DOC002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/conf.py"): + return None + return file_contains(root, "doc/source/conf.py", "intersphinx") + + +class DOC006(Documentation): + "index.rst has Getting started section" + + requires = {"DOC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/index.rst"): + return None + return file_contains(root, "doc/source/index.rst", re.compile(r"getting.started", re.I)) + + +class DOC007(Documentation): + "index.rst has API reference section" + + requires = {"DOC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/index.rst"): + return None + return file_contains(root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I)) + + +class README: + family = "readme" + + +class RM000(README): + "README file exists" + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | str: + if readme_path == "README.rst": + return True + if readme_path == "README.md": + return "⚠️ README.md found — PyAnsys preferred format is README.rst." + return False + + +class RM001(README): + "README has PyAnsys badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", re.I)): + return True + return f"⚠️ PyAnsys badge image not found in {readme_path}." + + +class RM002(README): + "README has PyPI badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", re.I)): + return True + return f"⚠️ PyPI badge image not found in {readme_path}." + + +class RM003(README): + "README has Codecov badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I)): + return True + return f"⚠️ Codecov badge image not found in {readme_path}." + + +class RM004(README): + "README has MIT license badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I)): + return True + return f"⚠️ MIT license badge image not found in {readme_path}." + + +class RM005(README): + "README has GH-CI badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I)): + return True + return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." + + +class RM006(README): + "README has Installation section" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None: + if not readme_path: + return None + return file_contains(root, readme_path, re.compile(r"install", re.I)) + + +class RM007(README): + "README has Documentation section" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None: + if not readme_path: + return None + return file_contains(root, readme_path, re.compile(r"documentation", re.I)) + + +class RM008(README): + "README has License section" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None: + if not readme_path: + return None + return file_contains(root, readme_path, re.compile(r"license", re.I)) + + +class BuildSystem: + family = "build_system" + + +_BACKENDS = { + "flit_core": "Flit", + "poetry.core": "Poetry", + "hatchling": "Hatch", + "pdm": "PDM", + "maturin": "Maturin", + "setuptools": "Setuptools", +} + + +def _detect_backend(content: str) -> tuple[str, str]: + m = re.search(r'build-backend\s*=\s*["\']([^"\']+)["\']', content) + backend = m.group(1) if m else "" + for pattern, name in _BACKENDS.items(): + if pattern in backend: + return name, pattern.split(".")[0].replace("_core", "") + if "[build-system]" in content: + return "Other", "other" + return "Unknown", "unknown" + + +class BS001(BuildSystem): + "[build-system] table declared" + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "pyproject.toml"): + return None + return file_contains(root, "pyproject.toml", "[build-system]") + + +class BS002(BuildSystem): + "Uses a supported modern build backend" + + requires = {"BS001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, "pyproject.toml"): + return None + name, key = _detect_backend(file_content(root, "pyproject.toml")) + if key == "unknown": + return False + if key == "setuptools": + return f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + return True + + +class BS003(BuildSystem): + "No legacy setup.py or setup.cfg" + + @staticmethod + def check(root: Traversable) -> bool | str: + has_py = file_exists(root, "setup.py") + has_cfg = file_exists(root, "setup.cfg") + if not has_py and not has_cfg: + return True + found = [f for f, present in [("setup.py", has_py), ("setup.cfg", has_cfg)] if present] + return f"⚠️ Legacy file(s) found: {', '.join(found)}. Remove in favour of pyproject.toml." + + +class BS004(BuildSystem): + "Build backend version pinned in requires" + + requires = {"BS001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, "pyproject.toml"): + return None + content = file_content(root, "pyproject.toml") + m = re.search(r"requires\s*=\s*\[([^\]]+)\]", content) + if not m: + return False + if re.search(r"[><=!~]", m.group(1)): + return True + return "⚠️ Build backend in requires has no version pin (e.g. >=x.y)." + + +class Security: + family = "security" + + +class SEC001(Security): + ".github/zizmor.yml exists" + + @staticmethod + def check(root: Traversable) -> bool | str: + if file_exists(root, ".github/zizmor.yml"): + return True + return "⚠️ .github/zizmor.yml not found — optional but recommended." + + +class SEC002(Security): + "zizmor.yml has secrets-outside-env rule" + + requires = {"SEC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".github/zizmor.yml"): + return None + return file_contains(root, ".github/zizmor.yml", "secrets-outside-env") + + +class SEC003(Security): + "gitleaks hook configured" + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "gitleaks") + + +class SEC004(Security): + "Workflows pin action SHAs" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | None | str: + _, content = wf_content(root, "pr", workflow_map) + if not content: + return None + if re.search(r"uses:\s*\S+@[0-9a-f]{40}", content, re.I): + return True + return "⚠️ No SHA-pinned actions detected in PR workflow. Use full commit SHAs." + + +class SEC005(Security): + "SECURITY.md discourages public issue reporting" + + requires = {"PM008"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, "SECURITY.md"): + return None + if file_contains(root, "SECURITY.md", re.compile(r"do not|don't|please don", re.I)): + return True + return "⚠️ SECURITY.md may not clearly discourage public issue reporting." + + +class Labeler: + family = "labeler" + + +class LB001(Labeler): + ".github/labeler.yml exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, ".github/labeler.yml") + + +class LB002(Labeler): + ".github/labels.yml exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, ".github/labels.yml") + + +class LB003(Labeler): + "labels.yml has 'bug' label" + + requires = {"LB002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".github/labels.yml"): + return None + return file_contains(root, ".github/labels.yml", "bug") + + +class LB004(Labeler): + "labels.yml has 'enhancement' label" + + requires = {"LB002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".github/labels.yml"): + return None + return file_contains(root, ".github/labels.yml", "enhancement") + + +class LB005(Labeler): + "labels.yml has 'documentation' label" + + requires = {"LB002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".github/labels.yml"): + return None + return file_contains(root, ".github/labels.yml", "documentation") + + +class Vale: + family = "vale" + + +_INI = "doc/.vale.ini" + + +class VL001(Vale): + "doc/.vale.ini exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, _INI) + + +class VL002(Vale): + "Vale uses Google style package" + + requires = {"VL001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, _INI): + return None + return file_contains(root, _INI, "Google") + + +class VL003(Vale): + "Vale uses ANSYS vocabulary" + + requires = {"VL001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, _INI): + return None + return file_contains(root, _INI, "ANSYS") + + +class VL004(Vale): + "ANSYS accept.txt vocabulary exists" + + requires = {"VL001"} + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "doc/styles/config/vocabularies/ANSYS/accept.txt") + + +class VL005(Vale): + "ANSYS reject.txt vocabulary exists" + + requires = {"VL001"} + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "doc/styles/config/vocabularies/ANSYS/reject.txt") + + +class MCP: + family = "mcp" + + +class MCP001(MCP): + "Core governance files all present" + + @staticmethod + def check(root: Traversable, is_mcp: bool) -> bool | None: + if not is_mcp: + return None + required = ["LICENSE", "SECURITY.md", "CONTRIBUTING.md", "CHANGELOG.md", ".github/CODEOWNERS"] + missing = [p for p in required if not file_exists(root, p)] + return True if not missing else f"Missing: {', '.join(missing)}" + + +class MCP002(MCP): + "CI/CD workflows all present" + + @staticmethod + def check(root: Traversable, is_mcp: bool) -> bool | None: + if not is_mcp: + return None + workflows = [ + ".github/workflows/ci_cd_main.yml", + ".github/workflows/ci_cd_pr.yml", + ".github/workflows/ci_cd_release.yml", + ] + missing = [p for p in workflows if not file_exists(root, p)] + return True if not missing else f"Missing: {', '.join(missing)}" + + +class MCP003(MCP): + "tests job wired in PR workflow" + + @staticmethod + def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: + if not is_mcp: + return None + _, content = wf_content(root, "pr", workflow_map) + if not content: + return None + return bool(re.search(r"tests|pytest", content, re.I)) + + +class MCP004(MCP): + "doc-build job present in PR workflow" + + @staticmethod + def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: + if not is_mcp: + return None + _, content = wf_content(root, "pr", workflow_map) + if not content: + return None + return "doc-build" in content + + +class MCP005(MCP): + "README and docs metadata aligned" + + @staticmethod + def check(root: Traversable, is_mcp: bool, readme_path: str | None) -> bool | None | str: + if not is_mcp: + return None + if not file_exists(root, "pyproject.toml"): + return None + if not readme_path: + return False + filename = readme_path.split("/")[-1] + if not file_contains(root, "pyproject.toml", filename): + return f"pyproject.toml does not reference {filename} as readme." + if readme_path == "README.md": + return "⚠️ README.md found — PyAnsys preferred format is README.rst." + return True + + +class MCP006(MCP): + "No TODO/FIXME in doc/source/index.rst" + + @staticmethod + def check(root: Traversable, is_mcp: bool) -> bool | None: + if not is_mcp: + return None + if not file_exists(root, "doc/source/index.rst"): + return None + return not file_contains(root, "doc/source/index.rst", re.compile(r"TODO|FIXME")) + + +class MCP007(MCP): + "Security checks not bypassed" + + @staticmethod + def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: + if not is_mcp: + return None + _, pr_content = wf_content(root, "pr", workflow_map) + _, rel_content = wf_content(root, "release", workflow_map) + combined = pr_content + rel_content + if not combined.strip(): + return None + return not bool(re.search(r"--no-verify|skip.*security|disable.*scan", combined, re.I)) + + +class PreCommit: + family = "pre_commit" + + +class PC001(PreCommit): + ".pre-commit-config.yaml exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, ".pre-commit-config.yaml") + + +class PC002(PreCommit): + "ruff-pre-commit configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "ruff-pre-commit") + + +class PC003(PreCommit): + "zizmor configured with --pedantic" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + has_zizmor = file_contains(root, ".pre-commit-config.yaml", "zizmor") + has_pedantic = file_contains(root, ".pre-commit-config.yaml", "--pedantic") + if not has_zizmor: + return False + if not has_pedantic: + return "⚠️ zizmor found but --pedantic flag not set." + return True + + +class PC004(PreCommit): + "blacken-docs configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "blacken-docs") + + +class PC005(PreCommit): + "codespell configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "codespell") + + +class PC006(PreCommit): + "ansys/pre-commit-hooks configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "ansys/pre-commit-hooks") + + +class PC007(PreCommit): + "google/yamlfmt configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "yamlfmt") + + +class PC008(PreCommit): + "pyright configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "pyright") + + +class PC009(PreCommit): + "autofix_prs: true enabled" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + if file_contains(root, ".pre-commit-config.yaml", "autofix_prs: true"): + return True + return "⚠️ autofix_prs: true not set in ci: block." + + +class PC010(PreCommit): + "autoupdate_schedule: weekly" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + if file_contains(root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly")): + return True + return "⚠️ autoupdate_schedule: weekly not found." + + +def repo_review_families() -> dict[str, dict]: + return { + "project_metadata": {"name": "Project Metadata", "order": 10}, + "cicd_files": {"name": "CI/CD — Workflow File Names", "order": 20}, + "cicd": {"name": "CI/CD — Content Checks", "order": 25}, + "dependabot": {"name": "Dependabot", "order": 30}, + "pre_commit": {"name": "Pre-commit", "order": 40}, + "documentation": {"name": "Documentation", "order": 50}, + "readme": {"name": "README", "order": 60}, + "build_system": {"name": "Build System", "order": 70}, + "security": {"name": "Security", "order": 80}, + "labeler": {"name": "Labeler", "order": 90}, + "vale": {"name": "Vale", "order": 100}, + "mcp": {"name": "MCP Release Readiness", "order": 110}, + } + + +def repo_review_checks() -> dict: + families = [ + ProjectMetadata, CICDFiles, CICD, Dependabot, PreCommit, Documentation, README, BuildSystem, + Security, Labeler, Vale, MCP, + ] + result = {} + for family in families: + for cls in family.__subclasses__(): + result[cls.__name__] = cls() + return result + + +def _first_doc_line(obj: Any) -> str: + doc = (obj.check.__doc__ or "").strip() + lines = [line.strip() for line in doc.splitlines() if line.strip()] + return lines[0] if lines else "" + + +def _interpret(raw: bool | str | None, check_obj: Any) -> tuple[str, str]: + if raw is True: + return "pass", "" + if raw is None: + return "na", "" + if isinstance(raw, str) and raw.startswith("⚠️ "): + return "warn", raw.removeprefix("⚠️ ") + if raw is False: + doc = (check_obj.check.__doc__ or "").strip() + lines = [line.strip() for line in doc.splitlines() if line.strip()] + detail = lines[-1] if len(lines) > 1 else (lines[0] if lines else "") + return "fail", detail + return "fail", str(raw) + + +def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, Any]: + root = MemoryTraversable(files) + fixture_values = { + "root": root, + "package": root, + "workflow_map": workflow_map(root), + "readme_path": readme_path(root), + "is_mcp": is_mcp_flag or is_mcp(root), + } + + checks = repo_review_checks() + families = repo_review_families() + results = [] + + for code, check_obj in checks.items(): + try: + import inspect + + signature = inspect.signature(check_obj.check) + kwargs = {key: fixture_values[key] for key in signature.parameters if key in fixture_values} + raw = check_obj.check(**kwargs) + except Exception as exc: # pragma: no cover + raw = f"⚠️ Check error: {exc}" + + status, detail = _interpret(raw, check_obj) + results.append( + { + "id": code, + "family": check_obj.family, + "family_name": families.get(check_obj.family, {}).get("name", check_obj.family), + "label": type(check_obj).__doc__ or code, + "description": _first_doc_line(check_obj), + "status": status, + "detail": detail, + } + ) + + tally = {"pass": 0, "fail": 0, "warn": 0, "na": 0} + for result in results: + tally[result["status"]] += 1 + + scored = tally["pass"] + tally["fail"] + score = round(tally["pass"] / scored * 100) if scored else 0 + return { + "results": results, + "tally": tally, + "score": score, + "workflow_map": fixture_values["workflow_map"], + "project_metadata": {"build_system": {"name": "Unknown", "key": "unknown"}, "license": None, "python_requires": None}, + } + + def _load_files(repo_root: Path) -> dict[str, str | None]: - """Collect the repository files most relevant to the quality review.""" files: dict[str, str | None] = {} for relative_path in _PATHS_TO_FETCH: candidate = repo_root / relative_path @@ -58,15 +1504,12 @@ def _load_files(repo_root: Path) -> dict[str, str | None]: workflows = repo_root / ".github" / "workflows" if workflows.is_dir(): for workflow in workflows.glob("*.y*ml"): - files[workflow.relative_to(repo_root).as_posix()] = workflow.read_text( - encoding="utf-8", errors="replace" - ) + files[workflow.relative_to(repo_root).as_posix()] = workflow.read_text(encoding="utf-8", errors="replace") return files def _print_report(review: dict[str, Any]) -> None: - """Print a short repo-quality summary to stdout.""" results = review["results"] tally = review["tally"] score = review["score"] @@ -74,23 +1517,18 @@ def _print_report(review: dict[str, Any]) -> None: print("PyAnsys quality report") print("=" * 24) print(f"Score: {score}%") - print( - "Summary: " - f"pass={tally['pass']} fail={tally['fail']} warn={tally['warn']} na={tally['na']}" - ) + print(f"Summary: pass={tally['pass']} fail={tally['fail']} warn={tally['warn']} na={tally['na']}") for item in results: if item["status"] == "pass": continue - label = item["label"] detail = item["detail"] or "" - print(f"- [{item['status'].upper()}] {item['id']} - {label}") + print(f"- [{item['status'].upper()}] {item['id']} - {item['label']}") if detail: print(f" {detail}") def main(argv: list[str] | None = None) -> int: - """Run all configured PyAnsys quality checks against a repository.""" parser = argparse.ArgumentParser(description="Run the PyAnsys repository quality report.") parser.add_argument("--repo-root", default=".", help="Repository root to review.") parser.add_argument("--json", action="store_true", help="Emit a JSON report instead of a text summary.") @@ -101,8 +1539,7 @@ def main(argv: list[str] | None = None) -> int: raise FileNotFoundError(f"Repo root not found: {repo_root}") files = _load_files(repo_root) - root = MemoryTraversable(files) - review = _run_checks(files, is_mcp_flag=is_mcp(root)) + review = _run_checks(files, is_mcp_flag=is_mcp(MemoryTraversable(files))) if args.json: print(json.dumps(review, indent=2)) From 9edff476e20f78330d5fd85c103c4b4bd5a58b5a Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Tue, 1 Sep 2026 14:46:33 +0530 Subject: [PATCH 03/49] feat: migrate the checks --- .pre-commit-config.yaml | 1 + .../pyansys_quality_report.py | 23 ++++++++- .../bad_chars.py | 22 +++++++++ tests/test_pyansys_quality_report.py | 48 +++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9919310f..07fdd1f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,3 +63,4 @@ repos: args: - --product=pre_commit_hooks - --non_compliant_name + - id: pyansys-quality-report diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 5560615e..deabe38a 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -1509,6 +1509,18 @@ def _load_files(repo_root: Path) -> dict[str, str | None]: return files +def _style_status(status: str, text: str) -> str: + colors = { + "pass": "\033[32m", + "warn": "\033[33m", + "fail": "\033[31m", + "na": "\033[36m", + } + reset = "\033[0m" + color = colors.get(status, "") + return f"{color}{text}{reset}" if color else text + + def _print_report(review: dict[str, Any]) -> None: results = review["results"] tally = review["tally"] @@ -1517,13 +1529,20 @@ def _print_report(review: dict[str, Any]) -> None: print("PyAnsys quality report") print("=" * 24) print(f"Score: {score}%") - print(f"Summary: pass={tally['pass']} fail={tally['fail']} warn={tally['warn']} na={tally['na']}") + summary = ( + f"Summary: pass={_style_status('pass', str(tally['pass']))} " + f"fail={_style_status('fail', str(tally['fail']))} " + f"warn={_style_status('warn', str(tally['warn']))} " + f"na={_style_status('na', str(tally['na']))}" + ) + print(summary) for item in results: if item["status"] == "pass": continue detail = item["detail"] or "" - print(f"- [{item['status'].upper()}] {item['id']} - {item['label']}") + label = _style_status(item["status"], item["status"].upper()) + print(f"- [{label}] {item['id']} - {item['label']}") if detail: print(f" {detail}") diff --git a/tests/test_add_license_headers_files/bad_chars.py b/tests/test_add_license_headers_files/bad_chars.py index 648886d7..0cc9f637 100644 --- a/tests/test_add_license_headers_files/bad_chars.py +++ b/tests/test_add_license_headers_files/bad_chars.py @@ -1,3 +1,25 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + """Test if fileinput.input() in add-license-headers decodes bad characters.""" # This intentionally does not have a header, so we can test the diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 64eecd2b..647f0d23 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -3,6 +3,7 @@ from __future__ import annotations +import ast import os from pathlib import Path @@ -37,3 +38,50 @@ def test_main_reports_quality_summary(tmp_path, capsys): assert exit_code in (0, 1) assert "PyAnsys quality report" in output assert "Score" in output or "Summary" in output + + +def test_main_colors_status_labels(tmp_path, capsys): + """The console report should colorize pass, warn, and fail states.""" + repo_path = tmp_path / "quality-demo" + repo_path.mkdir() + os.chdir(repo_path) + git.Repo.init(repo_path) + + (repo_path / ".pre-commit-config.yaml").write_text("repos: []\n", encoding="utf-8") + (repo_path / "README.rst").write_text("Demo\n=====\n", encoding="utf-8") + (repo_path / "pyproject.toml").write_text( + """ +[project] +name = "ansys-demo-library" +version = "0.1.0" +""".strip(), + encoding="utf-8", + ) + + exit_code = hook.main(["--repo-root", str(repo_path)]) + output = capsys.readouterr().out + + assert exit_code == 1 + assert "\033[32m" in output + assert "\033[33m" in output + assert "\033[31m" in output + + +def test_hook_covers_all_repo_review_checks(): + """The standalone hook must include the full repo-review rule set.""" + + repo_root = Path(__file__).resolve().parents[3] + checks_dir = repo_root / "src" / "pyansys_review" / "checks" + + def class_names(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + return {node.name for node in tree.body if isinstance(node, ast.ClassDef)} + + expected = set() + for path in checks_dir.glob("*.py"): + if path.name == "__init__.py": + continue + expected |= class_names(path) + + actual = class_names(Path(hook.__file__)) + assert expected == actual - {"MemoryTraversable"} From 118166b22b17956544709256635c6ae6b57cd55c Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Tue, 1 Sep 2026 15:04:14 +0530 Subject: [PATCH 04/49] feat: show all checks args --- src/ansys/pre_commit_hooks/pyansys_quality_report.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index deabe38a..7250db5e 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -1521,7 +1521,7 @@ def _style_status(status: str, text: str) -> str: return f"{color}{text}{reset}" if color else text -def _print_report(review: dict[str, Any]) -> None: +def _print_report(review: dict[str, Any], *, show_passes: bool = False) -> None: results = review["results"] tally = review["tally"] score = review["score"] @@ -1538,7 +1538,7 @@ def _print_report(review: dict[str, Any]) -> None: print(summary) for item in results: - if item["status"] == "pass": + if item["status"] == "pass" and not show_passes: continue detail = item["detail"] or "" label = _style_status(item["status"], item["status"].upper()) @@ -1551,6 +1551,7 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Run the PyAnsys repository quality report.") parser.add_argument("--repo-root", default=".", help="Repository root to review.") parser.add_argument("--json", action="store_true", help="Emit a JSON report instead of a text summary.") + parser.add_argument("--all", action="store_true", help="Show all checks, including passing ones.") args = parser.parse_args(argv) repo_root = Path(args.repo_root).resolve() @@ -1564,7 +1565,7 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(review, indent=2)) return 1 if review["tally"]["fail"] else 0 - _print_report(review) + _print_report(review, show_passes=args.all) return 1 if review["tally"]["fail"] else 0 From f16d9a5275814f2d2763583c7038ed5d5cf0bef8 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:32:33 +0000 Subject: [PATCH 05/49] chore: adding changelog file 486.added.md [dependabot-skip] --- doc/changelog.d/486.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/486.added.md diff --git a/doc/changelog.d/486.added.md b/doc/changelog.d/486.added.md new file mode 100644 index 00000000..41a15b59 --- /dev/null +++ b/doc/changelog.d/486.added.md @@ -0,0 +1 @@ +Add \`pyansys-quality-check\` hook From 48e39dcadd1f54d15d6e9523c0dfefb667a70bf4 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 12:37:13 +0530 Subject: [PATCH 06/49] feat: keep checks in seperate modules --- .pre-commit-config.yaml | 1 - .../pyansys_quality_report.py | 11 +- src/ansys/pre_commit_hooks/quality_rules.py | 1380 +++++++++++++++++ 3 files changed, 1390 insertions(+), 2 deletions(-) create mode 100644 src/ansys/pre_commit_hooks/quality_rules.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07fdd1f7..9919310f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,4 +63,3 @@ repos: args: - --product=pre_commit_hooks - --non_compliant_name - - id: pyansys-quality-report diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 7250db5e..9eeb1c4d 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -12,7 +12,6 @@ import argparse import json -import re from io import BytesIO, StringIO from pathlib import Path from typing import Any, Iterator @@ -22,6 +21,16 @@ except ImportError: # pragma: no cover from importlib.abc import Traversable +from ansys.pre_commit_hooks.quality_rules import ( + _first_doc_line, + _interpret, + readme_path, + repo_review_checks, + repo_review_families, + workflow_map, + is_mcp, +) + _PATHS_TO_FETCH = [ "AUTHORS", "CHANGELOG.md", diff --git a/src/ansys/pre_commit_hooks/quality_rules.py b/src/ansys/pre_commit_hooks/quality_rules.py new file mode 100644 index 00000000..1bf9fe46 --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules.py @@ -0,0 +1,1380 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Rule definitions for the PyAnsys repository quality report.""" + +from __future__ import annotations + +import re +from typing import Any, Iterator + +try: + from importlib.resources.abc import Traversable +except ImportError: # pragma: no cover + from importlib.abc import Traversable + + +__all__ = [ + "file_exists", + "file_content", + "file_contains", + "CANONICAL_WF", + "all_workflows_content", + "wf_content", + "wf_label", + "workflow_map", + "readme_path", + "is_mcp", + "repo_review_families", + "repo_review_checks", + "_first_doc_line", + "_interpret", + "ProjectMetadata", + "CICDFiles", + "CICD", + "Dependabot", + "Documentation", + "README", + "BuildSystem", + "Security", + "Labeler", + "Vale", + "MCP", + "PreCommit", +] + + +def file_exists(root: Traversable, path: str) -> bool: + try: + return root.joinpath(path).is_file() + except Exception: + return False + + +def file_content(root: Traversable, path: str) -> str: + try: + f = root.joinpath(path) + if f.is_file(): + return f.read_text(encoding="utf-8") + except Exception: + pass + return "" + + +def file_contains(root: Traversable, path: str, pattern: str | re.Pattern) -> bool: + content = file_content(root, path) + if not content: + return False + if isinstance(pattern, str): + return pattern in content + return bool(pattern.search(content)) + + +CANONICAL_WF = { + "main": ".github/workflows/ci_cd_main.yml", + "pr": ".github/workflows/ci_cd_pr.yml", + "release": ".github/workflows/ci_cd_release.yml", +} + + +def all_workflows_content(root: Traversable) -> str: + return _merge_all_workflows(root) + + +def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, str]: + canonical = CANONICAL_WF[role] + if file_exists(root, canonical): + return True, file_content(root, canonical) + + entry = workflow_map.get(role) + if entry and not entry.get("is_fallback"): + path = entry.get("path", "") + return False, file_content(root, path) if path else "" + return False, _merge_all_workflows(root) + + +def _merge_all_workflows(root: Traversable) -> str: + try: + entries = [ + e for e in root.joinpath(".github/workflows").iterdir() if e.name.endswith((".yml", ".yaml")) + ] + except Exception: + return "" + parts = [] + for entry in entries: + try: + c = entry.read_text(encoding="utf-8") + if c: + parts.append(c) + except Exception: + pass + return "\n\n".join(parts) + + +def wf_label(role: str, workflow_map: dict) -> str: + entry = workflow_map.get(role) + if not entry: + return CANONICAL_WF.get(role, role) + if entry.get("is_fallback"): + sources = entry.get("sources", []) + return f"{len(sources)} workflow file(s) ({', '.join(sources)})" + return entry.get("name", role) + + +def workflow_map(root: Traversable) -> dict[str, dict]: + wf_dir = root.joinpath(".github/workflows") + try: + entries = [e for e in wf_dir.iterdir() if e.name.endswith((".yml", ".yaml"))] + except Exception: + entries = [] + + result: dict[str, dict] = {} + for entry in entries: + role = _classify_workflow(entry.name) + if role != "unknown" and role not in result: + result[role] = { + "name": entry.name, + "path": f".github/workflows/{entry.name}", + "is_fallback": False, + "sources": [entry.name], + } + + for role in ("main", "pr", "release"): + if role not in result and entries: + result[role] = { + "name": f"{len(entries)} workflow(s)", + "path": None, + "is_fallback": True, + "sources": [e.name for e in entries], + } + + return result + + +def _classify_workflow(name: str) -> str: + n = name.lower() + if re.search(r"release|publish|deploy", n): + return "release" + if re.search(r"\bpr\b|pull.?request|pull_request", n): + return "pr" + if re.search(r"main|push|branch|nightly|schedule|ci_cd_main|ci.main", n): + return "main" + if re.search(r"\bci\b|build|test", n): + return "pr" + return "unknown" + + +def readme_path(root: Traversable) -> str | None: + if file_exists(root, "README.rst"): + return "README.rst" + if file_exists(root, "README.md"): + return "README.md" + return None + + +def is_mcp(root: Traversable) -> bool: + try: + pyproject_text = root.joinpath("pyproject.toml").read_text() + return bool(re.search(r"\b(fastmcp|mcp)\b", pyproject_text, re.IGNORECASE)) + except Exception: + pass + try: + return file_exists(root, "src/server.py") or file_exists(root, "server.py") + except Exception: + return False + + +class ProjectMetadata: + family = "project_metadata" + + +class PM001(ProjectMetadata): + "AUTHORS exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "AUTHORS") + + +class PM002(ProjectMetadata): + "CHANGELOG.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "CHANGELOG.md") + + +class PM003(ProjectMetadata): + "CODE_OF_CONDUCT.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "CODE_OF_CONDUCT.md") + + +class PM004(ProjectMetadata): + "CONTRIBUTING.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "CONTRIBUTING.md") + + +class PM005(ProjectMetadata): + "CONTRIBUTORS.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "CONTRIBUTORS.md") + + +class PM006(ProjectMetadata): + "LICENSE exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "LICENSE") + + +class PM007(ProjectMetadata): + "README exists (.rst preferred)" + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | str: + if readme_path is None: + return False + if readme_path == "README.md": + return "⚠️ README.md found — README.rst is the preferred format." + return True + + +class PM008(ProjectMetadata): + "SECURITY.md exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "SECURITY.md") + + +class PM009(ProjectMetadata): + ".github/CODEOWNERS exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, ".github/CODEOWNERS") + + +class PM010(ProjectMetadata): + "pyproject.toml references README file" + + requires = {"PM007"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not file_exists(root, "pyproject.toml"): + return None + content = file_content(root, "pyproject.toml") + if re.search(r"poetry\.core|poetry-core", content): + m = re.search(r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M) + if m: + return True + return False + rm = readme_path or "README.rst" + if rm in content: + return True + if "README" in content: + return "⚠️ readme key found but exact README filename not confirmed." + return False + + +class PM011(ProjectMetadata): + "pyproject.toml references LICENSE file" + + requires = {"PM006"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "pyproject.toml"): + return None + c = file_content(root, "pyproject.toml") + if re.search(r"poetry\.core|poetry-core", c): + return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", c, re.M)) + return bool( + re.search(r"license-files\s*=", c) + or re.search(r'license\s*=\s*\{[^}]*file', c) + or re.search(r'license\s*=\s*["\']LICENSE["\']', c) + ) + + +class CICDFiles: + family = "cicd_files" + + +class CI001(CICDFiles): + "ci_cd_main.yml exists" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | str: + if file_exists(root, CANONICAL_WF["main"]): + return True + lbl = wf_label("main", workflow_map) + return f"⚠️ Canonical ci_cd_main.yml not found — detected: {lbl}" + + +class CI002(CICDFiles): + "ci_cd_pr.yml exists" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | str: + if file_exists(root, CANONICAL_WF["pr"]): + return True + lbl = wf_label("pr", workflow_map) + return f"⚠️ Canonical ci_cd_pr.yml not found — detected: {lbl}" + + +class CI003(CICDFiles): + "ci_cd_release.yml exists" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | str: + if file_exists(root, CANONICAL_WF["release"]): + return True + lbl = wf_label("release", workflow_map) + return f"⚠️ Canonical ci_cd_release.yml not found — detected: {lbl}" + + +class CICD: + family = "cicd" + + +class CI004(CICD): + "Workflows use concurrency blocks" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | None | str: + roles = [("pr", "ci_cd_pr.yml"), ("main", "ci_cd_main.yml")] + present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + if not present: + return None + missing = [lbl for role, lbl in present if "concurrency:" not in wf_content(root, role, workflow_map)[1]] + if not missing: + return True + return f"⚠️ concurrency: block missing in: {', '.join(missing)}" + + +class CI005(CICD): + "Workflows set root `permissions: {}`" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | None | str: + roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] + present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + if not present: + return None + missing = [lbl for role, lbl in present if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M)] + return True if not missing else f"Missing root permissions: {{}} in: {', '.join(missing)}" + + +class CI006(CICD): + "checkout uses persist-credentials: false" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | None | str: + roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] + present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + if not present: + return None + missing = [lbl for role, lbl in present if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1]] + if not missing: + return True + return f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" + + +class CI007(CICD): + "Labeler job present across workflows" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/[^\s]*label|\blabeler\b", content, re.IGNORECASE)) + + +class CI008(CICD): + "ansys/actions/check-vulnerabilities used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return "ansys/actions/check-vulnerabilities" in content + + +class CI009(CICD): + "ansys/actions/code-style used" + + @staticmethod + def check(root: Traversable) -> bool | None | str: + content = all_workflows_content(root) + if not content: + return None + if "ansys/actions/code-style" in content: + return True + return "⚠️ ansys/actions/code-style not found in any workflow file." + + +class CI010(CICD): + "check-pr-title step present across workflows" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE)) + + +class CI011(CICD): + "changelog-fragment step present across workflows" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE)) + + +class CI012(CICD): + "ansys/actions/check-doc-style used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/check-doc-style|doc-style", content, re.IGNORECASE)) + + +class CI013(CICD): + "ansys/actions/doc-build used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/doc-build|\bdoc-build\b", content, re.IGNORECASE)) + + +class CI014(CICD): + "ansys/actions/build-wheelhouse used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE)) + + +class CI015(CICD): + "ansys/actions/tests-pytest (or pytest) used" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", content, re.IGNORECASE)) + + +class CI016(CICD): + "update-changelog step present across workflows" + + @staticmethod + def check(root: Traversable) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE)) + + +class Dependabot: + family = "dependabot" + + +_PATH_DEPENDABOT = ".github/dependabot.yml" + + +class DB001(Dependabot): + ".github/dependabot.yml exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, _PATH_DEPENDABOT) + + +class DB002(Dependabot): + "dependabot.yml sets version: 2" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, _PATH_DEPENDABOT): + return None + return file_contains(root, _PATH_DEPENDABOT, re.compile(r"^version:\s*2\s*$", re.M)) + + +class DB003(Dependabot): + "pip or uv ecosystem configured" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) + has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) + if has_pip: + return True + if has_uv: + return "⚠️ uv ecosystem configured (pip preferred for PyAnsys standard)." + return False + + +class DB004(Dependabot): + "github-actions ecosystem configured" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, _PATH_DEPENDABOT): + return None + return file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?")) + + +class DB005(Dependabot): + "Weekly update interval set" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + content = file_content(root, _PATH_DEPENDABOT) + count = len(re.findall(r"interval:\s*[\"']?weekly[\"']?", content)) + if count >= 2: + return True + return f"⚠️ Only {count} ecosystem(s) use weekly interval (expected ≥2)." + + +class DB006(Dependabot): + "Cooldown default-days: 7 configured" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + if file_contains(root, _PATH_DEPENDABOT, re.compile(r"default-days:\s*7")): + return True + return "⚠️ Cooldown default-days: 7 not found in dependabot.yml." + + +class DB007(Dependabot): + "pip uses versioning-strategy: lockfile-only" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) + has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) + if has_uv and not has_pip: + return None + if file_contains(root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?")): + return True + return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." + + +class DB008(Dependabot): + "pip groups all dependencies together" + + requires = {"DB001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): + return True + return '⚠️ pip groups wildcard pattern "- \"*\"" not found in dependabot.yml.' + + +class Documentation: + family = "documentation" + + +class DOC001(Documentation): + "doc/source/ structure exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "doc/source/index.rst") + + +class DOC002(Documentation): + "conf.py exists" + + requires = {"DOC001"} + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "doc/source/conf.py") + + +class DOC003(Documentation): + "conf.py includes numpydoc" + + requires = {"DOC002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/conf.py"): + return None + return file_contains(root, "doc/source/conf.py", "numpydoc") + + +class DOC004(Documentation): + "conf.py includes sphinx_design" + + requires = {"DOC002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/conf.py"): + return None + return file_contains(root, "doc/source/conf.py", "sphinx_design") + + +class DOC005(Documentation): + "conf.py includes intersphinx" + + requires = {"DOC002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/conf.py"): + return None + return file_contains(root, "doc/source/conf.py", "intersphinx") + + +class DOC006(Documentation): + "index.rst has Getting started section" + + requires = {"DOC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/index.rst"): + return None + return file_contains(root, "doc/source/index.rst", re.compile(r"getting.started", re.I)) + + +class DOC007(Documentation): + "index.rst has API reference section" + + requires = {"DOC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "doc/source/index.rst"): + return None + return file_contains(root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I)) + + +class README: + family = "readme" + + +class RM000(README): + "README file exists" + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | str: + if readme_path == "README.rst": + return True + if readme_path == "README.md": + return "⚠️ README.md found — PyAnsys preferred format is README.rst." + return False + + +class RM001(README): + "README has PyAnsys badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", re.I)): + return True + return f"⚠️ PyAnsys badge image not found in {readme_path}." + + +class RM002(README): + "README has PyPI badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", re.I)): + return True + return f"⚠️ PyPI badge image not found in {readme_path}." + + +class RM003(README): + "README has Codecov badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I)): + return True + return f"⚠️ Codecov badge image not found in {readme_path}." + + +class RM004(README): + "README has MIT license badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I)): + return True + return f"⚠️ MIT license badge image not found in {readme_path}." + + +class RM005(README): + "README has GH-CI badge" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains(root, readme_path, re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I)): + return True + return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." + + +class RM006(README): + "README has Installation section" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None: + if not readme_path: + return None + return file_contains(root, readme_path, re.compile(r"install", re.I)) + + +class RM007(README): + "README has Documentation section" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None: + if not readme_path: + return None + return file_contains(root, readme_path, re.compile(r"documentation", re.I)) + + +class RM008(README): + "README has License section" + + requires = {"RM000"} + + @staticmethod + def check(root: Traversable, readme_path: str | None) -> bool | None: + if not readme_path: + return None + return file_contains(root, readme_path, re.compile(r"license", re.I)) + + +class BuildSystem: + family = "build_system" + + +_BACKENDS = { + "flit_core": "Flit", + "poetry.core": "Poetry", + "hatchling": "Hatch", + "pdm": "PDM", + "maturin": "Maturin", + "setuptools": "Setuptools", +} + + +def _detect_backend(content: str) -> tuple[str, str]: + m = re.search(r'build-backend\s*=\s*["\']([^"\']+)["\']', content) + backend = m.group(1) if m else "" + for pattern, name in _BACKENDS.items(): + if pattern in backend: + return name, pattern.split(".")[0].replace("_core", "") + if "[build-system]" in content: + return "Other", "other" + return "Unknown", "unknown" + + +class BS001(BuildSystem): + "[build-system] table declared" + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, "pyproject.toml"): + return None + return file_contains(root, "pyproject.toml", "[build-system]") + + +class BS002(BuildSystem): + "Uses a supported modern build backend" + + requires = {"BS001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, "pyproject.toml"): + return None + name, key = _detect_backend(file_content(root, "pyproject.toml")) + if key == "unknown": + return False + if key == "setuptools": + return f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + return True + + +class BS003(BuildSystem): + "No legacy setup.py or setup.cfg" + + @staticmethod + def check(root: Traversable) -> bool | str: + has_py = file_exists(root, "setup.py") + has_cfg = file_exists(root, "setup.cfg") + if not has_py and not has_cfg: + return True + found = [f for f, present in [("setup.py", has_py), ("setup.cfg", has_cfg)] if present] + return f"⚠️ Legacy file(s) found: {', '.join(found)}. Remove in favour of pyproject.toml." + + +class BS004(BuildSystem): + "Build backend version pinned in requires" + + requires = {"BS001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, "pyproject.toml"): + return None + content = file_content(root, "pyproject.toml") + m = re.search(r"requires\s*=\s*\[([^\]]+)\]", content) + if not m: + return False + if re.search(r"[><=!~]", m.group(1)): + return True + return "⚠️ Build backend in requires has no version pin (e.g. >=x.y)." + + +class Security: + family = "security" + + +class SEC001(Security): + ".github/zizmor.yml exists" + + @staticmethod + def check(root: Traversable) -> bool | str: + if file_exists(root, ".github/zizmor.yml"): + return True + return "⚠️ .github/zizmor.yml not found — optional but recommended." + + +class SEC002(Security): + "zizmor.yml has secrets-outside-env rule" + + requires = {"SEC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".github/zizmor.yml"): + return None + return file_contains(root, ".github/zizmor.yml", "secrets-outside-env") + + +class SEC003(Security): + "gitleaks hook configured" + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "gitleaks") + + +class SEC004(Security): + "Workflows pin action SHAs" + + @staticmethod + def check(root: Traversable, workflow_map: dict) -> bool | None | str: + _, content = wf_content(root, "pr", workflow_map) + if not content: + return None + if re.search(r"uses:\s*\S+@[0-9a-f]{40}", content, re.I): + return True + return "⚠️ No SHA-pinned actions detected in PR workflow. Use full commit SHAs." + + +class SEC005(Security): + "SECURITY.md discourages public issue reporting" + + requires = {"PM008"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, "SECURITY.md"): + return None + if file_contains(root, "SECURITY.md", re.compile(r"do not|don't|please don", re.I)): + return True + return "⚠️ SECURITY.md may not clearly discourage public issue reporting." + + +class Labeler: + family = "labeler" + + +class LB001(Labeler): + ".github/labeler.yml exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, ".github/labeler.yml") + + +class LB002(Labeler): + ".github/labels.yml exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, ".github/labels.yml") + + +class LB003(Labeler): + "labels.yml has 'bug' label" + + requires = {"LB002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".github/labels.yml"): + return None + return file_contains(root, ".github/labels.yml", "bug") + + +class LB004(Labeler): + "labels.yml has 'enhancement' label" + + requires = {"LB002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".github/labels.yml"): + return None + return file_contains(root, ".github/labels.yml", "enhancement") + + +class LB005(Labeler): + "labels.yml has 'documentation' label" + + requires = {"LB002"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".github/labels.yml"): + return None + return file_contains(root, ".github/labels.yml", "documentation") + + +class Vale: + family = "vale" + + +_INI = "doc/.vale.ini" + + +class VL001(Vale): + "doc/.vale.ini exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, _INI) + + +class VL002(Vale): + "Vale uses Google style package" + + requires = {"VL001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, _INI): + return None + return file_contains(root, _INI, "Google") + + +class VL003(Vale): + "Vale uses ANSYS vocabulary" + + requires = {"VL001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, _INI): + return None + return file_contains(root, _INI, "ANSYS") + + +class VL004(Vale): + "ANSYS accept.txt vocabulary exists" + + requires = {"VL001"} + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "doc/styles/config/vocabularies/ANSYS/accept.txt") + + +class VL005(Vale): + "ANSYS reject.txt vocabulary exists" + + requires = {"VL001"} + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, "doc/styles/config/vocabularies/ANSYS/reject.txt") + + +class MCP: + family = "mcp" + + +class MCP001(MCP): + "Core governance files all present" + + @staticmethod + def check(root: Traversable, is_mcp: bool) -> bool | None: + if not is_mcp: + return None + required = ["LICENSE", "SECURITY.md", "CONTRIBUTING.md", "CHANGELOG.md", ".github/CODEOWNERS"] + missing = [p for p in required if not file_exists(root, p)] + return True if not missing else f"Missing: {', '.join(missing)}" + + +class MCP002(MCP): + "CI/CD workflows all present" + + @staticmethod + def check(root: Traversable, is_mcp: bool) -> bool | None: + if not is_mcp: + return None + workflows = [ + ".github/workflows/ci_cd_main.yml", + ".github/workflows/ci_cd_pr.yml", + ".github/workflows/ci_cd_release.yml", + ] + missing = [p for p in workflows if not file_exists(root, p)] + return True if not missing else f"Missing: {', '.join(missing)}" + + +class MCP003(MCP): + "tests job wired in PR workflow" + + @staticmethod + def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: + if not is_mcp: + return None + _, content = wf_content(root, "pr", workflow_map) + if not content: + return None + return bool(re.search(r"tests|pytest", content, re.I)) + + +class MCP004(MCP): + "doc-build job present in PR workflow" + + @staticmethod + def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: + if not is_mcp: + return None + _, content = wf_content(root, "pr", workflow_map) + if not content: + return None + return "doc-build" in content + + +class MCP005(MCP): + "README and docs metadata aligned" + + @staticmethod + def check(root: Traversable, is_mcp: bool, readme_path: str | None) -> bool | None | str: + if not is_mcp: + return None + if not file_exists(root, "pyproject.toml"): + return None + if not readme_path: + return False + filename = readme_path.split("/")[-1] + if not file_contains(root, "pyproject.toml", filename): + return f"pyproject.toml does not reference {filename} as readme." + if readme_path == "README.md": + return "⚠️ README.md found — PyAnsys preferred format is README.rst." + return True + + +class MCP006(MCP): + "No TODO/FIXME in doc/source/index.rst" + + @staticmethod + def check(root: Traversable, is_mcp: bool) -> bool | None: + if not is_mcp: + return None + if not file_exists(root, "doc/source/index.rst"): + return None + return not file_contains(root, "doc/source/index.rst", re.compile(r"TODO|FIXME")) + + +class MCP007(MCP): + "Security checks not bypassed" + + @staticmethod + def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: + if not is_mcp: + return None + _, pr_content = wf_content(root, "pr", workflow_map) + _, rel_content = wf_content(root, "release", workflow_map) + combined = pr_content + rel_content + if not combined.strip(): + return None + return not bool(re.search(r"--no-verify|skip.*security|disable.*scan", combined, re.I)) + + +class PreCommit: + family = "pre_commit" + + +class PC001(PreCommit): + ".pre-commit-config.yaml exists" + + @staticmethod + def check(root: Traversable) -> bool: + return file_exists(root, ".pre-commit-config.yaml") + + +class PC002(PreCommit): + "ruff-pre-commit configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "ruff-pre-commit") + + +class PC003(PreCommit): + "zizmor configured with --pedantic" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + has_zizmor = file_contains(root, ".pre-commit-config.yaml", "zizmor") + has_pedantic = file_contains(root, ".pre-commit-config.yaml", "--pedantic") + if not has_zizmor: + return False + if not has_pedantic: + return "⚠️ zizmor found but --pedantic flag not set." + return True + + +class PC004(PreCommit): + "blacken-docs configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "blacken-docs") + + +class PC005(PreCommit): + "codespell configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "codespell") + + +class PC006(PreCommit): + "ansys/pre-commit-hooks configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "ansys/pre-commit-hooks") + + +class PC007(PreCommit): + "google/yamlfmt configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "yamlfmt") + + +class PC008(PreCommit): + "pyright configured" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "pyright") + + +class PC009(PreCommit): + "autofix_prs: true enabled" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + if file_contains(root, ".pre-commit-config.yaml", "autofix_prs: true"): + return True + return "⚠️ autofix_prs: true not set in ci: block." + + +class PC010(PreCommit): + "autoupdate_schedule: weekly" + + requires = {"PC001"} + + @staticmethod + def check(root: Traversable) -> bool | None | str: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + if file_contains(root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly")): + return True + return "⚠️ autoupdate_schedule: weekly not found." + + +def repo_review_families() -> dict[str, dict]: + return { + "project_metadata": {"name": "Project Metadata", "order": 10}, + "cicd_files": {"name": "CI/CD — Workflow File Names", "order": 20}, + "cicd": {"name": "CI/CD — Content Checks", "order": 25}, + "dependabot": {"name": "Dependabot", "order": 30}, + "pre_commit": {"name": "Pre-commit", "order": 40}, + "documentation": {"name": "Documentation", "order": 50}, + "readme": {"name": "README", "order": 60}, + "build_system": {"name": "Build System", "order": 70}, + "security": {"name": "Security", "order": 80}, + "labeler": {"name": "Labeler", "order": 90}, + "vale": {"name": "Vale", "order": 100}, + "mcp": {"name": "MCP Release Readiness", "order": 110}, + } + + +def repo_review_checks() -> dict: + families = [ + ProjectMetadata, + CICDFiles, + CICD, + Dependabot, + PreCommit, + Documentation, + README, + BuildSystem, + Security, + Labeler, + Vale, + MCP, + ] + result = {} + for family in families: + for cls in family.__subclasses__(): + result[cls.__name__] = cls() + return result + + +def _first_doc_line(obj: Any) -> str: + doc = (obj.check.__doc__ or "").strip() + lines = [line.strip() for line in doc.splitlines() if line.strip()] + return lines[0] if lines else "" + + +def _interpret(raw: bool | str | None, check_obj: Any) -> tuple[str, str]: + if raw is True: + return "pass", "" + if raw is None: + return "na", "" + if isinstance(raw, str) and raw.startswith("⚠️ "): + return "warn", raw.removeprefix("⚠️ ") + if raw is False: + doc = (check_obj.check.__doc__ or "").strip() + lines = [line.strip() for line in doc.splitlines() if line.strip()] + detail = lines[-1] if len(lines) > 1 else (lines[0] if lines else "") + return "fail", detail + return "fail", str(raw) From e3f1f1c9ea4583f47e35ce8598b7545a47846574 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:07:52 +0000 Subject: [PATCH 07/49] chore: auto fixes from pre-commit hooks --- .../pyansys_quality_report.py | 174 ++++++++++++++---- src/ansys/pre_commit_hooks/quality_rules.py | 134 +++++++++++--- 2 files changed, 245 insertions(+), 63 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 9eeb1c4d..cab2c675 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -11,8 +11,8 @@ from __future__ import annotations import argparse -import json from io import BytesIO, StringIO +import json from pathlib import Path from typing import Any, Iterator @@ -24,11 +24,11 @@ from ansys.pre_commit_hooks.quality_rules import ( _first_doc_line, _interpret, + is_mcp, readme_path, repo_review_checks, repo_review_families, workflow_map, - is_mcp, ) _PATHS_TO_FETCH = [ @@ -175,7 +175,9 @@ def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, def _merge_all_workflows(root: Traversable) -> str: try: entries = [ - e for e in root.joinpath(".github/workflows").iterdir() if e.name.endswith((".yml", ".yaml")) + e + for e in root.joinpath(".github/workflows").iterdir() + if e.name.endswith((".yml", ".yaml")) ] except Exception: return "" @@ -354,7 +356,9 @@ def check(root: Traversable, readme_path: str | None) -> bool | None | str: return None content = file_content(root, "pyproject.toml") if re.search(r"poetry\.core|poetry-core", content): - m = re.search(r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M) + m = re.search( + r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M + ) if m: return True return False @@ -380,7 +384,7 @@ def check(root: Traversable) -> bool | None: return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", c, re.M)) return bool( re.search(r"license-files\s*=", c) - or re.search(r'license\s*=\s*\{[^}]*file', c) + or re.search(r"license\s*=\s*\{[^}]*file", c) or re.search(r'license\s*=\s*["\']LICENSE["\']', c) ) @@ -435,7 +439,11 @@ def check(root: Traversable, workflow_map: dict) -> bool | None | str: present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: return None - missing = [lbl for role, lbl in present if "concurrency:" not in wf_content(root, role, workflow_map)[1]] + missing = [ + lbl + for role, lbl in present + if "concurrency:" not in wf_content(root, role, workflow_map)[1] + ] if not missing: return True return f"⚠️ concurrency: block missing in: {', '.join(missing)}" @@ -450,7 +458,11 @@ def check(root: Traversable, workflow_map: dict) -> bool | None | str: present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: return None - missing = [lbl for role, lbl in present if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M)] + missing = [ + lbl + for role, lbl in present + if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M) + ] return True if not missing else f"Missing root permissions: {{}} in: {', '.join(missing)}" @@ -463,7 +475,11 @@ def check(root: Traversable, workflow_map: dict) -> bool | None | str: present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: return None - missing = [lbl for role, lbl in present if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1]] + missing = [ + lbl + for role, lbl in present + if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1] + ] if not missing: return True return f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" @@ -512,7 +528,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE) + ) class CI011(CICD): @@ -523,7 +541,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE) + ) class CI012(CICD): @@ -556,7 +576,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE) + ) class CI015(CICD): @@ -567,7 +589,13 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", content, re.IGNORECASE)) + return bool( + re.search( + r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", + content, + re.IGNORECASE, + ) + ) class CI016(CICD): @@ -578,7 +606,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE) + ) class Dependabot: @@ -617,8 +647,12 @@ class DB003(Dependabot): def check(root: Traversable) -> bool | None | str: if not file_exists(root, _PATH_DEPENDABOT): return None - has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) - has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) if has_pip: return True if has_uv: @@ -635,7 +669,9 @@ class DB004(Dependabot): def check(root: Traversable) -> bool | None: if not file_exists(root, _PATH_DEPENDABOT): return None - return file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?")) + return file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?") + ) class DB005(Dependabot): @@ -677,11 +713,17 @@ class DB007(Dependabot): def check(root: Traversable) -> bool | None | str: if not file_exists(root, _PATH_DEPENDABOT): return None - has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) - has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) if has_uv and not has_pip: return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?")): + if file_contains( + root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?") + ): return True return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." @@ -697,7 +739,7 @@ def check(root: Traversable) -> bool | None | str: return None if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): return True - return '⚠️ pip groups wildcard pattern "- \"*\"" not found in dependabot.yml.' + return '⚠️ pip groups wildcard pattern "- "*"" not found in dependabot.yml.' class Documentation: @@ -779,7 +821,9 @@ class DOC007(Documentation): def check(root: Traversable) -> bool | None: if not file_exists(root, "doc/source/index.rst"): return None - return file_contains(root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I)) + return file_contains( + root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I) + ) class README: @@ -807,7 +851,14 @@ class RM001(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", re.I)): + if file_contains( + root, + readme_path, + re.compile( + r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", + re.I, + ), + ): return True return f"⚠️ PyAnsys badge image not found in {readme_path}." @@ -821,7 +872,14 @@ class RM002(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", re.I)): + if file_contains( + root, + readme_path, + re.compile( + r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", + re.I, + ), + ): return True return f"⚠️ PyPI badge image not found in {readme_path}." @@ -835,7 +893,11 @@ class RM003(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I), + ): return True return f"⚠️ Codecov badge image not found in {readme_path}." @@ -849,7 +911,11 @@ class RM004(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I), + ): return True return f"⚠️ MIT license badge image not found in {readme_path}." @@ -863,7 +929,11 @@ class RM005(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I), + ): return True return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." @@ -952,7 +1022,9 @@ def check(root: Traversable) -> bool | None | str: if key == "unknown": return False if key == "setuptools": - return f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + return ( + f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + ) return True @@ -1176,7 +1248,13 @@ class MCP001(MCP): def check(root: Traversable, is_mcp: bool) -> bool | None: if not is_mcp: return None - required = ["LICENSE", "SECURITY.md", "CONTRIBUTING.md", "CHANGELOG.md", ".github/CODEOWNERS"] + required = [ + "LICENSE", + "SECURITY.md", + "CONTRIBUTING.md", + "CHANGELOG.md", + ".github/CODEOWNERS", + ] missing = [p for p in required if not file_exists(root, p)] return True if not missing else f"Missing: {', '.join(missing)}" @@ -1394,7 +1472,9 @@ class PC010(PreCommit): def check(root: Traversable) -> bool | None | str: if not file_exists(root, ".pre-commit-config.yaml"): return None - if file_contains(root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly")): + if file_contains( + root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly") + ): return True return "⚠️ autoupdate_schedule: weekly not found." @@ -1418,8 +1498,18 @@ def repo_review_families() -> dict[str, dict]: def repo_review_checks() -> dict: families = [ - ProjectMetadata, CICDFiles, CICD, Dependabot, PreCommit, Documentation, README, BuildSystem, - Security, Labeler, Vale, MCP, + ProjectMetadata, + CICDFiles, + CICD, + Dependabot, + PreCommit, + Documentation, + README, + BuildSystem, + Security, + Labeler, + Vale, + MCP, ] result = {} for family in families: @@ -1468,7 +1558,9 @@ def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, An import inspect signature = inspect.signature(check_obj.check) - kwargs = {key: fixture_values[key] for key in signature.parameters if key in fixture_values} + kwargs = { + key: fixture_values[key] for key in signature.parameters if key in fixture_values + } raw = check_obj.check(**kwargs) except Exception as exc: # pragma: no cover raw = f"⚠️ Check error: {exc}" @@ -1497,7 +1589,11 @@ def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, An "tally": tally, "score": score, "workflow_map": fixture_values["workflow_map"], - "project_metadata": {"build_system": {"name": "Unknown", "key": "unknown"}, "license": None, "python_requires": None}, + "project_metadata": { + "build_system": {"name": "Unknown", "key": "unknown"}, + "license": None, + "python_requires": None, + }, } @@ -1513,7 +1609,9 @@ def _load_files(repo_root: Path) -> dict[str, str | None]: workflows = repo_root / ".github" / "workflows" if workflows.is_dir(): for workflow in workflows.glob("*.y*ml"): - files[workflow.relative_to(repo_root).as_posix()] = workflow.read_text(encoding="utf-8", errors="replace") + files[workflow.relative_to(repo_root).as_posix()] = workflow.read_text( + encoding="utf-8", errors="replace" + ) return files @@ -1559,8 +1657,12 @@ def _print_report(review: dict[str, Any], *, show_passes: bool = False) -> None: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Run the PyAnsys repository quality report.") parser.add_argument("--repo-root", default=".", help="Repository root to review.") - parser.add_argument("--json", action="store_true", help="Emit a JSON report instead of a text summary.") - parser.add_argument("--all", action="store_true", help="Show all checks, including passing ones.") + parser.add_argument( + "--json", action="store_true", help="Emit a JSON report instead of a text summary." + ) + parser.add_argument( + "--all", action="store_true", help="Show all checks, including passing ones." + ) args = parser.parse_args(argv) repo_root = Path(args.repo_root).resolve() diff --git a/src/ansys/pre_commit_hooks/quality_rules.py b/src/ansys/pre_commit_hooks/quality_rules.py index 1bf9fe46..2db77c1e 100644 --- a/src/ansys/pre_commit_hooks/quality_rules.py +++ b/src/ansys/pre_commit_hooks/quality_rules.py @@ -96,7 +96,9 @@ def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, def _merge_all_workflows(root: Traversable) -> str: try: entries = [ - e for e in root.joinpath(".github/workflows").iterdir() if e.name.endswith((".yml", ".yaml")) + e + for e in root.joinpath(".github/workflows").iterdir() + if e.name.endswith((".yml", ".yaml")) ] except Exception: return "" @@ -275,7 +277,9 @@ def check(root: Traversable, readme_path: str | None) -> bool | None | str: return None content = file_content(root, "pyproject.toml") if re.search(r"poetry\.core|poetry-core", content): - m = re.search(r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M) + m = re.search( + r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M + ) if m: return True return False @@ -301,7 +305,7 @@ def check(root: Traversable) -> bool | None: return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", c, re.M)) return bool( re.search(r"license-files\s*=", c) - or re.search(r'license\s*=\s*\{[^}]*file', c) + or re.search(r"license\s*=\s*\{[^}]*file", c) or re.search(r'license\s*=\s*["\']LICENSE["\']', c) ) @@ -356,7 +360,11 @@ def check(root: Traversable, workflow_map: dict) -> bool | None | str: present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: return None - missing = [lbl for role, lbl in present if "concurrency:" not in wf_content(root, role, workflow_map)[1]] + missing = [ + lbl + for role, lbl in present + if "concurrency:" not in wf_content(root, role, workflow_map)[1] + ] if not missing: return True return f"⚠️ concurrency: block missing in: {', '.join(missing)}" @@ -371,7 +379,11 @@ def check(root: Traversable, workflow_map: dict) -> bool | None | str: present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: return None - missing = [lbl for role, lbl in present if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M)] + missing = [ + lbl + for role, lbl in present + if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M) + ] return True if not missing else f"Missing root permissions: {{}} in: {', '.join(missing)}" @@ -384,7 +396,11 @@ def check(root: Traversable, workflow_map: dict) -> bool | None | str: present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: return None - missing = [lbl for role, lbl in present if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1]] + missing = [ + lbl + for role, lbl in present + if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1] + ] if not missing: return True return f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" @@ -433,7 +449,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE) + ) class CI011(CICD): @@ -444,7 +462,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE) + ) class CI012(CICD): @@ -477,7 +497,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE) + ) class CI015(CICD): @@ -488,7 +510,13 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", content, re.IGNORECASE)) + return bool( + re.search( + r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", + content, + re.IGNORECASE, + ) + ) class CI016(CICD): @@ -499,7 +527,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE) + ) class Dependabot: @@ -538,8 +568,12 @@ class DB003(Dependabot): def check(root: Traversable) -> bool | None | str: if not file_exists(root, _PATH_DEPENDABOT): return None - has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) - has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) if has_pip: return True if has_uv: @@ -556,7 +590,9 @@ class DB004(Dependabot): def check(root: Traversable) -> bool | None: if not file_exists(root, _PATH_DEPENDABOT): return None - return file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?")) + return file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?") + ) class DB005(Dependabot): @@ -598,11 +634,17 @@ class DB007(Dependabot): def check(root: Traversable) -> bool | None | str: if not file_exists(root, _PATH_DEPENDABOT): return None - has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) - has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) if has_uv and not has_pip: return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?")): + if file_contains( + root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?") + ): return True return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." @@ -618,7 +660,7 @@ def check(root: Traversable) -> bool | None | str: return None if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): return True - return '⚠️ pip groups wildcard pattern "- \"*\"" not found in dependabot.yml.' + return '⚠️ pip groups wildcard pattern "- "*"" not found in dependabot.yml.' class Documentation: @@ -700,7 +742,9 @@ class DOC007(Documentation): def check(root: Traversable) -> bool | None: if not file_exists(root, "doc/source/index.rst"): return None - return file_contains(root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I)) + return file_contains( + root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I) + ) class README: @@ -728,7 +772,14 @@ class RM001(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", re.I)): + if file_contains( + root, + readme_path, + re.compile( + r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", + re.I, + ), + ): return True return f"⚠️ PyAnsys badge image not found in {readme_path}." @@ -742,7 +793,14 @@ class RM002(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", re.I)): + if file_contains( + root, + readme_path, + re.compile( + r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", + re.I, + ), + ): return True return f"⚠️ PyPI badge image not found in {readme_path}." @@ -756,7 +814,11 @@ class RM003(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I), + ): return True return f"⚠️ Codecov badge image not found in {readme_path}." @@ -770,7 +832,11 @@ class RM004(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I), + ): return True return f"⚠️ MIT license badge image not found in {readme_path}." @@ -784,7 +850,11 @@ class RM005(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I), + ): return True return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." @@ -873,7 +943,9 @@ def check(root: Traversable) -> bool | None | str: if key == "unknown": return False if key == "setuptools": - return f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + return ( + f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + ) return True @@ -1097,7 +1169,13 @@ class MCP001(MCP): def check(root: Traversable, is_mcp: bool) -> bool | None: if not is_mcp: return None - required = ["LICENSE", "SECURITY.md", "CONTRIBUTING.md", "CHANGELOG.md", ".github/CODEOWNERS"] + required = [ + "LICENSE", + "SECURITY.md", + "CONTRIBUTING.md", + "CHANGELOG.md", + ".github/CODEOWNERS", + ] missing = [p for p in required if not file_exists(root, p)] return True if not missing else f"Missing: {', '.join(missing)}" @@ -1315,7 +1393,9 @@ class PC010(PreCommit): def check(root: Traversable) -> bool | None | str: if not file_exists(root, ".pre-commit-config.yaml"): return None - if file_contains(root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly")): + if file_contains( + root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly") + ): return True return "⚠️ autoupdate_schedule: weekly not found." From 36d2ef355da80116aad3573191815ca5737e2b24 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 13:46:41 +0530 Subject: [PATCH 08/49] fix: add subpackage --- .../pyansys_quality_report.py | 174 ++++- .../__init__.py} | 690 ++++++------------ .../quality_rules/build_system.py | 97 +++ .../pre_commit_hooks/quality_rules/cicd.py | 212 ++++++ .../quality_rules/cicd_files.py | 47 ++ .../pre_commit_hooks/quality_rules/common.py | 192 +++++ .../quality_rules/dependabot.py | 153 ++++ .../quality_rules/documentation.py | 96 +++ .../pre_commit_hooks/quality_rules/labeler.py | 66 ++ .../pre_commit_hooks/quality_rules/mcp.py | 122 ++++ .../quality_rules/pre_commit.py | 156 ++++ .../quality_rules/project_metadata.py | 151 ++++ .../pre_commit_hooks/quality_rules/readme.py | 171 +++++ .../quality_rules/security.py | 75 ++ .../pre_commit_hooks/quality_rules/vale.py | 66 ++ tests/test_pyansys_quality_report.py | 10 + 16 files changed, 1965 insertions(+), 513 deletions(-) rename src/ansys/pre_commit_hooks/{quality_rules.py => quality_rules/__init__.py} (60%) create mode 100644 src/ansys/pre_commit_hooks/quality_rules/build_system.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/cicd.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/cicd_files.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/common.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/dependabot.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/documentation.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/labeler.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/mcp.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/pre_commit.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/project_metadata.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/readme.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/security.py create mode 100644 src/ansys/pre_commit_hooks/quality_rules/vale.py diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 9eeb1c4d..cab2c675 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -11,8 +11,8 @@ from __future__ import annotations import argparse -import json from io import BytesIO, StringIO +import json from pathlib import Path from typing import Any, Iterator @@ -24,11 +24,11 @@ from ansys.pre_commit_hooks.quality_rules import ( _first_doc_line, _interpret, + is_mcp, readme_path, repo_review_checks, repo_review_families, workflow_map, - is_mcp, ) _PATHS_TO_FETCH = [ @@ -175,7 +175,9 @@ def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, def _merge_all_workflows(root: Traversable) -> str: try: entries = [ - e for e in root.joinpath(".github/workflows").iterdir() if e.name.endswith((".yml", ".yaml")) + e + for e in root.joinpath(".github/workflows").iterdir() + if e.name.endswith((".yml", ".yaml")) ] except Exception: return "" @@ -354,7 +356,9 @@ def check(root: Traversable, readme_path: str | None) -> bool | None | str: return None content = file_content(root, "pyproject.toml") if re.search(r"poetry\.core|poetry-core", content): - m = re.search(r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M) + m = re.search( + r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M + ) if m: return True return False @@ -380,7 +384,7 @@ def check(root: Traversable) -> bool | None: return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", c, re.M)) return bool( re.search(r"license-files\s*=", c) - or re.search(r'license\s*=\s*\{[^}]*file', c) + or re.search(r"license\s*=\s*\{[^}]*file", c) or re.search(r'license\s*=\s*["\']LICENSE["\']', c) ) @@ -435,7 +439,11 @@ def check(root: Traversable, workflow_map: dict) -> bool | None | str: present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: return None - missing = [lbl for role, lbl in present if "concurrency:" not in wf_content(root, role, workflow_map)[1]] + missing = [ + lbl + for role, lbl in present + if "concurrency:" not in wf_content(root, role, workflow_map)[1] + ] if not missing: return True return f"⚠️ concurrency: block missing in: {', '.join(missing)}" @@ -450,7 +458,11 @@ def check(root: Traversable, workflow_map: dict) -> bool | None | str: present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: return None - missing = [lbl for role, lbl in present if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M)] + missing = [ + lbl + for role, lbl in present + if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M) + ] return True if not missing else f"Missing root permissions: {{}} in: {', '.join(missing)}" @@ -463,7 +475,11 @@ def check(root: Traversable, workflow_map: dict) -> bool | None | str: present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: return None - missing = [lbl for role, lbl in present if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1]] + missing = [ + lbl + for role, lbl in present + if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1] + ] if not missing: return True return f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" @@ -512,7 +528,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE) + ) class CI011(CICD): @@ -523,7 +541,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE) + ) class CI012(CICD): @@ -556,7 +576,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE) + ) class CI015(CICD): @@ -567,7 +589,13 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", content, re.IGNORECASE)) + return bool( + re.search( + r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", + content, + re.IGNORECASE, + ) + ) class CI016(CICD): @@ -578,7 +606,9 @@ def check(root: Traversable) -> bool | None: content = all_workflows_content(root) if not content: return None - return bool(re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE)) + return bool( + re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE) + ) class Dependabot: @@ -617,8 +647,12 @@ class DB003(Dependabot): def check(root: Traversable) -> bool | None | str: if not file_exists(root, _PATH_DEPENDABOT): return None - has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) - has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) if has_pip: return True if has_uv: @@ -635,7 +669,9 @@ class DB004(Dependabot): def check(root: Traversable) -> bool | None: if not file_exists(root, _PATH_DEPENDABOT): return None - return file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?")) + return file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?") + ) class DB005(Dependabot): @@ -677,11 +713,17 @@ class DB007(Dependabot): def check(root: Traversable) -> bool | None | str: if not file_exists(root, _PATH_DEPENDABOT): return None - has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) - has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) if has_uv and not has_pip: return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?")): + if file_contains( + root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?") + ): return True return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." @@ -697,7 +739,7 @@ def check(root: Traversable) -> bool | None | str: return None if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): return True - return '⚠️ pip groups wildcard pattern "- \"*\"" not found in dependabot.yml.' + return '⚠️ pip groups wildcard pattern "- "*"" not found in dependabot.yml.' class Documentation: @@ -779,7 +821,9 @@ class DOC007(Documentation): def check(root: Traversable) -> bool | None: if not file_exists(root, "doc/source/index.rst"): return None - return file_contains(root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I)) + return file_contains( + root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I) + ) class README: @@ -807,7 +851,14 @@ class RM001(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", re.I)): + if file_contains( + root, + readme_path, + re.compile( + r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", + re.I, + ), + ): return True return f"⚠️ PyAnsys badge image not found in {readme_path}." @@ -821,7 +872,14 @@ class RM002(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", re.I)): + if file_contains( + root, + readme_path, + re.compile( + r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", + re.I, + ), + ): return True return f"⚠️ PyPI badge image not found in {readme_path}." @@ -835,7 +893,11 @@ class RM003(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I), + ): return True return f"⚠️ Codecov badge image not found in {readme_path}." @@ -849,7 +911,11 @@ class RM004(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I), + ): return True return f"⚠️ MIT license badge image not found in {readme_path}." @@ -863,7 +929,11 @@ class RM005(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I), + ): return True return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." @@ -952,7 +1022,9 @@ def check(root: Traversable) -> bool | None | str: if key == "unknown": return False if key == "setuptools": - return f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + return ( + f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + ) return True @@ -1176,7 +1248,13 @@ class MCP001(MCP): def check(root: Traversable, is_mcp: bool) -> bool | None: if not is_mcp: return None - required = ["LICENSE", "SECURITY.md", "CONTRIBUTING.md", "CHANGELOG.md", ".github/CODEOWNERS"] + required = [ + "LICENSE", + "SECURITY.md", + "CONTRIBUTING.md", + "CHANGELOG.md", + ".github/CODEOWNERS", + ] missing = [p for p in required if not file_exists(root, p)] return True if not missing else f"Missing: {', '.join(missing)}" @@ -1394,7 +1472,9 @@ class PC010(PreCommit): def check(root: Traversable) -> bool | None | str: if not file_exists(root, ".pre-commit-config.yaml"): return None - if file_contains(root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly")): + if file_contains( + root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly") + ): return True return "⚠️ autoupdate_schedule: weekly not found." @@ -1418,8 +1498,18 @@ def repo_review_families() -> dict[str, dict]: def repo_review_checks() -> dict: families = [ - ProjectMetadata, CICDFiles, CICD, Dependabot, PreCommit, Documentation, README, BuildSystem, - Security, Labeler, Vale, MCP, + ProjectMetadata, + CICDFiles, + CICD, + Dependabot, + PreCommit, + Documentation, + README, + BuildSystem, + Security, + Labeler, + Vale, + MCP, ] result = {} for family in families: @@ -1468,7 +1558,9 @@ def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, An import inspect signature = inspect.signature(check_obj.check) - kwargs = {key: fixture_values[key] for key in signature.parameters if key in fixture_values} + kwargs = { + key: fixture_values[key] for key in signature.parameters if key in fixture_values + } raw = check_obj.check(**kwargs) except Exception as exc: # pragma: no cover raw = f"⚠️ Check error: {exc}" @@ -1497,7 +1589,11 @@ def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, An "tally": tally, "score": score, "workflow_map": fixture_values["workflow_map"], - "project_metadata": {"build_system": {"name": "Unknown", "key": "unknown"}, "license": None, "python_requires": None}, + "project_metadata": { + "build_system": {"name": "Unknown", "key": "unknown"}, + "license": None, + "python_requires": None, + }, } @@ -1513,7 +1609,9 @@ def _load_files(repo_root: Path) -> dict[str, str | None]: workflows = repo_root / ".github" / "workflows" if workflows.is_dir(): for workflow in workflows.glob("*.y*ml"): - files[workflow.relative_to(repo_root).as_posix()] = workflow.read_text(encoding="utf-8", errors="replace") + files[workflow.relative_to(repo_root).as_posix()] = workflow.read_text( + encoding="utf-8", errors="replace" + ) return files @@ -1559,8 +1657,12 @@ def _print_report(review: dict[str, Any], *, show_passes: bool = False) -> None: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Run the PyAnsys repository quality report.") parser.add_argument("--repo-root", default=".", help="Repository root to review.") - parser.add_argument("--json", action="store_true", help="Emit a JSON report instead of a text summary.") - parser.add_argument("--all", action="store_true", help="Show all checks, including passing ones.") + parser.add_argument( + "--json", action="store_true", help="Emit a JSON report instead of a text summary." + ) + parser.add_argument( + "--all", action="store_true", help="Show all checks, including passing ones." + ) args = parser.parse_args(argv) repo_root = Path(args.repo_root).resolve() diff --git a/src/ansys/pre_commit_hooks/quality_rules.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py similarity index 60% rename from src/ansys/pre_commit_hooks/quality_rules.py rename to src/ansys/pre_commit_hooks/quality_rules/__init__.py index 1bf9fe46..a3322faf 100644 --- a/src/ansys/pre_commit_hooks/quality_rules.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -5,14 +5,32 @@ from __future__ import annotations -import re -from typing import Any, Iterator - -try: - from importlib.resources.abc import Traversable -except ImportError: # pragma: no cover - from importlib.abc import Traversable - +from .build_system import BS001, BS002, BS003, BS004, BuildSystem +from .cicd import CI004, CI005, CI006, CI007, CI008, CI009, CI010, CI011, CI012, CI013, CI014, CI015, CI016, CICD +from .cicd_files import CI001, CI002, CI003, CICDFiles +from .common import ( + CANONICAL_WF, + _first_doc_line, + _interpret, + all_workflows_content, + file_contains, + file_content, + file_exists, + is_mcp, + readme_path, + wf_content, + wf_label, + workflow_map, +) +from .dependabot import DB001, DB002, DB003, DB004, DB005, DB006, DB007, DB008, Dependabot +from .documentation import DOC001, DOC002, DOC003, DOC004, DOC005, DOC006, DOC007, Documentation +from .labeler import LB001, LB002, LB003, LB004, LB005, Labeler +from .mcp import MCP001, MCP002, MCP003, MCP004, MCP005, MCP006, MCP007, MCP +from .pre_commit import PC001, PC002, PC003, PC004, PC005, PC006, PC007, PC008, PC009, PC010, PreCommit +from .project_metadata import PM001, PM002, PM003, PM004, PM005, PM006, PM007, PM008, PM009, PM010, PM011, ProjectMetadata +from .readme import RM000, RM001, RM002, RM003, RM004, RM005, RM006, RM007, RM008, README +from .security import SEC001, SEC002, SEC003, SEC004, SEC005, Security +from .vale import VL001, VL002, VL003, VL004, VL005, Vale __all__ = [ "file_exists", @@ -30,478 +48,146 @@ "_first_doc_line", "_interpret", "ProjectMetadata", + "PM001", + "PM002", + "PM003", + "PM004", + "PM005", + "PM006", + "PM007", + "PM008", + "PM009", + "PM010", + "PM011", "CICDFiles", + "CI001", + "CI002", + "CI003", "CICD", + "CI004", + "CI005", + "CI006", + "CI007", + "CI008", + "CI009", + "CI010", + "CI011", + "CI012", + "CI013", + "CI014", + "CI015", + "CI016", "Dependabot", + "DB001", + "DB002", + "DB003", + "DB004", + "DB005", + "DB006", + "DB007", + "DB008", "Documentation", + "DOC001", + "DOC002", + "DOC003", + "DOC004", + "DOC005", + "DOC006", + "DOC007", "README", + "RM000", + "RM001", + "RM002", + "RM003", + "RM004", + "RM005", + "RM006", + "RM007", + "RM008", "BuildSystem", + "BS001", + "BS002", + "BS003", + "BS004", "Security", + "SEC001", + "SEC002", + "SEC003", + "SEC004", + "SEC005", "Labeler", + "LB001", + "LB002", + "LB003", + "LB004", + "LB005", "Vale", + "VL001", + "VL002", + "VL003", + "VL004", + "VL005", "MCP", + "MCP001", + "MCP002", + "MCP003", + "MCP004", + "MCP005", + "MCP006", + "MCP007", "PreCommit", + "PC001", + "PC002", + "PC003", + "PC004", + "PC005", + "PC006", + "PC007", + "PC008", + "PC009", + "PC010", ] -def file_exists(root: Traversable, path: str) -> bool: - try: - return root.joinpath(path).is_file() - except Exception: - return False - - -def file_content(root: Traversable, path: str) -> str: - try: - f = root.joinpath(path) - if f.is_file(): - return f.read_text(encoding="utf-8") - except Exception: - pass - return "" - - -def file_contains(root: Traversable, path: str, pattern: str | re.Pattern) -> bool: - content = file_content(root, path) - if not content: - return False - if isinstance(pattern, str): - return pattern in content - return bool(pattern.search(content)) - - -CANONICAL_WF = { - "main": ".github/workflows/ci_cd_main.yml", - "pr": ".github/workflows/ci_cd_pr.yml", - "release": ".github/workflows/ci_cd_release.yml", -} - - -def all_workflows_content(root: Traversable) -> str: - return _merge_all_workflows(root) - - -def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, str]: - canonical = CANONICAL_WF[role] - if file_exists(root, canonical): - return True, file_content(root, canonical) - - entry = workflow_map.get(role) - if entry and not entry.get("is_fallback"): - path = entry.get("path", "") - return False, file_content(root, path) if path else "" - return False, _merge_all_workflows(root) - +def repo_review_families() -> dict[str, dict]: + return { + "project_metadata": {"name": "Project Metadata", "order": 10}, + "cicd_files": {"name": "CI/CD — Workflow File Names", "order": 20}, + "cicd": {"name": "CI/CD — Content Checks", "order": 25}, + "dependabot": {"name": "Dependabot", "order": 30}, + "pre_commit": {"name": "Pre-commit", "order": 40}, + "documentation": {"name": "Documentation", "order": 50}, + "readme": {"name": "README", "order": 60}, + "build_system": {"name": "Build System", "order": 70}, + "security": {"name": "Security", "order": 80}, + "labeler": {"name": "Labeler", "order": 90}, + "vale": {"name": "Vale", "order": 100}, + "mcp": {"name": "MCP Release Readiness", "order": 110}, + } -def _merge_all_workflows(root: Traversable) -> str: - try: - entries = [ - e for e in root.joinpath(".github/workflows").iterdir() if e.name.endswith((".yml", ".yaml")) - ] - except Exception: - return "" - parts = [] - for entry in entries: - try: - c = entry.read_text(encoding="utf-8") - if c: - parts.append(c) - except Exception: - pass - return "\n\n".join(parts) - - -def wf_label(role: str, workflow_map: dict) -> str: - entry = workflow_map.get(role) - if not entry: - return CANONICAL_WF.get(role, role) - if entry.get("is_fallback"): - sources = entry.get("sources", []) - return f"{len(sources)} workflow file(s) ({', '.join(sources)})" - return entry.get("name", role) - - -def workflow_map(root: Traversable) -> dict[str, dict]: - wf_dir = root.joinpath(".github/workflows") - try: - entries = [e for e in wf_dir.iterdir() if e.name.endswith((".yml", ".yaml"))] - except Exception: - entries = [] - - result: dict[str, dict] = {} - for entry in entries: - role = _classify_workflow(entry.name) - if role != "unknown" and role not in result: - result[role] = { - "name": entry.name, - "path": f".github/workflows/{entry.name}", - "is_fallback": False, - "sources": [entry.name], - } - - for role in ("main", "pr", "release"): - if role not in result and entries: - result[role] = { - "name": f"{len(entries)} workflow(s)", - "path": None, - "is_fallback": True, - "sources": [e.name for e in entries], - } +def repo_review_checks() -> dict: + families = [ + ProjectMetadata, + CICDFiles, + CICD, + Dependabot, + PreCommit, + Documentation, + README, + BuildSystem, + Security, + Labeler, + Vale, + MCP, + ] + result = {} + for family in families: + for cls in family.__subclasses__(): + result[cls.__name__] = cls() return result -def _classify_workflow(name: str) -> str: - n = name.lower() - if re.search(r"release|publish|deploy", n): - return "release" - if re.search(r"\bpr\b|pull.?request|pull_request", n): - return "pr" - if re.search(r"main|push|branch|nightly|schedule|ci_cd_main|ci.main", n): - return "main" - if re.search(r"\bci\b|build|test", n): - return "pr" - return "unknown" - - -def readme_path(root: Traversable) -> str | None: - if file_exists(root, "README.rst"): - return "README.rst" - if file_exists(root, "README.md"): - return "README.md" - return None - - -def is_mcp(root: Traversable) -> bool: - try: - pyproject_text = root.joinpath("pyproject.toml").read_text() - return bool(re.search(r"\b(fastmcp|mcp)\b", pyproject_text, re.IGNORECASE)) - except Exception: - pass - try: - return file_exists(root, "src/server.py") or file_exists(root, "server.py") - except Exception: - return False - - -class ProjectMetadata: - family = "project_metadata" - - -class PM001(ProjectMetadata): - "AUTHORS exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "AUTHORS") - - -class PM002(ProjectMetadata): - "CHANGELOG.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "CHANGELOG.md") - - -class PM003(ProjectMetadata): - "CODE_OF_CONDUCT.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "CODE_OF_CONDUCT.md") - - -class PM004(ProjectMetadata): - "CONTRIBUTING.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "CONTRIBUTING.md") - - -class PM005(ProjectMetadata): - "CONTRIBUTORS.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "CONTRIBUTORS.md") - - -class PM006(ProjectMetadata): - "LICENSE exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "LICENSE") - - -class PM007(ProjectMetadata): - "README exists (.rst preferred)" - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | str: - if readme_path is None: - return False - if readme_path == "README.md": - return "⚠️ README.md found — README.rst is the preferred format." - return True - - -class PM008(ProjectMetadata): - "SECURITY.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "SECURITY.md") - - -class PM009(ProjectMetadata): - ".github/CODEOWNERS exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, ".github/CODEOWNERS") - - -class PM010(ProjectMetadata): - "pyproject.toml references README file" - - requires = {"PM007"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not file_exists(root, "pyproject.toml"): - return None - content = file_content(root, "pyproject.toml") - if re.search(r"poetry\.core|poetry-core", content): - m = re.search(r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M) - if m: - return True - return False - rm = readme_path or "README.rst" - if rm in content: - return True - if "README" in content: - return "⚠️ readme key found but exact README filename not confirmed." - return False - - -class PM011(ProjectMetadata): - "pyproject.toml references LICENSE file" - - requires = {"PM006"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "pyproject.toml"): - return None - c = file_content(root, "pyproject.toml") - if re.search(r"poetry\.core|poetry-core", c): - return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", c, re.M)) - return bool( - re.search(r"license-files\s*=", c) - or re.search(r'license\s*=\s*\{[^}]*file', c) - or re.search(r'license\s*=\s*["\']LICENSE["\']', c) - ) - - -class CICDFiles: - family = "cicd_files" - - -class CI001(CICDFiles): - "ci_cd_main.yml exists" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | str: - if file_exists(root, CANONICAL_WF["main"]): - return True - lbl = wf_label("main", workflow_map) - return f"⚠️ Canonical ci_cd_main.yml not found — detected: {lbl}" - - -class CI002(CICDFiles): - "ci_cd_pr.yml exists" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | str: - if file_exists(root, CANONICAL_WF["pr"]): - return True - lbl = wf_label("pr", workflow_map) - return f"⚠️ Canonical ci_cd_pr.yml not found — detected: {lbl}" - - -class CI003(CICDFiles): - "ci_cd_release.yml exists" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | str: - if file_exists(root, CANONICAL_WF["release"]): - return True - lbl = wf_label("release", workflow_map) - return f"⚠️ Canonical ci_cd_release.yml not found — detected: {lbl}" - - -class CICD: - family = "cicd" - - -class CI004(CICD): - "Workflows use concurrency blocks" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | None | str: - roles = [("pr", "ci_cd_pr.yml"), ("main", "ci_cd_main.yml")] - present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] - if not present: - return None - missing = [lbl for role, lbl in present if "concurrency:" not in wf_content(root, role, workflow_map)[1]] - if not missing: - return True - return f"⚠️ concurrency: block missing in: {', '.join(missing)}" - - -class CI005(CICD): - "Workflows set root `permissions: {}`" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | None | str: - roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] - present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] - if not present: - return None - missing = [lbl for role, lbl in present if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M)] - return True if not missing else f"Missing root permissions: {{}} in: {', '.join(missing)}" - - -class CI006(CICD): - "checkout uses persist-credentials: false" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | None | str: - roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] - present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] - if not present: - return None - missing = [lbl for role, lbl in present if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1]] - if not missing: - return True - return f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" - - -class CI007(CICD): - "Labeler job present across workflows" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/[^\s]*label|\blabeler\b", content, re.IGNORECASE)) - - -class CI008(CICD): - "ansys/actions/check-vulnerabilities used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return "ansys/actions/check-vulnerabilities" in content - - -class CI009(CICD): - "ansys/actions/code-style used" - - @staticmethod - def check(root: Traversable) -> bool | None | str: - content = all_workflows_content(root) - if not content: - return None - if "ansys/actions/code-style" in content: - return True - return "⚠️ ansys/actions/code-style not found in any workflow file." - - -class CI010(CICD): - "check-pr-title step present across workflows" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE)) - - -class CI011(CICD): - "changelog-fragment step present across workflows" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE)) - - -class CI012(CICD): - "ansys/actions/check-doc-style used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/check-doc-style|doc-style", content, re.IGNORECASE)) - - -class CI013(CICD): - "ansys/actions/doc-build used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/doc-build|\bdoc-build\b", content, re.IGNORECASE)) - - -class CI014(CICD): - "ansys/actions/build-wheelhouse used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE)) - - -class CI015(CICD): - "ansys/actions/tests-pytest (or pytest) used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", content, re.IGNORECASE)) - - -class CI016(CICD): - "update-changelog step present across workflows" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE)) - - class Dependabot: family = "dependabot" @@ -538,8 +224,12 @@ class DB003(Dependabot): def check(root: Traversable) -> bool | None | str: if not file_exists(root, _PATH_DEPENDABOT): return None - has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) - has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) if has_pip: return True if has_uv: @@ -556,7 +246,9 @@ class DB004(Dependabot): def check(root: Traversable) -> bool | None: if not file_exists(root, _PATH_DEPENDABOT): return None - return file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?")) + return file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?") + ) class DB005(Dependabot): @@ -598,11 +290,17 @@ class DB007(Dependabot): def check(root: Traversable) -> bool | None | str: if not file_exists(root, _PATH_DEPENDABOT): return None - has_uv = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?")) - has_pip = file_contains(root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?")) + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) if has_uv and not has_pip: return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?")): + if file_contains( + root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?") + ): return True return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." @@ -618,7 +316,7 @@ def check(root: Traversable) -> bool | None | str: return None if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): return True - return '⚠️ pip groups wildcard pattern "- \"*\"" not found in dependabot.yml.' + return '⚠️ pip groups wildcard pattern "- "*"" not found in dependabot.yml.' class Documentation: @@ -700,7 +398,9 @@ class DOC007(Documentation): def check(root: Traversable) -> bool | None: if not file_exists(root, "doc/source/index.rst"): return None - return file_contains(root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I)) + return file_contains( + root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I) + ) class README: @@ -728,7 +428,14 @@ class RM001(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", re.I)): + if file_contains( + root, + readme_path, + re.compile( + r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", + re.I, + ), + ): return True return f"⚠️ PyAnsys badge image not found in {readme_path}." @@ -742,7 +449,14 @@ class RM002(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", re.I)): + if file_contains( + root, + readme_path, + re.compile( + r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", + re.I, + ), + ): return True return f"⚠️ PyPI badge image not found in {readme_path}." @@ -756,7 +470,11 @@ class RM003(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I), + ): return True return f"⚠️ Codecov badge image not found in {readme_path}." @@ -770,7 +488,11 @@ class RM004(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I), + ): return True return f"⚠️ MIT license badge image not found in {readme_path}." @@ -784,7 +506,11 @@ class RM005(README): def check(root: Traversable, readme_path: str | None) -> bool | None | str: if not readme_path: return None - if file_contains(root, readme_path, re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I)): + if file_contains( + root, + readme_path, + re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I), + ): return True return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." @@ -873,7 +599,9 @@ def check(root: Traversable) -> bool | None | str: if key == "unknown": return False if key == "setuptools": - return f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + return ( + f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + ) return True @@ -1097,7 +825,13 @@ class MCP001(MCP): def check(root: Traversable, is_mcp: bool) -> bool | None: if not is_mcp: return None - required = ["LICENSE", "SECURITY.md", "CONTRIBUTING.md", "CHANGELOG.md", ".github/CODEOWNERS"] + required = [ + "LICENSE", + "SECURITY.md", + "CONTRIBUTING.md", + "CHANGELOG.md", + ".github/CODEOWNERS", + ] missing = [p for p in required if not file_exists(root, p)] return True if not missing else f"Missing: {', '.join(missing)}" @@ -1315,7 +1049,9 @@ class PC010(PreCommit): def check(root: Traversable) -> bool | None | str: if not file_exists(root, ".pre-commit-config.yaml"): return None - if file_contains(root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly")): + if file_contains( + root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly") + ): return True return "⚠️ autoupdate_schedule: weekly not found." diff --git a/src/ansys/pre_commit_hooks/quality_rules/build_system.py b/src/ansys/pre_commit_hooks/quality_rules/build_system.py new file mode 100644 index 00000000..da4f75f3 --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/build_system.py @@ -0,0 +1,97 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Build system checks.""" + +from __future__ import annotations + +import re + +from .common import file_contains, file_content, file_exists + +__all__ = ["BuildSystem", "BS001", "BS002", "BS003", "BS004"] + + +_BACKENDS = { + "flit_core": "Flit", + "poetry.core": "Poetry", + "hatchling": "Hatch", + "pdm": "PDM", + "maturin": "Maturin", + "setuptools": "Setuptools", +} + + +def _detect_backend(content: str) -> tuple[str, str]: + m = re.search(r'build-backend\s*=\s*["\']([^"\']+)["\']', content) + backend = m.group(1) if m else "" + for pattern, name in _BACKENDS.items(): + if pattern in backend: + return name, pattern.split(".")[0].replace("_core", "") + if "[build-system]" in content: + return "Other", "other" + return "Unknown", "unknown" + + +class BuildSystem: + family = "build_system" + + +class BS001(BuildSystem): + "[build-system] table declared" + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, "pyproject.toml"): + return None + return file_contains(root, "pyproject.toml", "[build-system]") + + +class BS002(BuildSystem): + "Uses a supported modern build backend" + + requires = {"BS001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, "pyproject.toml"): + return None + name, key = _detect_backend(file_content(root, "pyproject.toml")) + if key == "unknown": + return False + if key == "setuptools": + return ( + f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + ) + return True + + +class BS003(BuildSystem): + "No legacy setup.py or setup.cfg" + + @staticmethod + def check(root) -> bool | str: + has_py = file_exists(root, "setup.py") + has_cfg = file_exists(root, "setup.cfg") + if not has_py and not has_cfg: + return True + found = [f for f, present in [("setup.py", has_py), ("setup.cfg", has_cfg)] if present] + return f"⚠️ Legacy file(s) found: {', '.join(found)}. Remove in favour of pyproject.toml." + + +class BS004(BuildSystem): + "Build backend version pinned in requires" + + requires = {"BS001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, "pyproject.toml"): + return None + content = file_content(root, "pyproject.toml") + m = re.search(r"requires\s*=\s*\[([^\]]+)\]", content) + if not m: + return False + if re.search(r"[><=!~]", m.group(1)): + return True + return "⚠️ Build backend in requires has no version pin (e.g. >=x.y)." diff --git a/src/ansys/pre_commit_hooks/quality_rules/cicd.py b/src/ansys/pre_commit_hooks/quality_rules/cicd.py new file mode 100644 index 00000000..4ab20856 --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/cicd.py @@ -0,0 +1,212 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""CI/CD content checks.""" + +from __future__ import annotations + +import re + +from .common import all_workflows_content, wf_content + +__all__ = [ + "CICD", + "CI004", + "CI005", + "CI006", + "CI007", + "CI008", + "CI009", + "CI010", + "CI011", + "CI012", + "CI013", + "CI014", + "CI015", + "CI016", +] + + +class CICD: + family = "cicd" + + +class CI004(CICD): + "Workflows use concurrency blocks" + + @staticmethod + def check(root, workflow_map: dict) -> bool | None | str: + roles = [("pr", "ci_cd_pr.yml"), ("main", "ci_cd_main.yml")] + present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + if not present: + return None + missing = [ + lbl + for role, lbl in present + if "concurrency:" not in wf_content(root, role, workflow_map)[1] + ] + if not missing: + return True + return f"⚠️ concurrency: block missing in: {', '.join(missing)}" + + +class CI005(CICD): + "Workflows set root `permissions: {}`" + + @staticmethod + def check(root, workflow_map: dict) -> bool | None | str: + roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] + present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + if not present: + return None + missing = [ + lbl + for role, lbl in present + if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M) + ] + return True if not missing else f"Missing root permissions: {{}} in: {', '.join(missing)}" + + +class CI006(CICD): + "checkout uses persist-credentials: false" + + @staticmethod + def check(root, workflow_map: dict) -> bool | None | str: + roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] + present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + if not present: + return None + missing = [ + lbl + for role, lbl in present + if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1] + ] + if not missing: + return True + return f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" + + +class CI007(CICD): + "Labeler job present across workflows" + + @staticmethod + def check(root) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/[^\s]*label|\blabeler\b", content, re.IGNORECASE)) + + +class CI008(CICD): + "ansys/actions/check-vulnerabilities used" + + @staticmethod + def check(root) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return "ansys/actions/check-vulnerabilities" in content + + +class CI009(CICD): + "ansys/actions/code-style used" + + @staticmethod + def check(root) -> bool | None | str: + content = all_workflows_content(root) + if not content: + return None + if "ansys/actions/code-style" in content: + return True + return "⚠️ ansys/actions/code-style not found in any workflow file." + + +class CI010(CICD): + "check-pr-title step present across workflows" + + @staticmethod + def check(root) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool( + re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE) + ) + + +class CI011(CICD): + "changelog-fragment step present across workflows" + + @staticmethod + def check(root) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool( + re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE) + ) + + +class CI012(CICD): + "ansys/actions/check-doc-style used" + + @staticmethod + def check(root) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/check-doc-style|doc-style", content, re.IGNORECASE)) + + +class CI013(CICD): + "ansys/actions/doc-build used" + + @staticmethod + def check(root) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool(re.search(r"ansys/actions/doc-build|\bdoc-build\b", content, re.IGNORECASE)) + + +class CI014(CICD): + "ansys/actions/build-wheelhouse used" + + @staticmethod + def check(root) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool( + re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE) + ) + + +class CI015(CICD): + "ansys/actions/tests-pytest (or pytest) used" + + @staticmethod + def check(root) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool( + re.search( + r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", + content, + re.IGNORECASE, + ) + ) + + +class CI016(CICD): + "update-changelog step present across workflows" + + @staticmethod + def check(root) -> bool | None: + content = all_workflows_content(root) + if not content: + return None + return bool( + re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE) + ) diff --git a/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py b/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py new file mode 100644 index 00000000..6c8597a7 --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py @@ -0,0 +1,47 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""CI/CD workflow file naming checks.""" + +from __future__ import annotations + +from .common import CANONICAL_WF, file_exists, wf_label + +__all__ = ["CICDFiles", "CI001", "CI002", "CI003"] + + +class CICDFiles: + family = "cicd_files" + + +class CI001(CICDFiles): + "ci_cd_main.yml exists" + + @staticmethod + def check(root, workflow_map: dict) -> bool | str: + if file_exists(root, CANONICAL_WF["main"]): + return True + lbl = wf_label("main", workflow_map) + return f"⚠️ Canonical ci_cd_main.yml not found — detected: {lbl}" + + +class CI002(CICDFiles): + "ci_cd_pr.yml exists" + + @staticmethod + def check(root, workflow_map: dict) -> bool | str: + if file_exists(root, CANONICAL_WF["pr"]): + return True + lbl = wf_label("pr", workflow_map) + return f"⚠️ Canonical ci_cd_pr.yml not found — detected: {lbl}" + + +class CI003(CICDFiles): + "ci_cd_release.yml exists" + + @staticmethod + def check(root, workflow_map: dict) -> bool | str: + if file_exists(root, CANONICAL_WF["release"]): + return True + lbl = wf_label("release", workflow_map) + return f"⚠️ Canonical ci_cd_release.yml not found — detected: {lbl}" diff --git a/src/ansys/pre_commit_hooks/quality_rules/common.py b/src/ansys/pre_commit_hooks/quality_rules/common.py new file mode 100644 index 00000000..9c73a03e --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/common.py @@ -0,0 +1,192 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Shared helpers used by the repository quality checks.""" + +from __future__ import annotations + +import re +from typing import Any + +try: + from importlib.resources.abc import Traversable +except ImportError: # pragma: no cover + from importlib.abc import Traversable + +__all__ = [ + "file_exists", + "file_content", + "file_contains", + "CANONICAL_WF", + "all_workflows_content", + "wf_content", + "wf_label", + "workflow_map", + "readme_path", + "is_mcp", + "_first_doc_line", + "_interpret", +] + + +def file_exists(root: Traversable, path: str) -> bool: + try: + return root.joinpath(path).is_file() + except Exception: + return False + + +def file_content(root: Traversable, path: str) -> str: + try: + f = root.joinpath(path) + if f.is_file(): + return f.read_text(encoding="utf-8") + except Exception: + pass + return "" + + +def file_contains(root: Traversable, path: str, pattern: str | re.Pattern) -> bool: + content = file_content(root, path) + if not content: + return False + if isinstance(pattern, str): + return pattern in content + return bool(pattern.search(content)) + + +CANONICAL_WF = { + "main": ".github/workflows/ci_cd_main.yml", + "pr": ".github/workflows/ci_cd_pr.yml", + "release": ".github/workflows/ci_cd_release.yml", +} + + +def all_workflows_content(root: Traversable) -> str: + return _merge_all_workflows(root) + + +def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, str]: + canonical = CANONICAL_WF[role] + if file_exists(root, canonical): + return True, file_content(root, canonical) + + entry = workflow_map.get(role) + if entry and not entry.get("is_fallback"): + path = entry.get("path", "") + return False, file_content(root, path) if path else "" + return False, _merge_all_workflows(root) + + +def _merge_all_workflows(root: Traversable) -> str: + try: + entries = [ + e + for e in root.joinpath(".github/workflows").iterdir() + if e.name.endswith((".yml", ".yaml")) + ] + except Exception: + return "" + parts = [] + for entry in entries: + try: + c = entry.read_text(encoding="utf-8") + if c: + parts.append(c) + except Exception: + pass + return "\n\n".join(parts) + + +def wf_label(role: str, workflow_map: dict) -> str: + entry = workflow_map.get(role) + if not entry: + return CANONICAL_WF.get(role, role) + if entry.get("is_fallback"): + sources = entry.get("sources", []) + return f"{len(sources)} workflow file(s) ({', '.join(sources)})" + return entry.get("name", role) + + +def workflow_map(root: Traversable) -> dict[str, dict]: + wf_dir = root.joinpath(".github/workflows") + try: + entries = [e for e in wf_dir.iterdir() if e.name.endswith((".yml", ".yaml"))] + except Exception: + entries = [] + + result: dict[str, dict] = {} + for entry in entries: + role = _classify_workflow(entry.name) + if role != "unknown" and role not in result: + result[role] = { + "name": entry.name, + "path": f".github/workflows/{entry.name}", + "is_fallback": False, + "sources": [entry.name], + } + + for role in ("main", "pr", "release"): + if role not in result and entries: + result[role] = { + "name": f"{len(entries)} workflow(s)", + "path": None, + "is_fallback": True, + "sources": [e.name for e in entries], + } + + return result + + +def _classify_workflow(name: str) -> str: + n = name.lower() + if re.search(r"release|publish|deploy", n): + return "release" + if re.search(r"\bpr\b|pull.?request|pull_request", n): + return "pr" + if re.search(r"main|push|branch|nightly|schedule|ci_cd_main|ci.main", n): + return "main" + if re.search(r"\bci\b|build|test", n): + return "pr" + return "unknown" + + +def readme_path(root: Traversable) -> str | None: + if file_exists(root, "README.rst"): + return "README.rst" + if file_exists(root, "README.md"): + return "README.md" + return None + + +def is_mcp(root: Traversable) -> bool: + try: + pyproject_text = root.joinpath("pyproject.toml").read_text() + return bool(re.search(r"\b(fastmcp|mcp)\b", pyproject_text, re.IGNORECASE)) + except Exception: + pass + try: + return file_exists(root, "src/server.py") or file_exists(root, "server.py") + except Exception: + return False + + +def _first_doc_line(obj: Any) -> str: + doc = (obj.check.__doc__ or "").strip() + lines = [line.strip() for line in doc.splitlines() if line.strip()] + return lines[0] if lines else "" + + +def _interpret(raw: bool | str | None, check_obj: Any) -> tuple[str, str]: + if raw is True: + return "pass", "" + if raw is None: + return "na", "" + if isinstance(raw, str) and raw.startswith("⚠️ "): + return "warn", raw.removeprefix("⚠️ ") + if raw is False: + doc = (check_obj.check.__doc__ or "").strip() + lines = [line.strip() for line in doc.splitlines() if line.strip()] + detail = lines[-1] if len(lines) > 1 else (lines[0] if lines else "") + return "fail", detail + return "fail", str(raw) diff --git a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py new file mode 100644 index 00000000..b84cb12e --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py @@ -0,0 +1,153 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Dependabot checks.""" + +from __future__ import annotations + +import re + +from .common import file_contains, file_content, file_exists + +__all__ = [ + "Dependabot", + "DB001", + "DB002", + "DB003", + "DB004", + "DB005", + "DB006", + "DB007", + "DB008", +] + + +_PATH_DEPENDABOT = ".github/dependabot.yml" + + +class Dependabot: + family = "dependabot" + + +class DB001(Dependabot): + ".github/dependabot.yml exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, _PATH_DEPENDABOT) + + +class DB002(Dependabot): + "dependabot.yml sets version: 2" + + requires = {"DB001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, _PATH_DEPENDABOT): + return None + return file_contains(root, _PATH_DEPENDABOT, re.compile(r"^version:\s*2\s*$", re.M)) + + +class DB003(Dependabot): + "pip or uv ecosystem configured" + + requires = {"DB001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) + if has_pip: + return True + if has_uv: + return "⚠️ uv ecosystem configured (pip preferred for PyAnsys standard)." + return False + + +class DB004(Dependabot): + "github-actions ecosystem configured" + + requires = {"DB001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, _PATH_DEPENDABOT): + return None + return file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?") + ) + + +class DB005(Dependabot): + "Weekly update interval set" + + requires = {"DB001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + content = file_content(root, _PATH_DEPENDABOT) + count = len(re.findall(r"interval:\s*[\"']?weekly[\"']?", content)) + if count >= 2: + return True + return f"⚠️ Only {count} ecosystem(s) use weekly interval (expected ≥2)." + + +class DB006(Dependabot): + "Cooldown default-days: 7 configured" + + requires = {"DB001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + if file_contains(root, _PATH_DEPENDABOT, re.compile(r"default-days:\s*7")): + return True + return "⚠️ Cooldown default-days: 7 not found in dependabot.yml." + + +class DB007(Dependabot): + "pip uses versioning-strategy: lockfile-only" + + requires = {"DB001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + has_uv = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + ) + has_pip = file_contains( + root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + ) + if has_uv and not has_pip: + return None + if file_contains( + root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?") + ): + return True + return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." + + +class DB008(Dependabot): + "pip groups all dependencies together" + + requires = {"DB001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, _PATH_DEPENDABOT): + return None + if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): + return True + return '⚠️ pip groups wildcard pattern "- "*"" not found in dependabot.yml.' diff --git a/src/ansys/pre_commit_hooks/quality_rules/documentation.py b/src/ansys/pre_commit_hooks/quality_rules/documentation.py new file mode 100644 index 00000000..07389246 --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/documentation.py @@ -0,0 +1,96 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Documentation checks.""" + +from __future__ import annotations + +import re + +from .common import file_contains, file_exists + +__all__ = ["Documentation", "DOC001", "DOC002", "DOC003", "DOC004", "DOC005", "DOC006", "DOC007"] + + +class Documentation: + family = "documentation" + + +class DOC001(Documentation): + "doc/source/ structure exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, "doc/source/index.rst") + + +class DOC002(Documentation): + "conf.py exists" + + requires = {"DOC001"} + + @staticmethod + def check(root) -> bool: + return file_exists(root, "doc/source/conf.py") + + +class DOC003(Documentation): + "conf.py includes numpydoc" + + requires = {"DOC002"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, "doc/source/conf.py"): + return None + return file_contains(root, "doc/source/conf.py", "numpydoc") + + +class DOC004(Documentation): + "conf.py includes sphinx_design" + + requires = {"DOC002"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, "doc/source/conf.py"): + return None + return file_contains(root, "doc/source/conf.py", "sphinx_design") + + +class DOC005(Documentation): + "conf.py includes intersphinx" + + requires = {"DOC002"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, "doc/source/conf.py"): + return None + return file_contains(root, "doc/source/conf.py", "intersphinx") + + +class DOC006(Documentation): + "index.rst has Getting started section" + + requires = {"DOC001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, "doc/source/index.rst"): + return None + return file_contains(root, "doc/source/index.rst", re.compile(r"getting.started", re.I)) + + +class DOC007(Documentation): + "index.rst has API reference section" + + requires = {"DOC001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, "doc/source/index.rst"): + return None + return file_contains( + root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I) + ) diff --git a/src/ansys/pre_commit_hooks/quality_rules/labeler.py b/src/ansys/pre_commit_hooks/quality_rules/labeler.py new file mode 100644 index 00000000..cd8c84f6 --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/labeler.py @@ -0,0 +1,66 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Labeler checks.""" + +from __future__ import annotations + +from .common import file_contains, file_exists + +__all__ = ["Labeler", "LB001", "LB002", "LB003", "LB004", "LB005"] + + +class Labeler: + family = "labeler" + + +class LB001(Labeler): + ".github/labeler.yml exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, ".github/labeler.yml") + + +class LB002(Labeler): + ".github/labels.yml exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, ".github/labels.yml") + + +class LB003(Labeler): + "labels.yml has 'bug' label" + + requires = {"LB002"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".github/labels.yml"): + return None + return file_contains(root, ".github/labels.yml", "bug") + + +class LB004(Labeler): + "labels.yml has 'enhancement' label" + + requires = {"LB002"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".github/labels.yml"): + return None + return file_contains(root, ".github/labels.yml", "enhancement") + + +class LB005(Labeler): + "labels.yml has 'documentation' label" + + requires = {"LB002"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".github/labels.yml"): + return None + return file_contains(root, ".github/labels.yml", "documentation") diff --git a/src/ansys/pre_commit_hooks/quality_rules/mcp.py b/src/ansys/pre_commit_hooks/quality_rules/mcp.py new file mode 100644 index 00000000..829395e4 --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/mcp.py @@ -0,0 +1,122 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""MCP release readiness checks.""" + +from __future__ import annotations + +import re + +from .common import file_contains, file_exists, wf_content + +__all__ = ["MCP", "MCP001", "MCP002", "MCP003", "MCP004", "MCP005", "MCP006", "MCP007"] + + +class MCP: + family = "mcp" + + +class MCP001(MCP): + "Core governance files all present" + + @staticmethod + def check(root, is_mcp: bool) -> bool | None: + if not is_mcp: + return None + required = [ + "LICENSE", + "SECURITY.md", + "CONTRIBUTING.md", + "CHANGELOG.md", + ".github/CODEOWNERS", + ] + missing = [p for p in required if not file_exists(root, p)] + return True if not missing else f"Missing: {', '.join(missing)}" + + +class MCP002(MCP): + "CI/CD workflows all present" + + @staticmethod + def check(root, is_mcp: bool) -> bool | None: + if not is_mcp: + return None + workflows = [ + ".github/workflows/ci_cd_main.yml", + ".github/workflows/ci_cd_pr.yml", + ".github/workflows/ci_cd_release.yml", + ] + missing = [p for p in workflows if not file_exists(root, p)] + return True if not missing else f"Missing: {', '.join(missing)}" + + +class MCP003(MCP): + "tests job wired in PR workflow" + + @staticmethod + def check(root, is_mcp: bool, workflow_map: dict) -> bool | None: + if not is_mcp: + return None + _, content = wf_content(root, "pr", workflow_map) + if not content: + return None + return bool(re.search(r"tests|pytest", content, re.I)) + + +class MCP004(MCP): + "doc-build job present in PR workflow" + + @staticmethod + def check(root, is_mcp: bool, workflow_map: dict) -> bool | None: + if not is_mcp: + return None + _, content = wf_content(root, "pr", workflow_map) + if not content: + return None + return "doc-build" in content + + +class MCP005(MCP): + "README and docs metadata aligned" + + @staticmethod + def check(root, is_mcp: bool, readme_path: str | None) -> bool | None | str: + if not is_mcp: + return None + if not file_exists(root, "pyproject.toml"): + return None + if not readme_path: + return False + filename = readme_path.split("/")[-1] + if not file_contains(root, "pyproject.toml", filename): + return f"pyproject.toml does not reference {filename} as readme." + if readme_path == "README.md": + return "⚠️ README.md found — PyAnsys preferred format is README.rst." + return True + + +class MCP006(MCP): + "No TODO/FIXME in doc/source/index.rst" + + @staticmethod + def check(root, is_mcp: bool) -> bool | None: + if not is_mcp: + return None + if not file_exists(root, "doc/source/index.rst"): + return None + return not file_contains(root, "doc/source/index.rst", re.compile(r"TODO|FIXME")) + + +class MCP007(MCP): + "Security checks not bypassed" + + @staticmethod + def check(root, is_mcp: bool, workflow_map: dict) -> bool | None: + if not is_mcp: + return None + _, pr_content = wf_content(root, "pr", workflow_map) + _, rel_content = wf_content(root, "release", workflow_map) + combined = pr_content + rel_content + if not combined.strip(): + return None + return not bool(re.search(r"--no-verify|skip.*security|disable.*scan", combined, re.I)) diff --git a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py new file mode 100644 index 00000000..0e9edeaa --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py @@ -0,0 +1,156 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Pre-commit configuration checks.""" + +from __future__ import annotations + +import re + +from .common import file_contains, file_exists + +__all__ = [ + "PreCommit", + "PC001", + "PC002", + "PC003", + "PC004", + "PC005", + "PC006", + "PC007", + "PC008", + "PC009", + "PC010", +] + + +class PreCommit: + family = "pre_commit" + + +class PC001(PreCommit): + ".pre-commit-config.yaml exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, ".pre-commit-config.yaml") + + +class PC002(PreCommit): + "ruff-pre-commit configured" + + requires = {"PC001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "ruff-pre-commit") + + +class PC003(PreCommit): + "zizmor configured with --pedantic" + + requires = {"PC001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + has_zizmor = file_contains(root, ".pre-commit-config.yaml", "zizmor") + has_pedantic = file_contains(root, ".pre-commit-config.yaml", "--pedantic") + if not has_zizmor: + return False + if not has_pedantic: + return "⚠️ zizmor found but --pedantic flag not set." + return True + + +class PC004(PreCommit): + "blacken-docs configured" + + requires = {"PC001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "blacken-docs") + + +class PC005(PreCommit): + "codespell configured" + + requires = {"PC001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "codespell") + + +class PC006(PreCommit): + "ansys/pre-commit-hooks configured" + + requires = {"PC001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "ansys/pre-commit-hooks") + + +class PC007(PreCommit): + "google/yamlfmt configured" + + requires = {"PC001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "yamlfmt") + + +class PC008(PreCommit): + "pyright configured" + + requires = {"PC001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "pyright") + + +class PC009(PreCommit): + "autofix_prs: true enabled" + + requires = {"PC001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + if file_contains(root, ".pre-commit-config.yaml", "autofix_prs: true"): + return True + return "⚠️ autofix_prs: true not set in ci: block." + + +class PC010(PreCommit): + "autoupdate_schedule: weekly" + + requires = {"PC001"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + if file_contains( + root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly") + ): + return True + return "⚠️ autoupdate_schedule: weekly not found." diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py new file mode 100644 index 00000000..fa366cde --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -0,0 +1,151 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Project metadata checks.""" + +from __future__ import annotations + +import re + +from .common import file_contains, file_exists, file_content + +__all__ = [ + "ProjectMetadata", + "PM001", + "PM002", + "PM003", + "PM004", + "PM005", + "PM006", + "PM007", + "PM008", + "PM009", + "PM010", + "PM011", +] + + +class ProjectMetadata: + family = "project_metadata" + + +class PM001(ProjectMetadata): + "AUTHORS exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, "AUTHORS") + + +class PM002(ProjectMetadata): + "CHANGELOG.md exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, "CHANGELOG.md") + + +class PM003(ProjectMetadata): + "CODE_OF_CONDUCT.md exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, "CODE_OF_CONDUCT.md") + + +class PM004(ProjectMetadata): + "CONTRIBUTING.md exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, "CONTRIBUTING.md") + + +class PM005(ProjectMetadata): + "CONTRIBUTORS.md exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, "CONTRIBUTORS.md") + + +class PM006(ProjectMetadata): + "LICENSE exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, "LICENSE") + + +class PM007(ProjectMetadata): + "README exists (.rst preferred)" + + @staticmethod + def check(root, readme_path: str | None) -> bool | str: + if readme_path is None: + return False + if readme_path == "README.md": + return "⚠️ README.md found — README.rst is the preferred format." + return True + + +class PM008(ProjectMetadata): + "SECURITY.md exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, "SECURITY.md") + + +class PM009(ProjectMetadata): + ".github/CODEOWNERS exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, ".github/CODEOWNERS") + + +class PM010(ProjectMetadata): + "pyproject.toml references README file" + + requires = {"PM007"} + + @staticmethod + def check(root, readme_path: str | None) -> bool | None | str: + if not file_exists(root, "pyproject.toml"): + return None + content = file_content(root, "pyproject.toml") + if re.search(r"poetry\.core|poetry-core", content): + m = re.search( + r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", + content, + re.M, + ) + if m: + return True + return False + rm = readme_path or "README.rst" + if rm in content: + return True + if "README" in content: + return "⚠️ readme key found but exact README filename not confirmed." + return False + + +class PM011(ProjectMetadata): + "pyproject.toml references LICENSE file" + + requires = {"PM006"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, "pyproject.toml"): + return None + c = file_content(root, "pyproject.toml") + if re.search(r"poetry\.core|poetry-core", c): + return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", c, re.M)) + return bool( + re.search(r"license-files\s*=", c) + or re.search(r"license\s*=\s*\{[^}]*file", c) + or re.search(r'license\s*=\s*["\']LICENSE["\']', c) + ) diff --git a/src/ansys/pre_commit_hooks/quality_rules/readme.py b/src/ansys/pre_commit_hooks/quality_rules/readme.py new file mode 100644 index 00000000..9b41b0cc --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/readme.py @@ -0,0 +1,171 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""README checks.""" + +from __future__ import annotations + +import re + +from .common import file_contains + +__all__ = [ + "README", + "RM000", + "RM001", + "RM002", + "RM003", + "RM004", + "RM005", + "RM006", + "RM007", + "RM008", +] + + +class README: + family = "readme" + + +class RM000(README): + "README file exists" + + @staticmethod + def check(root, readme_path: str | None) -> bool | str: + if readme_path == "README.rst": + return True + if readme_path == "README.md": + return "⚠️ README.md found — PyAnsys preferred format is README.rst." + return False + + +class RM001(README): + "README has PyAnsys badge" + + requires = {"RM000"} + + @staticmethod + def check(root, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains( + root, + readme_path, + re.compile( + r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", + re.I, + ), + ): + return True + return f"⚠️ PyAnsys badge image not found in {readme_path}." + + +class RM002(README): + "README has PyPI badge" + + requires = {"RM000"} + + @staticmethod + def check(root, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains( + root, + readme_path, + re.compile( + r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", + re.I, + ), + ): + return True + return f"⚠️ PyPI badge image not found in {readme_path}." + + +class RM003(README): + "README has Codecov badge" + + requires = {"RM000"} + + @staticmethod + def check(root, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains( + root, + readme_path, + re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I), + ): + return True + return f"⚠️ Codecov badge image not found in {readme_path}." + + +class RM004(README): + "README has MIT license badge" + + requires = {"RM000"} + + @staticmethod + def check(root, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains( + root, + readme_path, + re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I), + ): + return True + return f"⚠️ MIT license badge image not found in {readme_path}." + + +class RM005(README): + "README has GH-CI badge" + + requires = {"RM000"} + + @staticmethod + def check(root, readme_path: str | None) -> bool | None | str: + if not readme_path: + return None + if file_contains( + root, + readme_path, + re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I), + ): + return True + return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." + + +class RM006(README): + "README has Installation section" + + requires = {"RM000"} + + @staticmethod + def check(root, readme_path: str | None) -> bool | None: + if not readme_path: + return None + return file_contains(root, readme_path, re.compile(r"install", re.I)) + + +class RM007(README): + "README has Documentation section" + + requires = {"RM000"} + + @staticmethod + def check(root, readme_path: str | None) -> bool | None: + if not readme_path: + return None + return file_contains(root, readme_path, re.compile(r"documentation", re.I)) + + +class RM008(README): + "README has License section" + + requires = {"RM000"} + + @staticmethod + def check(root, readme_path: str | None) -> bool | None: + if not readme_path: + return None + return file_contains(root, readme_path, re.compile(r"license", re.I)) diff --git a/src/ansys/pre_commit_hooks/quality_rules/security.py b/src/ansys/pre_commit_hooks/quality_rules/security.py new file mode 100644 index 00000000..e033368a --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/security.py @@ -0,0 +1,75 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Security checks.""" + +from __future__ import annotations + +import re + +from .common import file_contains, file_exists, wf_content + +__all__ = ["Security", "SEC001", "SEC002", "SEC003", "SEC004", "SEC005"] + + +class Security: + family = "security" + + +class SEC001(Security): + ".github/zizmor.yml exists" + + @staticmethod + def check(root) -> bool | str: + if file_exists(root, ".github/zizmor.yml"): + return True + return "⚠️ .github/zizmor.yml not found — optional but recommended." + + +class SEC002(Security): + "zizmor.yml has secrets-outside-env rule" + + requires = {"SEC001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".github/zizmor.yml"): + return None + return file_contains(root, ".github/zizmor.yml", "secrets-outside-env") + + +class SEC003(Security): + "gitleaks hook configured" + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, ".pre-commit-config.yaml"): + return None + return file_contains(root, ".pre-commit-config.yaml", "gitleaks") + + +class SEC004(Security): + "Workflows pin action SHAs" + + @staticmethod + def check(root, workflow_map: dict) -> bool | None | str: + _, content = wf_content(root, "pr", workflow_map) + if not content: + return None + if re.search(r"uses:\s*\S+@[0-9a-f]{40}", content, re.I): + return True + return "⚠️ No SHA-pinned actions detected in PR workflow. Use full commit SHAs." + + +class SEC005(Security): + "SECURITY.md discourages public issue reporting" + + requires = {"PM008"} + + @staticmethod + def check(root) -> bool | None | str: + if not file_exists(root, "SECURITY.md"): + return None + if file_contains(root, "SECURITY.md", re.compile(r"do not|don't|please don", re.I)): + return True + return "⚠️ SECURITY.md may not clearly discourage public issue reporting." diff --git a/src/ansys/pre_commit_hooks/quality_rules/vale.py b/src/ansys/pre_commit_hooks/quality_rules/vale.py new file mode 100644 index 00000000..456fb314 --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/vale.py @@ -0,0 +1,66 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Vale checks.""" + +from __future__ import annotations + +from .common import file_contains, file_exists + +__all__ = ["Vale", "VL001", "VL002", "VL003", "VL004", "VL005"] + + +class Vale: + family = "vale" + + +class VL001(Vale): + "doc/.vale.ini exists" + + @staticmethod + def check(root) -> bool: + return file_exists(root, "doc/.vale.ini") + + +class VL002(Vale): + "Vale uses Google style package" + + requires = {"VL001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, "doc/.vale.ini"): + return None + return file_contains(root, "doc/.vale.ini", "Google") + + +class VL003(Vale): + "Vale uses ANSYS vocabulary" + + requires = {"VL001"} + + @staticmethod + def check(root) -> bool | None: + if not file_exists(root, "doc/.vale.ini"): + return None + return file_contains(root, "doc/.vale.ini", "ANSYS") + + +class VL004(Vale): + "ANSYS accept.txt vocabulary exists" + + requires = {"VL001"} + + @staticmethod + def check(root) -> bool: + return file_exists(root, "doc/styles/config/vocabularies/ANSYS/accept.txt") + + +class VL005(Vale): + "ANSYS reject.txt vocabulary exists" + + requires = {"VL001"} + + @staticmethod + def check(root) -> bool: + return file_exists(root, "doc/styles/config/vocabularies/ANSYS/reject.txt") diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 647f0d23..f18b5e8e 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -85,3 +85,13 @@ def class_names(path: Path) -> set[str]: actual = class_names(Path(hook.__file__)) assert expected == actual - {"MemoryTraversable"} + + +def test_quality_rules_are_grouped_package(): + """Quality rules should be exposed from a package with one module per check family.""" + import ansys.pre_commit_hooks.quality_rules as quality_rules + import ansys.pre_commit_hooks.quality_rules.project_metadata as project_metadata + + assert hasattr(quality_rules, "PM001") + assert hasattr(project_metadata, "PM001") + assert callable(quality_rules.repo_review_checks) From 9ef11ee81ea062c1d00c0adab2367d1f731b56d3 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 13:53:51 +0530 Subject: [PATCH 09/49] fix: add subpackage --- .../quality_rules/__init__.py | 974 +----------------- 1 file changed, 43 insertions(+), 931 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index a3322faf..7cafbdc6 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -6,7 +6,22 @@ from __future__ import annotations from .build_system import BS001, BS002, BS003, BS004, BuildSystem -from .cicd import CI004, CI005, CI006, CI007, CI008, CI009, CI010, CI011, CI012, CI013, CI014, CI015, CI016, CICD +from .cicd import ( + CI004, + CI005, + CI006, + CI007, + CI008, + CI009, + CI010, + CI011, + CI012, + CI013, + CI014, + CI015, + CI016, + CICD, +) from .cicd_files import CI001, CI002, CI003, CICDFiles from .common import ( CANONICAL_WF, @@ -26,8 +41,33 @@ from .documentation import DOC001, DOC002, DOC003, DOC004, DOC005, DOC006, DOC007, Documentation from .labeler import LB001, LB002, LB003, LB004, LB005, Labeler from .mcp import MCP001, MCP002, MCP003, MCP004, MCP005, MCP006, MCP007, MCP -from .pre_commit import PC001, PC002, PC003, PC004, PC005, PC006, PC007, PC008, PC009, PC010, PreCommit -from .project_metadata import PM001, PM002, PM003, PM004, PM005, PM006, PM007, PM008, PM009, PM010, PM011, ProjectMetadata +from .pre_commit import ( + PC001, + PC002, + PC003, + PC004, + PC005, + PC006, + PC007, + PC008, + PC009, + PC010, + PreCommit, +) +from .project_metadata import ( + PM001, + PM002, + PM003, + PM004, + PM005, + PM006, + PM007, + PM008, + PM009, + PM010, + PM011, + ProjectMetadata, +) from .readme import RM000, RM001, RM002, RM003, RM004, RM005, RM006, RM007, RM008, README from .security import SEC001, SEC002, SEC003, SEC004, SEC005, Security from .vale import VL001, VL002, VL003, VL004, VL005, Vale @@ -186,931 +226,3 @@ def repo_review_checks() -> dict: for cls in family.__subclasses__(): result[cls.__name__] = cls() return result - - -class Dependabot: - family = "dependabot" - - -_PATH_DEPENDABOT = ".github/dependabot.yml" - - -class DB001(Dependabot): - ".github/dependabot.yml exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, _PATH_DEPENDABOT) - - -class DB002(Dependabot): - "dependabot.yml sets version: 2" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, _PATH_DEPENDABOT): - return None - return file_contains(root, _PATH_DEPENDABOT, re.compile(r"^version:\s*2\s*$", re.M)) - - -class DB003(Dependabot): - "pip or uv ecosystem configured" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - has_pip = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") - ) - has_uv = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") - ) - if has_pip: - return True - if has_uv: - return "⚠️ uv ecosystem configured (pip preferred for PyAnsys standard)." - return False - - -class DB004(Dependabot): - "github-actions ecosystem configured" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, _PATH_DEPENDABOT): - return None - return file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?") - ) - - -class DB005(Dependabot): - "Weekly update interval set" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - content = file_content(root, _PATH_DEPENDABOT) - count = len(re.findall(r"interval:\s*[\"']?weekly[\"']?", content)) - if count >= 2: - return True - return f"⚠️ Only {count} ecosystem(s) use weekly interval (expected ≥2)." - - -class DB006(Dependabot): - "Cooldown default-days: 7 configured" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r"default-days:\s*7")): - return True - return "⚠️ Cooldown default-days: 7 not found in dependabot.yml." - - -class DB007(Dependabot): - "pip uses versioning-strategy: lockfile-only" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - has_uv = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") - ) - has_pip = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") - ) - if has_uv and not has_pip: - return None - if file_contains( - root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?") - ): - return True - return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." - - -class DB008(Dependabot): - "pip groups all dependencies together" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): - return True - return '⚠️ pip groups wildcard pattern "- "*"" not found in dependabot.yml.' - - -class Documentation: - family = "documentation" - - -class DOC001(Documentation): - "doc/source/ structure exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "doc/source/index.rst") - - -class DOC002(Documentation): - "conf.py exists" - - requires = {"DOC001"} - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "doc/source/conf.py") - - -class DOC003(Documentation): - "conf.py includes numpydoc" - - requires = {"DOC002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/conf.py"): - return None - return file_contains(root, "doc/source/conf.py", "numpydoc") - - -class DOC004(Documentation): - "conf.py includes sphinx_design" - - requires = {"DOC002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/conf.py"): - return None - return file_contains(root, "doc/source/conf.py", "sphinx_design") - - -class DOC005(Documentation): - "conf.py includes intersphinx" - - requires = {"DOC002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/conf.py"): - return None - return file_contains(root, "doc/source/conf.py", "intersphinx") - - -class DOC006(Documentation): - "index.rst has Getting started section" - - requires = {"DOC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/index.rst"): - return None - return file_contains(root, "doc/source/index.rst", re.compile(r"getting.started", re.I)) - - -class DOC007(Documentation): - "index.rst has API reference section" - - requires = {"DOC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/index.rst"): - return None - return file_contains( - root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I) - ) - - -class README: - family = "readme" - - -class RM000(README): - "README file exists" - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | str: - if readme_path == "README.rst": - return True - if readme_path == "README.md": - return "⚠️ README.md found — PyAnsys preferred format is README.rst." - return False - - -class RM001(README): - "README has PyAnsys badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile( - r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", - re.I, - ), - ): - return True - return f"⚠️ PyAnsys badge image not found in {readme_path}." - - -class RM002(README): - "README has PyPI badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile( - r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", - re.I, - ), - ): - return True - return f"⚠️ PyPI badge image not found in {readme_path}." - - -class RM003(README): - "README has Codecov badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I), - ): - return True - return f"⚠️ Codecov badge image not found in {readme_path}." - - -class RM004(README): - "README has MIT license badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I), - ): - return True - return f"⚠️ MIT license badge image not found in {readme_path}." - - -class RM005(README): - "README has GH-CI badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I), - ): - return True - return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." - - -class RM006(README): - "README has Installation section" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None: - if not readme_path: - return None - return file_contains(root, readme_path, re.compile(r"install", re.I)) - - -class RM007(README): - "README has Documentation section" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None: - if not readme_path: - return None - return file_contains(root, readme_path, re.compile(r"documentation", re.I)) - - -class RM008(README): - "README has License section" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None: - if not readme_path: - return None - return file_contains(root, readme_path, re.compile(r"license", re.I)) - - -class BuildSystem: - family = "build_system" - - -_BACKENDS = { - "flit_core": "Flit", - "poetry.core": "Poetry", - "hatchling": "Hatch", - "pdm": "PDM", - "maturin": "Maturin", - "setuptools": "Setuptools", -} - - -def _detect_backend(content: str) -> tuple[str, str]: - m = re.search(r'build-backend\s*=\s*["\']([^"\']+)["\']', content) - backend = m.group(1) if m else "" - for pattern, name in _BACKENDS.items(): - if pattern in backend: - return name, pattern.split(".")[0].replace("_core", "") - if "[build-system]" in content: - return "Other", "other" - return "Unknown", "unknown" - - -class BS001(BuildSystem): - "[build-system] table declared" - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "pyproject.toml"): - return None - return file_contains(root, "pyproject.toml", "[build-system]") - - -class BS002(BuildSystem): - "Uses a supported modern build backend" - - requires = {"BS001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, "pyproject.toml"): - return None - name, key = _detect_backend(file_content(root, "pyproject.toml")) - if key == "unknown": - return False - if key == "setuptools": - return ( - f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." - ) - return True - - -class BS003(BuildSystem): - "No legacy setup.py or setup.cfg" - - @staticmethod - def check(root: Traversable) -> bool | str: - has_py = file_exists(root, "setup.py") - has_cfg = file_exists(root, "setup.cfg") - if not has_py and not has_cfg: - return True - found = [f for f, present in [("setup.py", has_py), ("setup.cfg", has_cfg)] if present] - return f"⚠️ Legacy file(s) found: {', '.join(found)}. Remove in favour of pyproject.toml." - - -class BS004(BuildSystem): - "Build backend version pinned in requires" - - requires = {"BS001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, "pyproject.toml"): - return None - content = file_content(root, "pyproject.toml") - m = re.search(r"requires\s*=\s*\[([^\]]+)\]", content) - if not m: - return False - if re.search(r"[><=!~]", m.group(1)): - return True - return "⚠️ Build backend in requires has no version pin (e.g. >=x.y)." - - -class Security: - family = "security" - - -class SEC001(Security): - ".github/zizmor.yml exists" - - @staticmethod - def check(root: Traversable) -> bool | str: - if file_exists(root, ".github/zizmor.yml"): - return True - return "⚠️ .github/zizmor.yml not found — optional but recommended." - - -class SEC002(Security): - "zizmor.yml has secrets-outside-env rule" - - requires = {"SEC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".github/zizmor.yml"): - return None - return file_contains(root, ".github/zizmor.yml", "secrets-outside-env") - - -class SEC003(Security): - "gitleaks hook configured" - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "gitleaks") - - -class SEC004(Security): - "Workflows pin action SHAs" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | None | str: - _, content = wf_content(root, "pr", workflow_map) - if not content: - return None - if re.search(r"uses:\s*\S+@[0-9a-f]{40}", content, re.I): - return True - return "⚠️ No SHA-pinned actions detected in PR workflow. Use full commit SHAs." - - -class SEC005(Security): - "SECURITY.md discourages public issue reporting" - - requires = {"PM008"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, "SECURITY.md"): - return None - if file_contains(root, "SECURITY.md", re.compile(r"do not|don't|please don", re.I)): - return True - return "⚠️ SECURITY.md may not clearly discourage public issue reporting." - - -class Labeler: - family = "labeler" - - -class LB001(Labeler): - ".github/labeler.yml exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, ".github/labeler.yml") - - -class LB002(Labeler): - ".github/labels.yml exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, ".github/labels.yml") - - -class LB003(Labeler): - "labels.yml has 'bug' label" - - requires = {"LB002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".github/labels.yml"): - return None - return file_contains(root, ".github/labels.yml", "bug") - - -class LB004(Labeler): - "labels.yml has 'enhancement' label" - - requires = {"LB002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".github/labels.yml"): - return None - return file_contains(root, ".github/labels.yml", "enhancement") - - -class LB005(Labeler): - "labels.yml has 'documentation' label" - - requires = {"LB002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".github/labels.yml"): - return None - return file_contains(root, ".github/labels.yml", "documentation") - - -class Vale: - family = "vale" - - -_INI = "doc/.vale.ini" - - -class VL001(Vale): - "doc/.vale.ini exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, _INI) - - -class VL002(Vale): - "Vale uses Google style package" - - requires = {"VL001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, _INI): - return None - return file_contains(root, _INI, "Google") - - -class VL003(Vale): - "Vale uses ANSYS vocabulary" - - requires = {"VL001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, _INI): - return None - return file_contains(root, _INI, "ANSYS") - - -class VL004(Vale): - "ANSYS accept.txt vocabulary exists" - - requires = {"VL001"} - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "doc/styles/config/vocabularies/ANSYS/accept.txt") - - -class VL005(Vale): - "ANSYS reject.txt vocabulary exists" - - requires = {"VL001"} - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "doc/styles/config/vocabularies/ANSYS/reject.txt") - - -class MCP: - family = "mcp" - - -class MCP001(MCP): - "Core governance files all present" - - @staticmethod - def check(root: Traversable, is_mcp: bool) -> bool | None: - if not is_mcp: - return None - required = [ - "LICENSE", - "SECURITY.md", - "CONTRIBUTING.md", - "CHANGELOG.md", - ".github/CODEOWNERS", - ] - missing = [p for p in required if not file_exists(root, p)] - return True if not missing else f"Missing: {', '.join(missing)}" - - -class MCP002(MCP): - "CI/CD workflows all present" - - @staticmethod - def check(root: Traversable, is_mcp: bool) -> bool | None: - if not is_mcp: - return None - workflows = [ - ".github/workflows/ci_cd_main.yml", - ".github/workflows/ci_cd_pr.yml", - ".github/workflows/ci_cd_release.yml", - ] - missing = [p for p in workflows if not file_exists(root, p)] - return True if not missing else f"Missing: {', '.join(missing)}" - - -class MCP003(MCP): - "tests job wired in PR workflow" - - @staticmethod - def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: - if not is_mcp: - return None - _, content = wf_content(root, "pr", workflow_map) - if not content: - return None - return bool(re.search(r"tests|pytest", content, re.I)) - - -class MCP004(MCP): - "doc-build job present in PR workflow" - - @staticmethod - def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: - if not is_mcp: - return None - _, content = wf_content(root, "pr", workflow_map) - if not content: - return None - return "doc-build" in content - - -class MCP005(MCP): - "README and docs metadata aligned" - - @staticmethod - def check(root: Traversable, is_mcp: bool, readme_path: str | None) -> bool | None | str: - if not is_mcp: - return None - if not file_exists(root, "pyproject.toml"): - return None - if not readme_path: - return False - filename = readme_path.split("/")[-1] - if not file_contains(root, "pyproject.toml", filename): - return f"pyproject.toml does not reference {filename} as readme." - if readme_path == "README.md": - return "⚠️ README.md found — PyAnsys preferred format is README.rst." - return True - - -class MCP006(MCP): - "No TODO/FIXME in doc/source/index.rst" - - @staticmethod - def check(root: Traversable, is_mcp: bool) -> bool | None: - if not is_mcp: - return None - if not file_exists(root, "doc/source/index.rst"): - return None - return not file_contains(root, "doc/source/index.rst", re.compile(r"TODO|FIXME")) - - -class MCP007(MCP): - "Security checks not bypassed" - - @staticmethod - def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: - if not is_mcp: - return None - _, pr_content = wf_content(root, "pr", workflow_map) - _, rel_content = wf_content(root, "release", workflow_map) - combined = pr_content + rel_content - if not combined.strip(): - return None - return not bool(re.search(r"--no-verify|skip.*security|disable.*scan", combined, re.I)) - - -class PreCommit: - family = "pre_commit" - - -class PC001(PreCommit): - ".pre-commit-config.yaml exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, ".pre-commit-config.yaml") - - -class PC002(PreCommit): - "ruff-pre-commit configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "ruff-pre-commit") - - -class PC003(PreCommit): - "zizmor configured with --pedantic" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - has_zizmor = file_contains(root, ".pre-commit-config.yaml", "zizmor") - has_pedantic = file_contains(root, ".pre-commit-config.yaml", "--pedantic") - if not has_zizmor: - return False - if not has_pedantic: - return "⚠️ zizmor found but --pedantic flag not set." - return True - - -class PC004(PreCommit): - "blacken-docs configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "blacken-docs") - - -class PC005(PreCommit): - "codespell configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "codespell") - - -class PC006(PreCommit): - "ansys/pre-commit-hooks configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "ansys/pre-commit-hooks") - - -class PC007(PreCommit): - "google/yamlfmt configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "yamlfmt") - - -class PC008(PreCommit): - "pyright configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "pyright") - - -class PC009(PreCommit): - "autofix_prs: true enabled" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - if file_contains(root, ".pre-commit-config.yaml", "autofix_prs: true"): - return True - return "⚠️ autofix_prs: true not set in ci: block." - - -class PC010(PreCommit): - "autoupdate_schedule: weekly" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - if file_contains( - root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly") - ): - return True - return "⚠️ autoupdate_schedule: weekly not found." - - -def repo_review_families() -> dict[str, dict]: - return { - "project_metadata": {"name": "Project Metadata", "order": 10}, - "cicd_files": {"name": "CI/CD — Workflow File Names", "order": 20}, - "cicd": {"name": "CI/CD — Content Checks", "order": 25}, - "dependabot": {"name": "Dependabot", "order": 30}, - "pre_commit": {"name": "Pre-commit", "order": 40}, - "documentation": {"name": "Documentation", "order": 50}, - "readme": {"name": "README", "order": 60}, - "build_system": {"name": "Build System", "order": 70}, - "security": {"name": "Security", "order": 80}, - "labeler": {"name": "Labeler", "order": 90}, - "vale": {"name": "Vale", "order": 100}, - "mcp": {"name": "MCP Release Readiness", "order": 110}, - } - - -def repo_review_checks() -> dict: - families = [ - ProjectMetadata, - CICDFiles, - CICD, - Dependabot, - PreCommit, - Documentation, - README, - BuildSystem, - Security, - Labeler, - Vale, - MCP, - ] - result = {} - for family in families: - for cls in family.__subclasses__(): - result[cls.__name__] = cls() - return result - - -def _first_doc_line(obj: Any) -> str: - doc = (obj.check.__doc__ or "").strip() - lines = [line.strip() for line in doc.splitlines() if line.strip()] - return lines[0] if lines else "" - - -def _interpret(raw: bool | str | None, check_obj: Any) -> tuple[str, str]: - if raw is True: - return "pass", "" - if raw is None: - return "na", "" - if isinstance(raw, str) and raw.startswith("⚠️ "): - return "warn", raw.removeprefix("⚠️ ") - if raw is False: - doc = (check_obj.check.__doc__ or "").strip() - lines = [line.strip() for line in doc.splitlines() if line.strip()] - detail = lines[-1] if len(lines) > 1 else (lines[0] if lines else "") - return "fail", detail - return "fail", str(raw) From 36e0c3b4c039879ee115752c3758ca1db03d3b59 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:25:00 +0000 Subject: [PATCH 10/49] chore: auto fixes from pre-commit hooks --- src/ansys/pre_commit_hooks/quality_rules/__init__.py | 4 ++-- src/ansys/pre_commit_hooks/quality_rules/project_metadata.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index 7cafbdc6..a56007ff 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -40,7 +40,7 @@ from .dependabot import DB001, DB002, DB003, DB004, DB005, DB006, DB007, DB008, Dependabot from .documentation import DOC001, DOC002, DOC003, DOC004, DOC005, DOC006, DOC007, Documentation from .labeler import LB001, LB002, LB003, LB004, LB005, Labeler -from .mcp import MCP001, MCP002, MCP003, MCP004, MCP005, MCP006, MCP007, MCP +from .mcp import MCP, MCP001, MCP002, MCP003, MCP004, MCP005, MCP006, MCP007 from .pre_commit import ( PC001, PC002, @@ -68,7 +68,7 @@ PM011, ProjectMetadata, ) -from .readme import RM000, RM001, RM002, RM003, RM004, RM005, RM006, RM007, RM008, README +from .readme import README, RM000, RM001, RM002, RM003, RM004, RM005, RM006, RM007, RM008 from .security import SEC001, SEC002, SEC003, SEC004, SEC005, Security from .vale import VL001, VL002, VL003, VL004, VL005, Vale diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index fa366cde..82e867fd 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -7,7 +7,7 @@ import re -from .common import file_contains, file_exists, file_content +from .common import file_contains, file_content, file_exists __all__ = [ "ProjectMetadata", From 662f1c5e7f91e5679a7874955f0e0c315baa0521 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 18:07:02 +0530 Subject: [PATCH 11/49] fix: merge the old tech review for bootsrap mode --- .../pyansys_quality_report.py | 36 ++++++++++++++++++- src/ansys/pre_commit_hooks/tech_review.py | 4 +-- tests/test_pyansys_quality_report.py | 26 +++++++++++--- tests/test_tech_review.py | 18 ++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index cab2c675..62eae8be 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -13,9 +13,13 @@ import argparse from io import BytesIO, StringIO import json +import os from pathlib import Path +import re from typing import Any, Iterator +from ansys.pre_commit_hooks import tech_review + try: from importlib.resources.abc import Traversable except ImportError: # pragma: no cover @@ -1663,12 +1667,42 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--all", action="store_true", help="Show all checks, including passing ones." ) - args = parser.parse_args(argv) + parser.add_argument( + "--fix-missing", + action="store_true", + help="Generate missing repository scaffolding before running the quality report.", + ) + args, unknown = parser.parse_known_args(argv) repo_root = Path(args.repo_root).resolve() if not repo_root.exists(): raise FileNotFoundError(f"Repo root not found: {repo_root}") + if args.fix_missing: + legacy_argv: list[str] = [] + raw_argv = list(argv) if argv is not None else list(__import__("sys").argv[1:]) + + idx = 0 + while idx < len(raw_argv): + token = raw_argv[idx] + if token in {"--repo-root", "--json", "--all", "--fix-missing"}: + idx += 1 + if token == "--repo-root" and idx < len(raw_argv): + idx += 1 + continue + legacy_argv.append(token) + idx += 1 + + if unknown: + legacy_argv.extend(unknown) + + current_dir = Path.cwd() + os.chdir(repo_root) + try: + return tech_review.main(legacy_argv) + finally: + os.chdir(current_dir) + files = _load_files(repo_root) review = _run_checks(files, is_mcp_flag=is_mcp(MemoryTraversable(files))) diff --git a/src/ansys/pre_commit_hooks/tech_review.py b/src/ansys/pre_commit_hooks/tech_review.py index cd9e6238..7f159198 100644 --- a/src/ansys/pre_commit_hooks/tech_review.py +++ b/src/ansys/pre_commit_hooks/tech_review.py @@ -649,7 +649,7 @@ def check_file_content(file: str, generated_content: str, is_compliant: bool, li return is_compliant -def main(): +def main(argv: list[str] | None = None): """Check files for technical review.""" parser = argparse.ArgumentParser() # Get the name of the authors and maintainers of the project @@ -685,7 +685,7 @@ def main(): parser.add_argument("--non_compliant_name", action="store_true") # Parse arguments - args = parser.parse_args() + args = parser.parse_args(argv) author_maint_name = args.author_maint_name author_maint_email = args.author_maint_email non_compliant_name = args.non_compliant_name diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index f18b5e8e..41ea1ce6 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -68,23 +68,39 @@ def test_main_colors_status_labels(tmp_path, capsys): def test_hook_covers_all_repo_review_checks(): - """The standalone hook must include the full repo-review rule set.""" + """The package-level rule registry should expose the complete local check set.""" - repo_root = Path(__file__).resolve().parents[3] - checks_dir = repo_root / "src" / "pyansys_review" / "checks" + import ansys.pre_commit_hooks.quality_rules as quality_rules + + checks_dir = Path(quality_rules.__file__).resolve().parent def class_names(path: Path) -> set[str]: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) return {node.name for node in tree.body if isinstance(node, ast.ClassDef)} + family_names = { + "ProjectMetadata", + "CICDFiles", + "CICD", + "Dependabot", + "Documentation", + "README", + "BuildSystem", + "Security", + "Labeler", + "Vale", + "MCP", + "PreCommit", + } + expected = set() for path in checks_dir.glob("*.py"): if path.name == "__init__.py": continue expected |= class_names(path) - actual = class_names(Path(hook.__file__)) - assert expected == actual - {"MemoryTraversable"} + actual = set(quality_rules.repo_review_checks()) + assert expected - family_names == actual def test_quality_rules_are_grouped_package(): diff --git a/tests/test_tech_review.py b/tests/test_tech_review.py index 97afc65c..7e449686 100644 --- a/tests/test_tech_review.py +++ b/tests/test_tech_review.py @@ -31,6 +31,7 @@ import pytest from ansys.pre_commit_hooks.add_license_headers import check_same_content +import ansys.pre_commit_hooks.pyansys_quality_report as quality_hook import ansys.pre_commit_hooks.tech_review as hook git_repo = git.Repo(os.getcwd(), search_parent_directories=True) @@ -112,6 +113,23 @@ def test_pyproject_toml(tmp_path: pytest.TempPathFactory): os.chdir(REPO_PATH) +@pytest.mark.tech_review +def test_fix_missing_mode_bootstraps_repo_files(tmp_path: pytest.TempPathFactory): + """The quality report should support the old fix-missing bootstrap mode.""" + tmp_path = tmp_path / "pytechreview" + setup_repo(tmp_path) + os.chdir(tmp_path) + + exit_code = quality_hook.main(["--repo-root", str(tmp_path), "--fix-missing", "--product=techreview"]) + + assert exit_code == 1 + assert pathlib.Path.exists(tmp_path / ".github") + assert pathlib.Path.exists(tmp_path / "CODE_OF_CONDUCT.md") + assert pathlib.Path.exists(tmp_path / ".github" / "dependabot.yml") + + os.chdir(REPO_PATH) + + @pytest.mark.tech_review def test_setup_py(tmp_path: pytest.TempPathFactory): """Test setup.py file is not implemented and some files are generated.""" From 2ab095d849339df911efa50346073da024b9cfbb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:37:27 +0000 Subject: [PATCH 12/49] chore: auto fixes from pre-commit hooks --- tests/test_tech_review.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_tech_review.py b/tests/test_tech_review.py index 7e449686..e231f83f 100644 --- a/tests/test_tech_review.py +++ b/tests/test_tech_review.py @@ -120,7 +120,9 @@ def test_fix_missing_mode_bootstraps_repo_files(tmp_path: pytest.TempPathFactory setup_repo(tmp_path) os.chdir(tmp_path) - exit_code = quality_hook.main(["--repo-root", str(tmp_path), "--fix-missing", "--product=techreview"]) + exit_code = quality_hook.main( + ["--repo-root", str(tmp_path), "--fix-missing", "--product=techreview"] + ) assert exit_code == 1 assert pathlib.Path.exists(tmp_path / ".github") From 29fd50c813118f1d45d5ef6ce3a6b6a72fee1d06 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 18:33:02 +0530 Subject: [PATCH 13/49] fix: merge the old tech review for bootsrap mode --- .../pyansys_quality_report.py | 1503 +---------------- .../quality_rules/__init__.py | 2 + .../quality_rules/build_system.py | 33 +- .../pre_commit_hooks/quality_rules/cicd.py | 60 +- .../quality_rules/cicd_files.py | 30 +- .../pre_commit_hooks/quality_rules/common.py | 31 + .../quality_rules/dependabot.py | 45 +- .../quality_rules/documentation.py | 42 +- .../pre_commit_hooks/quality_rules/labeler.py | 36 +- .../pre_commit_hooks/quality_rules/mcp.py | 42 +- .../quality_rules/pre_commit.py | 51 +- .../quality_rules/project_metadata.py | 59 +- .../pre_commit_hooks/quality_rules/readme.py | 52 +- .../quality_rules/security.py | 38 +- .../pre_commit_hooks/quality_rules/vale.py | 38 +- tests/test_tech_review.py | 4 +- 16 files changed, 552 insertions(+), 1514 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 62eae8be..5f443a33 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -11,12 +11,12 @@ from __future__ import annotations import argparse +from collections.abc import Iterator from io import BytesIO, StringIO import json import os from pathlib import Path -import re -from typing import Any, Iterator +from typing import Any from ansys.pre_commit_hooks import tech_review @@ -69,23 +69,28 @@ class MemoryTraversable(Traversable): """In-memory Traversable backed by a flat dict mapping path -> content.""" def __init__(self, files: dict[str, str | None], path: str = "") -> None: + """Initialize a virtual Traversable with a dict of file contents.""" self._files = files self._path = path.strip("/") @property def name(self) -> str: + """Return the final path component for this virtual file or directory.""" return self._path.split("/")[-1] if self._path else "" def is_file(self) -> bool: + """Return whether this virtual path resolves to a file.""" return self._path in self._files and self._files[self._path] is not None def is_dir(self) -> bool: + """Return whether this virtual path resolves to a directory.""" if not self._path: return True prefix = self._path + "/" return any(key.startswith(prefix) for key in self._files) - def iterdir(self) -> Iterator["MemoryTraversable"]: + def iterdir(self) -> Iterator[MemoryTraversable]: + """Yield child paths for this virtual directory.""" prefix = (self._path + "/") if self._path else "" seen: set[str] = set() for key in self._files: @@ -97,13 +102,15 @@ def iterdir(self) -> Iterator["MemoryTraversable"]: seen.add(child_name) yield MemoryTraversable(self._files, f"{prefix}{child_name}") - def joinpath(self, *parts: str) -> "MemoryTraversable": + def joinpath(self, *parts: str) -> MemoryTraversable: + """Join path components under this virtual root.""" combined = "/".join(filter(None, [self._path, *parts])) return MemoryTraversable(self._files, combined) __truediv__ = joinpath def open(self, mode: str = "r", encoding: str = "utf-8", **_) -> StringIO | BytesIO: + """Open the virtual file as a text or binary stream.""" content = self._files.get(self._path) if content is None: raise FileNotFoundError(self._path) @@ -112,1438 +119,27 @@ def open(self, mode: str = "r", encoding: str = "utf-8", **_) -> StringIO | Byte return StringIO(content) def read_bytes(self) -> bytes: + """Read the virtual file as raw bytes.""" return self.open("rb").read() def read_text(self, encoding: str = "utf-8") -> str: + """Read the virtual file as UTF-8 text.""" content = self._files.get(self._path) if content is None: raise FileNotFoundError(self._path) return content def __repr__(self) -> str: + """Return a string representation of the virtual path.""" return f"MemoryTraversable({self._path!r})" def __str__(self) -> str: + """Return a string representation of the virtual path.""" return self._path -def file_exists(root: Traversable, path: str) -> bool: - try: - return root.joinpath(path).is_file() - except Exception: - return False - - -def file_content(root: Traversable, path: str) -> str: - try: - f = root.joinpath(path) - if f.is_file(): - return f.read_text(encoding="utf-8") - except Exception: - pass - return "" - - -def file_contains(root: Traversable, path: str, pattern: str | re.Pattern) -> bool: - content = file_content(root, path) - if not content: - return False - if isinstance(pattern, str): - return pattern in content - return bool(pattern.search(content)) - - -CANONICAL_WF = { - "main": ".github/workflows/ci_cd_main.yml", - "pr": ".github/workflows/ci_cd_pr.yml", - "release": ".github/workflows/ci_cd_release.yml", -} - - -def all_workflows_content(root: Traversable) -> str: - return _merge_all_workflows(root) - - -def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, str]: - canonical = CANONICAL_WF[role] - if file_exists(root, canonical): - return True, file_content(root, canonical) - - entry = workflow_map.get(role) - if entry and not entry.get("is_fallback"): - path = entry.get("path", "") - return False, file_content(root, path) if path else "" - return False, _merge_all_workflows(root) - - -def _merge_all_workflows(root: Traversable) -> str: - try: - entries = [ - e - for e in root.joinpath(".github/workflows").iterdir() - if e.name.endswith((".yml", ".yaml")) - ] - except Exception: - return "" - parts = [] - for entry in entries: - try: - c = entry.read_text(encoding="utf-8") - if c: - parts.append(c) - except Exception: - pass - return "\n\n".join(parts) - - -def wf_label(role: str, workflow_map: dict) -> str: - entry = workflow_map.get(role) - if not entry: - return CANONICAL_WF.get(role, role) - if entry.get("is_fallback"): - sources = entry.get("sources", []) - return f"{len(sources)} workflow file(s) ({', '.join(sources)})" - return entry.get("name", role) - - -def workflow_map(root: Traversable) -> dict[str, dict]: - wf_dir = root.joinpath(".github/workflows") - try: - entries = [e for e in wf_dir.iterdir() if e.name.endswith((".yml", ".yaml"))] - except Exception: - entries = [] - - result: dict[str, dict] = {} - for entry in entries: - role = _classify_workflow(entry.name) - if role != "unknown" and role not in result: - result[role] = { - "name": entry.name, - "path": f".github/workflows/{entry.name}", - "is_fallback": False, - "sources": [entry.name], - } - - for role in ("main", "pr", "release"): - if role not in result and entries: - result[role] = { - "name": f"{len(entries)} workflow(s)", - "path": None, - "is_fallback": True, - "sources": [e.name for e in entries], - } - - return result - - -def _classify_workflow(name: str) -> str: - n = name.lower() - if re.search(r"release|publish|deploy", n): - return "release" - if re.search(r"\bpr\b|pull.?request|pull_request", n): - return "pr" - if re.search(r"main|push|branch|nightly|schedule|ci_cd_main|ci.main", n): - return "main" - if re.search(r"\bci\b|build|test", n): - return "pr" - return "unknown" - - -def readme_path(root: Traversable) -> str | None: - if file_exists(root, "README.rst"): - return "README.rst" - if file_exists(root, "README.md"): - return "README.md" - return None - - -def is_mcp(root: Traversable) -> bool: - try: - pyproject_text = root.joinpath("pyproject.toml").read_text() - return bool(re.search(r"\b(fastmcp|mcp)\b", pyproject_text, re.IGNORECASE)) - except Exception: - pass - try: - return file_exists(root, "src/server.py") or file_exists(root, "server.py") - except Exception: - return False - - -class ProjectMetadata: - family = "project_metadata" - - -class PM001(ProjectMetadata): - "AUTHORS exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "AUTHORS") - - -class PM002(ProjectMetadata): - "CHANGELOG.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "CHANGELOG.md") - - -class PM003(ProjectMetadata): - "CODE_OF_CONDUCT.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "CODE_OF_CONDUCT.md") - - -class PM004(ProjectMetadata): - "CONTRIBUTING.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "CONTRIBUTING.md") - - -class PM005(ProjectMetadata): - "CONTRIBUTORS.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "CONTRIBUTORS.md") - - -class PM006(ProjectMetadata): - "LICENSE exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "LICENSE") - - -class PM007(ProjectMetadata): - "README exists (.rst preferred)" - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | str: - if readme_path is None: - return False - if readme_path == "README.md": - return "⚠️ README.md found — README.rst is the preferred format." - return True - - -class PM008(ProjectMetadata): - "SECURITY.md exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "SECURITY.md") - - -class PM009(ProjectMetadata): - ".github/CODEOWNERS exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, ".github/CODEOWNERS") - - -class PM010(ProjectMetadata): - "pyproject.toml references README file" - - requires = {"PM007"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not file_exists(root, "pyproject.toml"): - return None - content = file_content(root, "pyproject.toml") - if re.search(r"poetry\.core|poetry-core", content): - m = re.search( - r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", content, re.M - ) - if m: - return True - return False - rm = readme_path or "README.rst" - if rm in content: - return True - if "README" in content: - return "⚠️ readme key found but exact README filename not confirmed." - return False - - -class PM011(ProjectMetadata): - "pyproject.toml references LICENSE file" - - requires = {"PM006"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "pyproject.toml"): - return None - c = file_content(root, "pyproject.toml") - if re.search(r"poetry\.core|poetry-core", c): - return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", c, re.M)) - return bool( - re.search(r"license-files\s*=", c) - or re.search(r"license\s*=\s*\{[^}]*file", c) - or re.search(r'license\s*=\s*["\']LICENSE["\']', c) - ) - - -class CICDFiles: - family = "cicd_files" - - -class CI001(CICDFiles): - "ci_cd_main.yml exists" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | str: - if file_exists(root, CANONICAL_WF["main"]): - return True - lbl = wf_label("main", workflow_map) - return f"⚠️ Canonical ci_cd_main.yml not found — detected: {lbl}" - - -class CI002(CICDFiles): - "ci_cd_pr.yml exists" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | str: - if file_exists(root, CANONICAL_WF["pr"]): - return True - lbl = wf_label("pr", workflow_map) - return f"⚠️ Canonical ci_cd_pr.yml not found — detected: {lbl}" - - -class CI003(CICDFiles): - "ci_cd_release.yml exists" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | str: - if file_exists(root, CANONICAL_WF["release"]): - return True - lbl = wf_label("release", workflow_map) - return f"⚠️ Canonical ci_cd_release.yml not found — detected: {lbl}" - - -class CICD: - family = "cicd" - - -class CI004(CICD): - "Workflows use concurrency blocks" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | None | str: - roles = [("pr", "ci_cd_pr.yml"), ("main", "ci_cd_main.yml")] - present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] - if not present: - return None - missing = [ - lbl - for role, lbl in present - if "concurrency:" not in wf_content(root, role, workflow_map)[1] - ] - if not missing: - return True - return f"⚠️ concurrency: block missing in: {', '.join(missing)}" - - -class CI005(CICD): - "Workflows set root `permissions: {}`" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | None | str: - roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] - present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] - if not present: - return None - missing = [ - lbl - for role, lbl in present - if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M) - ] - return True if not missing else f"Missing root permissions: {{}} in: {', '.join(missing)}" - - -class CI006(CICD): - "checkout uses persist-credentials: false" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | None | str: - roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] - present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] - if not present: - return None - missing = [ - lbl - for role, lbl in present - if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1] - ] - if not missing: - return True - return f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" - - -class CI007(CICD): - "Labeler job present across workflows" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/[^\s]*label|\blabeler\b", content, re.IGNORECASE)) - - -class CI008(CICD): - "ansys/actions/check-vulnerabilities used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return "ansys/actions/check-vulnerabilities" in content - - -class CI009(CICD): - "ansys/actions/code-style used" - - @staticmethod - def check(root: Traversable) -> bool | None | str: - content = all_workflows_content(root) - if not content: - return None - if "ansys/actions/code-style" in content: - return True - return "⚠️ ansys/actions/code-style not found in any workflow file." - - -class CI010(CICD): - "check-pr-title step present across workflows" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool( - re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE) - ) - - -class CI011(CICD): - "changelog-fragment step present across workflows" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool( - re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE) - ) - - -class CI012(CICD): - "ansys/actions/check-doc-style used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/check-doc-style|doc-style", content, re.IGNORECASE)) - - -class CI013(CICD): - "ansys/actions/doc-build used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool(re.search(r"ansys/actions/doc-build|\bdoc-build\b", content, re.IGNORECASE)) - - -class CI014(CICD): - "ansys/actions/build-wheelhouse used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool( - re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE) - ) - - -class CI015(CICD): - "ansys/actions/tests-pytest (or pytest) used" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool( - re.search( - r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", - content, - re.IGNORECASE, - ) - ) - - -class CI016(CICD): - "update-changelog step present across workflows" - - @staticmethod - def check(root: Traversable) -> bool | None: - content = all_workflows_content(root) - if not content: - return None - return bool( - re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE) - ) - - -class Dependabot: - family = "dependabot" - - -_PATH_DEPENDABOT = ".github/dependabot.yml" - - -class DB001(Dependabot): - ".github/dependabot.yml exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, _PATH_DEPENDABOT) - - -class DB002(Dependabot): - "dependabot.yml sets version: 2" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, _PATH_DEPENDABOT): - return None - return file_contains(root, _PATH_DEPENDABOT, re.compile(r"^version:\s*2\s*$", re.M)) - - -class DB003(Dependabot): - "pip or uv ecosystem configured" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - has_pip = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") - ) - has_uv = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") - ) - if has_pip: - return True - if has_uv: - return "⚠️ uv ecosystem configured (pip preferred for PyAnsys standard)." - return False - - -class DB004(Dependabot): - "github-actions ecosystem configured" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, _PATH_DEPENDABOT): - return None - return file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?") - ) - - -class DB005(Dependabot): - "Weekly update interval set" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - content = file_content(root, _PATH_DEPENDABOT) - count = len(re.findall(r"interval:\s*[\"']?weekly[\"']?", content)) - if count >= 2: - return True - return f"⚠️ Only {count} ecosystem(s) use weekly interval (expected ≥2)." - - -class DB006(Dependabot): - "Cooldown default-days: 7 configured" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r"default-days:\s*7")): - return True - return "⚠️ Cooldown default-days: 7 not found in dependabot.yml." - - -class DB007(Dependabot): - "pip uses versioning-strategy: lockfile-only" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - has_uv = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") - ) - has_pip = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") - ) - if has_uv and not has_pip: - return None - if file_contains( - root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?") - ): - return True - return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." - - -class DB008(Dependabot): - "pip groups all dependencies together" - - requires = {"DB001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, _PATH_DEPENDABOT): - return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): - return True - return '⚠️ pip groups wildcard pattern "- "*"" not found in dependabot.yml.' - - -class Documentation: - family = "documentation" - - -class DOC001(Documentation): - "doc/source/ structure exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "doc/source/index.rst") - - -class DOC002(Documentation): - "conf.py exists" - - requires = {"DOC001"} - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "doc/source/conf.py") - - -class DOC003(Documentation): - "conf.py includes numpydoc" - - requires = {"DOC002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/conf.py"): - return None - return file_contains(root, "doc/source/conf.py", "numpydoc") - - -class DOC004(Documentation): - "conf.py includes sphinx_design" - - requires = {"DOC002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/conf.py"): - return None - return file_contains(root, "doc/source/conf.py", "sphinx_design") - - -class DOC005(Documentation): - "conf.py includes intersphinx" - - requires = {"DOC002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/conf.py"): - return None - return file_contains(root, "doc/source/conf.py", "intersphinx") - - -class DOC006(Documentation): - "index.rst has Getting started section" - - requires = {"DOC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/index.rst"): - return None - return file_contains(root, "doc/source/index.rst", re.compile(r"getting.started", re.I)) - - -class DOC007(Documentation): - "index.rst has API reference section" - - requires = {"DOC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "doc/source/index.rst"): - return None - return file_contains( - root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I) - ) - - -class README: - family = "readme" - - -class RM000(README): - "README file exists" - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | str: - if readme_path == "README.rst": - return True - if readme_path == "README.md": - return "⚠️ README.md found — PyAnsys preferred format is README.rst." - return False - - -class RM001(README): - "README has PyAnsys badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile( - r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", - re.I, - ), - ): - return True - return f"⚠️ PyAnsys badge image not found in {readme_path}." - - -class RM002(README): - "README has PyPI badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile( - r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", - re.I, - ), - ): - return True - return f"⚠️ PyPI badge image not found in {readme_path}." - - -class RM003(README): - "README has Codecov badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I), - ): - return True - return f"⚠️ Codecov badge image not found in {readme_path}." - - -class RM004(README): - "README has MIT license badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I), - ): - return True - return f"⚠️ MIT license badge image not found in {readme_path}." - - -class RM005(README): - "README has GH-CI badge" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None | str: - if not readme_path: - return None - if file_contains( - root, - readme_path, - re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I), - ): - return True - return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." - - -class RM006(README): - "README has Installation section" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None: - if not readme_path: - return None - return file_contains(root, readme_path, re.compile(r"install", re.I)) - - -class RM007(README): - "README has Documentation section" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None: - if not readme_path: - return None - return file_contains(root, readme_path, re.compile(r"documentation", re.I)) - - -class RM008(README): - "README has License section" - - requires = {"RM000"} - - @staticmethod - def check(root: Traversable, readme_path: str | None) -> bool | None: - if not readme_path: - return None - return file_contains(root, readme_path, re.compile(r"license", re.I)) - - -class BuildSystem: - family = "build_system" - - -_BACKENDS = { - "flit_core": "Flit", - "poetry.core": "Poetry", - "hatchling": "Hatch", - "pdm": "PDM", - "maturin": "Maturin", - "setuptools": "Setuptools", -} - - -def _detect_backend(content: str) -> tuple[str, str]: - m = re.search(r'build-backend\s*=\s*["\']([^"\']+)["\']', content) - backend = m.group(1) if m else "" - for pattern, name in _BACKENDS.items(): - if pattern in backend: - return name, pattern.split(".")[0].replace("_core", "") - if "[build-system]" in content: - return "Other", "other" - return "Unknown", "unknown" - - -class BS001(BuildSystem): - "[build-system] table declared" - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, "pyproject.toml"): - return None - return file_contains(root, "pyproject.toml", "[build-system]") - - -class BS002(BuildSystem): - "Uses a supported modern build backend" - - requires = {"BS001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, "pyproject.toml"): - return None - name, key = _detect_backend(file_content(root, "pyproject.toml")) - if key == "unknown": - return False - if key == "setuptools": - return ( - f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." - ) - return True - - -class BS003(BuildSystem): - "No legacy setup.py or setup.cfg" - - @staticmethod - def check(root: Traversable) -> bool | str: - has_py = file_exists(root, "setup.py") - has_cfg = file_exists(root, "setup.cfg") - if not has_py and not has_cfg: - return True - found = [f for f, present in [("setup.py", has_py), ("setup.cfg", has_cfg)] if present] - return f"⚠️ Legacy file(s) found: {', '.join(found)}. Remove in favour of pyproject.toml." - - -class BS004(BuildSystem): - "Build backend version pinned in requires" - - requires = {"BS001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, "pyproject.toml"): - return None - content = file_content(root, "pyproject.toml") - m = re.search(r"requires\s*=\s*\[([^\]]+)\]", content) - if not m: - return False - if re.search(r"[><=!~]", m.group(1)): - return True - return "⚠️ Build backend in requires has no version pin (e.g. >=x.y)." - - -class Security: - family = "security" - - -class SEC001(Security): - ".github/zizmor.yml exists" - - @staticmethod - def check(root: Traversable) -> bool | str: - if file_exists(root, ".github/zizmor.yml"): - return True - return "⚠️ .github/zizmor.yml not found — optional but recommended." - - -class SEC002(Security): - "zizmor.yml has secrets-outside-env rule" - - requires = {"SEC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".github/zizmor.yml"): - return None - return file_contains(root, ".github/zizmor.yml", "secrets-outside-env") - - -class SEC003(Security): - "gitleaks hook configured" - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "gitleaks") - - -class SEC004(Security): - "Workflows pin action SHAs" - - @staticmethod - def check(root: Traversable, workflow_map: dict) -> bool | None | str: - _, content = wf_content(root, "pr", workflow_map) - if not content: - return None - if re.search(r"uses:\s*\S+@[0-9a-f]{40}", content, re.I): - return True - return "⚠️ No SHA-pinned actions detected in PR workflow. Use full commit SHAs." - - -class SEC005(Security): - "SECURITY.md discourages public issue reporting" - - requires = {"PM008"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, "SECURITY.md"): - return None - if file_contains(root, "SECURITY.md", re.compile(r"do not|don't|please don", re.I)): - return True - return "⚠️ SECURITY.md may not clearly discourage public issue reporting." - - -class Labeler: - family = "labeler" - - -class LB001(Labeler): - ".github/labeler.yml exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, ".github/labeler.yml") - - -class LB002(Labeler): - ".github/labels.yml exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, ".github/labels.yml") - - -class LB003(Labeler): - "labels.yml has 'bug' label" - - requires = {"LB002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".github/labels.yml"): - return None - return file_contains(root, ".github/labels.yml", "bug") - - -class LB004(Labeler): - "labels.yml has 'enhancement' label" - - requires = {"LB002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".github/labels.yml"): - return None - return file_contains(root, ".github/labels.yml", "enhancement") - - -class LB005(Labeler): - "labels.yml has 'documentation' label" - - requires = {"LB002"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".github/labels.yml"): - return None - return file_contains(root, ".github/labels.yml", "documentation") - - -class Vale: - family = "vale" - - -_INI = "doc/.vale.ini" - - -class VL001(Vale): - "doc/.vale.ini exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, _INI) - - -class VL002(Vale): - "Vale uses Google style package" - - requires = {"VL001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, _INI): - return None - return file_contains(root, _INI, "Google") - - -class VL003(Vale): - "Vale uses ANSYS vocabulary" - - requires = {"VL001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, _INI): - return None - return file_contains(root, _INI, "ANSYS") - - -class VL004(Vale): - "ANSYS accept.txt vocabulary exists" - - requires = {"VL001"} - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "doc/styles/config/vocabularies/ANSYS/accept.txt") - - -class VL005(Vale): - "ANSYS reject.txt vocabulary exists" - - requires = {"VL001"} - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, "doc/styles/config/vocabularies/ANSYS/reject.txt") - - -class MCP: - family = "mcp" - - -class MCP001(MCP): - "Core governance files all present" - - @staticmethod - def check(root: Traversable, is_mcp: bool) -> bool | None: - if not is_mcp: - return None - required = [ - "LICENSE", - "SECURITY.md", - "CONTRIBUTING.md", - "CHANGELOG.md", - ".github/CODEOWNERS", - ] - missing = [p for p in required if not file_exists(root, p)] - return True if not missing else f"Missing: {', '.join(missing)}" - - -class MCP002(MCP): - "CI/CD workflows all present" - - @staticmethod - def check(root: Traversable, is_mcp: bool) -> bool | None: - if not is_mcp: - return None - workflows = [ - ".github/workflows/ci_cd_main.yml", - ".github/workflows/ci_cd_pr.yml", - ".github/workflows/ci_cd_release.yml", - ] - missing = [p for p in workflows if not file_exists(root, p)] - return True if not missing else f"Missing: {', '.join(missing)}" - - -class MCP003(MCP): - "tests job wired in PR workflow" - - @staticmethod - def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: - if not is_mcp: - return None - _, content = wf_content(root, "pr", workflow_map) - if not content: - return None - return bool(re.search(r"tests|pytest", content, re.I)) - - -class MCP004(MCP): - "doc-build job present in PR workflow" - - @staticmethod - def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: - if not is_mcp: - return None - _, content = wf_content(root, "pr", workflow_map) - if not content: - return None - return "doc-build" in content - - -class MCP005(MCP): - "README and docs metadata aligned" - - @staticmethod - def check(root: Traversable, is_mcp: bool, readme_path: str | None) -> bool | None | str: - if not is_mcp: - return None - if not file_exists(root, "pyproject.toml"): - return None - if not readme_path: - return False - filename = readme_path.split("/")[-1] - if not file_contains(root, "pyproject.toml", filename): - return f"pyproject.toml does not reference {filename} as readme." - if readme_path == "README.md": - return "⚠️ README.md found — PyAnsys preferred format is README.rst." - return True - - -class MCP006(MCP): - "No TODO/FIXME in doc/source/index.rst" - - @staticmethod - def check(root: Traversable, is_mcp: bool) -> bool | None: - if not is_mcp: - return None - if not file_exists(root, "doc/source/index.rst"): - return None - return not file_contains(root, "doc/source/index.rst", re.compile(r"TODO|FIXME")) - - -class MCP007(MCP): - "Security checks not bypassed" - - @staticmethod - def check(root: Traversable, is_mcp: bool, workflow_map: dict) -> bool | None: - if not is_mcp: - return None - _, pr_content = wf_content(root, "pr", workflow_map) - _, rel_content = wf_content(root, "release", workflow_map) - combined = pr_content + rel_content - if not combined.strip(): - return None - return not bool(re.search(r"--no-verify|skip.*security|disable.*scan", combined, re.I)) - - -class PreCommit: - family = "pre_commit" - - -class PC001(PreCommit): - ".pre-commit-config.yaml exists" - - @staticmethod - def check(root: Traversable) -> bool: - return file_exists(root, ".pre-commit-config.yaml") - - -class PC002(PreCommit): - "ruff-pre-commit configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "ruff-pre-commit") - - -class PC003(PreCommit): - "zizmor configured with --pedantic" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - has_zizmor = file_contains(root, ".pre-commit-config.yaml", "zizmor") - has_pedantic = file_contains(root, ".pre-commit-config.yaml", "--pedantic") - if not has_zizmor: - return False - if not has_pedantic: - return "⚠️ zizmor found but --pedantic flag not set." - return True - - -class PC004(PreCommit): - "blacken-docs configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "blacken-docs") - - -class PC005(PreCommit): - "codespell configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "codespell") - - -class PC006(PreCommit): - "ansys/pre-commit-hooks configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "ansys/pre-commit-hooks") - - -class PC007(PreCommit): - "google/yamlfmt configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "yamlfmt") - - -class PC008(PreCommit): - "pyright configured" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - return file_contains(root, ".pre-commit-config.yaml", "pyright") - - -class PC009(PreCommit): - "autofix_prs: true enabled" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - if file_contains(root, ".pre-commit-config.yaml", "autofix_prs: true"): - return True - return "⚠️ autofix_prs: true not set in ci: block." - - -class PC010(PreCommit): - "autoupdate_schedule: weekly" - - requires = {"PC001"} - - @staticmethod - def check(root: Traversable) -> bool | None | str: - if not file_exists(root, ".pre-commit-config.yaml"): - return None - if file_contains( - root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly") - ): - return True - return "⚠️ autoupdate_schedule: weekly not found." - - -def repo_review_families() -> dict[str, dict]: - return { - "project_metadata": {"name": "Project Metadata", "order": 10}, - "cicd_files": {"name": "CI/CD — Workflow File Names", "order": 20}, - "cicd": {"name": "CI/CD — Content Checks", "order": 25}, - "dependabot": {"name": "Dependabot", "order": 30}, - "pre_commit": {"name": "Pre-commit", "order": 40}, - "documentation": {"name": "Documentation", "order": 50}, - "readme": {"name": "README", "order": 60}, - "build_system": {"name": "Build System", "order": 70}, - "security": {"name": "Security", "order": 80}, - "labeler": {"name": "Labeler", "order": 90}, - "vale": {"name": "Vale", "order": 100}, - "mcp": {"name": "MCP Release Readiness", "order": 110}, - } - - -def repo_review_checks() -> dict: - families = [ - ProjectMetadata, - CICDFiles, - CICD, - Dependabot, - PreCommit, - Documentation, - README, - BuildSystem, - Security, - Labeler, - Vale, - MCP, - ] - result = {} - for family in families: - for cls in family.__subclasses__(): - result[cls.__name__] = cls() - return result - - -def _first_doc_line(obj: Any) -> str: - doc = (obj.check.__doc__ or "").strip() - lines = [line.strip() for line in doc.splitlines() if line.strip()] - return lines[0] if lines else "" - - -def _interpret(raw: bool | str | None, check_obj: Any) -> tuple[str, str]: - if raw is True: - return "pass", "" - if raw is None: - return "na", "" - if isinstance(raw, str) and raw.startswith("⚠️ "): - return "warn", raw.removeprefix("⚠️ ") - if raw is False: - doc = (check_obj.check.__doc__ or "").strip() - lines = [line.strip() for line in doc.splitlines() if line.strip()] - detail = lines[-1] if len(lines) > 1 else (lines[0] if lines else "") - return "fail", detail - return "fail", str(raw) - - def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, Any]: + """Run the package-based repo review checks against an in-memory file set.""" root = MemoryTraversable(files) fixture_values = { "root": root, @@ -1566,7 +162,7 @@ def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, An key: fixture_values[key] for key in signature.parameters if key in fixture_values } raw = check_obj.check(**kwargs) - except Exception as exc: # pragma: no cover + except (AttributeError, TypeError, ValueError) as exc: # pragma: no cover raw = f"⚠️ Check error: {exc}" status, detail = _interpret(raw, check_obj) @@ -1602,6 +198,7 @@ def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, An def _load_files(repo_root: Path) -> dict[str, str | None]: + """Load the repository files needed by the quality report from disk.""" files: dict[str, str | None] = {} for relative_path in _PATHS_TO_FETCH: candidate = repo_root / relative_path @@ -1621,6 +218,7 @@ def _load_files(repo_root: Path) -> dict[str, str | None]: def _style_status(status: str, text: str) -> str: + """Style a status label for console output.""" colors = { "pass": "\033[32m", "warn": "\033[33m", @@ -1633,6 +231,7 @@ def _style_status(status: str, text: str) -> str: def _print_report(review: dict[str, Any], *, show_passes: bool = False) -> None: + """Print the repo quality summary to stdout.""" results = review["results"] tally = review["tally"] score = review["score"] @@ -1659,6 +258,68 @@ def _print_report(review: dict[str, Any], *, show_passes: bool = False) -> None: def main(argv: list[str] | None = None) -> int: + """Run the PyAnsys repository quality report.""" + parser = argparse.ArgumentParser(description="Run the PyAnsys repository quality report.") + parser.add_argument("--repo-root", default=".", help="Repository root to review.") + parser.add_argument( + "--json", action="store_true", help="Emit a JSON report instead of a text summary." + ) + parser.add_argument( + "--all", action="store_true", help="Show all checks, including passing ones." + ) + parser.add_argument( + "--fix-missing", + action="store_true", + help="Generate missing repository scaffolding before running the quality report.", + ) + args, unknown = parser.parse_known_args(argv) + + repo_root = Path(args.repo_root).resolve() + if not repo_root.exists(): + raise FileNotFoundError(f"Repo root not found: {repo_root}") + + if args.fix_missing: + legacy_argv: list[str] = [] + raw_argv = list(argv) if argv is not None else list(__import__("sys").argv[1:]) + + idx = 0 + while idx < len(raw_argv): + token = raw_argv[idx] + if token in {"--repo-root", "--json", "--all", "--fix-missing"}: + idx += 1 + if token == "--repo-root" and idx < len(raw_argv): + idx += 1 + continue + legacy_argv.append(token) + idx += 1 + + if unknown: + legacy_argv.extend(unknown) + + current_dir = Path.cwd() + os.chdir(repo_root) + try: + return tech_review.main(legacy_argv) + finally: + os.chdir(current_dir) + + files = _load_files(repo_root) + review = _run_checks(files, is_mcp_flag=is_mcp(MemoryTraversable(files))) + + if args.json: + print(json.dumps(review, indent=2)) + return 1 if review["tally"]["fail"] else 0 + + _print_report(review, show_passes=args.all) + return 1 if review["tally"]["fail"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + +def main(argv: list[str] | None = None) -> int: + """Run the PyAnsys repository quality report.""" parser = argparse.ArgumentParser(description="Run the PyAnsys repository quality report.") parser.add_argument("--repo-root", default=".", help="Repository root to review.") parser.add_argument( diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index a56007ff..c0a4c8bf 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -190,6 +190,7 @@ def repo_review_families() -> dict[str, dict]: + """Return the metadata for each quality-report family.""" return { "project_metadata": {"name": "Project Metadata", "order": 10}, "cicd_files": {"name": "CI/CD — Workflow File Names", "order": 20}, @@ -207,6 +208,7 @@ def repo_review_families() -> dict[str, dict]: def repo_review_checks() -> dict: + """Return the rule family classes used by the quality report.""" families = [ ProjectMetadata, CICDFiles, diff --git a/src/ansys/pre_commit_hooks/quality_rules/build_system.py b/src/ansys/pre_commit_hooks/quality_rules/build_system.py index da4f75f3..37bec430 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/build_system.py +++ b/src/ansys/pre_commit_hooks/quality_rules/build_system.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Build system checks.""" @@ -34,26 +53,30 @@ def _detect_backend(content: str) -> tuple[str, str]: class BuildSystem: + """Build system rule family.""" + family = "build_system" class BS001(BuildSystem): - "[build-system] table declared" + """The [build-system] table is declared.""" @staticmethod def check(root) -> bool | None: + """Return whether a build-system table is present in pyproject.toml.""" if not file_exists(root, "pyproject.toml"): return None return file_contains(root, "pyproject.toml", "[build-system]") class BS002(BuildSystem): - "Uses a supported modern build backend" + """Uses a supported modern build backend.""" requires = {"BS001"} @staticmethod def check(root) -> bool | None | str: + """Return whether the project uses a supported modern build backend.""" if not file_exists(root, "pyproject.toml"): return None name, key = _detect_backend(file_content(root, "pyproject.toml")) @@ -67,10 +90,11 @@ def check(root) -> bool | None | str: class BS003(BuildSystem): - "No legacy setup.py or setup.cfg" + """No legacy setup.py or setup.cfg files are present.""" @staticmethod def check(root) -> bool | str: + """Return whether the project uses only pyproject.toml for packaging metadata.""" has_py = file_exists(root, "setup.py") has_cfg = file_exists(root, "setup.cfg") if not has_py and not has_cfg: @@ -80,12 +104,13 @@ def check(root) -> bool | str: class BS004(BuildSystem): - "Build backend version pinned in requires" + """The build backend version is pinned in requires.""" requires = {"BS001"} @staticmethod def check(root) -> bool | None | str: + """Return whether the build backend requirement includes a version pin.""" if not file_exists(root, "pyproject.toml"): return None content = file_content(root, "pyproject.toml") diff --git a/src/ansys/pre_commit_hooks/quality_rules/cicd.py b/src/ansys/pre_commit_hooks/quality_rules/cicd.py index 4ab20856..41934984 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/cicd.py +++ b/src/ansys/pre_commit_hooks/quality_rules/cicd.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """CI/CD content checks.""" @@ -28,14 +47,17 @@ class CICD: + """CI/CD rule family.""" + family = "cicd" class CI004(CICD): - "Workflows use concurrency blocks" + """Workflows use concurrency blocks.""" @staticmethod def check(root, workflow_map: dict) -> bool | None | str: + """Return whether the PR and main workflows define concurrency blocks.""" roles = [("pr", "ci_cd_pr.yml"), ("main", "ci_cd_main.yml")] present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: @@ -51,10 +73,11 @@ def check(root, workflow_map: dict) -> bool | None | str: class CI005(CICD): - "Workflows set root `permissions: {}`" + """Workflows set root permissions: {}.""" @staticmethod def check(root, workflow_map: dict) -> bool | None | str: + """Return whether the PR and release workflows have explicit root permissions.""" roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: @@ -68,10 +91,11 @@ def check(root, workflow_map: dict) -> bool | None | str: class CI006(CICD): - "checkout uses persist-credentials: false" + """Checkout uses persist-credentials: false.""" @staticmethod def check(root, workflow_map: dict) -> bool | None | str: + """Return whether workflows disable persisting credentials during checkout.""" roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] if not present: @@ -87,10 +111,11 @@ def check(root, workflow_map: dict) -> bool | None | str: class CI007(CICD): - "Labeler job present across workflows" + """A labeler job is present across workflows.""" @staticmethod def check(root) -> bool | None: + """Return whether the workflows include a labeler action.""" content = all_workflows_content(root) if not content: return None @@ -98,10 +123,11 @@ def check(root) -> bool | None: class CI008(CICD): - "ansys/actions/check-vulnerabilities used" + """The vulnerability check action is used.""" @staticmethod def check(root) -> bool | None: + """Return whether the workflows include the vulnerability check action.""" content = all_workflows_content(root) if not content: return None @@ -109,10 +135,11 @@ def check(root) -> bool | None: class CI009(CICD): - "ansys/actions/code-style used" + """The code-style action is used.""" @staticmethod def check(root) -> bool | None | str: + """Return whether the workflows include the code-style action.""" content = all_workflows_content(root) if not content: return None @@ -122,10 +149,11 @@ def check(root) -> bool | None | str: class CI010(CICD): - "check-pr-title step present across workflows" + """The check-pr-title step is present across workflows.""" @staticmethod def check(root) -> bool | None: + """Return whether workflows enforce the PR title check.""" content = all_workflows_content(root) if not content: return None @@ -135,10 +163,11 @@ def check(root) -> bool | None: class CI011(CICD): - "changelog-fragment step present across workflows" + """The changelog fragment step is present across workflows.""" @staticmethod def check(root) -> bool | None: + """Return whether workflows include changelog-fragment validation.""" content = all_workflows_content(root) if not content: return None @@ -148,10 +177,11 @@ def check(root) -> bool | None: class CI012(CICD): - "ansys/actions/check-doc-style used" + """The doc-style action is used.""" @staticmethod def check(root) -> bool | None: + """Return whether the workflows include the doc-style action.""" content = all_workflows_content(root) if not content: return None @@ -159,10 +189,11 @@ def check(root) -> bool | None: class CI013(CICD): - "ansys/actions/doc-build used" + """The doc-build action is used.""" @staticmethod def check(root) -> bool | None: + """Return whether the workflows include the doc-build action.""" content = all_workflows_content(root) if not content: return None @@ -170,10 +201,11 @@ def check(root) -> bool | None: class CI014(CICD): - "ansys/actions/build-wheelhouse used" + """The build-wheelhouse action is used.""" @staticmethod def check(root) -> bool | None: + """Return whether the workflows include the build-wheelhouse action.""" content = all_workflows_content(root) if not content: return None @@ -183,10 +215,11 @@ def check(root) -> bool | None: class CI015(CICD): - "ansys/actions/tests-pytest (or pytest) used" + """The pytest test action is used.""" @staticmethod def check(root) -> bool | None: + """Return whether the workflows include pytest-based tests.""" content = all_workflows_content(root) if not content: return None @@ -200,10 +233,11 @@ def check(root) -> bool | None: class CI016(CICD): - "update-changelog step present across workflows" + """The update-changelog step is present across workflows.""" @staticmethod def check(root) -> bool | None: + """Return whether workflows include changelog updates during release.""" content = all_workflows_content(root) if not content: return None diff --git a/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py b/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py index 6c8597a7..7d3f2244 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py +++ b/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """CI/CD workflow file naming checks.""" @@ -11,14 +30,17 @@ class CICDFiles: + """CI/CD workflow file naming rule family.""" + family = "cicd_files" class CI001(CICDFiles): - "ci_cd_main.yml exists" + """The ci_cd_main.yml workflow file exists.""" @staticmethod def check(root, workflow_map: dict) -> bool | str: + """Return whether the canonical main workflow file is present.""" if file_exists(root, CANONICAL_WF["main"]): return True lbl = wf_label("main", workflow_map) @@ -26,10 +48,11 @@ def check(root, workflow_map: dict) -> bool | str: class CI002(CICDFiles): - "ci_cd_pr.yml exists" + """The ci_cd_pr.yml workflow file exists.""" @staticmethod def check(root, workflow_map: dict) -> bool | str: + """Return whether the canonical PR workflow file is present.""" if file_exists(root, CANONICAL_WF["pr"]): return True lbl = wf_label("pr", workflow_map) @@ -37,10 +60,11 @@ def check(root, workflow_map: dict) -> bool | str: class CI003(CICDFiles): - "ci_cd_release.yml exists" + """The ci_cd_release.yml workflow file exists.""" @staticmethod def check(root, workflow_map: dict) -> bool | str: + """Return whether the canonical release workflow file is present.""" if file_exists(root, CANONICAL_WF["release"]): return True lbl = wf_label("release", workflow_map) diff --git a/src/ansys/pre_commit_hooks/quality_rules/common.py b/src/ansys/pre_commit_hooks/quality_rules/common.py index 9c73a03e..f1409b5e 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/common.py +++ b/src/ansys/pre_commit_hooks/quality_rules/common.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Shared helpers used by the repository quality checks.""" @@ -30,6 +49,7 @@ def file_exists(root: Traversable, path: str) -> bool: + """Return whether a file exists under the repository root.""" try: return root.joinpath(path).is_file() except Exception: @@ -37,6 +57,7 @@ def file_exists(root: Traversable, path: str) -> bool: def file_content(root: Traversable, path: str) -> str: + """Return the text content of a file under the repository root.""" try: f = root.joinpath(path) if f.is_file(): @@ -47,6 +68,7 @@ def file_content(root: Traversable, path: str) -> str: def file_contains(root: Traversable, path: str, pattern: str | re.Pattern) -> bool: + """Return whether a file contains the given string or regex pattern.""" content = file_content(root, path) if not content: return False @@ -63,10 +85,12 @@ def file_contains(root: Traversable, path: str, pattern: str | re.Pattern) -> bo def all_workflows_content(root: Traversable) -> str: + """Return the combined content of all workflow files in the repository.""" return _merge_all_workflows(root) def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, str]: + """Return the content for the workflow matching the given role.""" canonical = CANONICAL_WF[role] if file_exists(root, canonical): return True, file_content(root, canonical) @@ -79,6 +103,7 @@ def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, def _merge_all_workflows(root: Traversable) -> str: + """Merge the contents of all workflow files into a single string.""" try: entries = [ e @@ -99,6 +124,7 @@ def _merge_all_workflows(root: Traversable) -> str: def wf_label(role: str, workflow_map: dict) -> str: + """Return a human-readable label for a workflow role.""" entry = workflow_map.get(role) if not entry: return CANONICAL_WF.get(role, role) @@ -109,6 +135,7 @@ def wf_label(role: str, workflow_map: dict) -> str: def workflow_map(root: Traversable) -> dict[str, dict]: + """Classify workflow files into canonical roles for repository checks.""" wf_dir = root.joinpath(".github/workflows") try: entries = [e for e in wf_dir.iterdir() if e.name.endswith((".yml", ".yaml"))] @@ -152,6 +179,7 @@ def _classify_workflow(name: str) -> str: def readme_path(root: Traversable) -> str | None: + """Return the preferred README filename if present.""" if file_exists(root, "README.rst"): return "README.rst" if file_exists(root, "README.md"): @@ -160,6 +188,7 @@ def readme_path(root: Traversable) -> str | None: def is_mcp(root: Traversable) -> bool: + """Return whether the repository appears to be an MCP project.""" try: pyproject_text = root.joinpath("pyproject.toml").read_text() return bool(re.search(r"\b(fastmcp|mcp)\b", pyproject_text, re.IGNORECASE)) @@ -172,12 +201,14 @@ def is_mcp(root: Traversable) -> bool: def _first_doc_line(obj: Any) -> str: + """Return the first line of the check method's docstring, if present.""" doc = (obj.check.__doc__ or "").strip() lines = [line.strip() for line in doc.splitlines() if line.strip()] return lines[0] if lines else "" def _interpret(raw: bool | str | None, check_obj: Any) -> tuple[str, str]: + """Interpret the raw check result into a status and detail message.""" if raw is True: return "pass", "" if raw is None: diff --git a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py index b84cb12e..a8fb4522 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py +++ b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Dependabot checks.""" @@ -26,36 +45,41 @@ class Dependabot: + """Dependabot rule family.""" + family = "dependabot" class DB001(Dependabot): - ".github/dependabot.yml exists" + """The .github/dependabot.yml file exists.""" @staticmethod def check(root) -> bool: + """Return whether the Dependabot config file exists.""" return file_exists(root, _PATH_DEPENDABOT) class DB002(Dependabot): - "dependabot.yml sets version: 2" + """dependabot.yml sets version 2.""" requires = {"DB001"} @staticmethod def check(root) -> bool | None: + """Return whether the Dependabot config uses the expected schema version.""" if not file_exists(root, _PATH_DEPENDABOT): return None return file_contains(root, _PATH_DEPENDABOT, re.compile(r"^version:\s*2\s*$", re.M)) class DB003(Dependabot): - "pip or uv ecosystem configured" + """Pip or uv ecosystem is configured.""" requires = {"DB001"} @staticmethod def check(root) -> bool | None | str: + """Return whether a supported dependency ecosystem is configured.""" if not file_exists(root, _PATH_DEPENDABOT): return None has_pip = file_contains( @@ -72,12 +96,13 @@ def check(root) -> bool | None | str: class DB004(Dependabot): - "github-actions ecosystem configured" + """The GitHub Actions ecosystem is configured.""" requires = {"DB001"} @staticmethod def check(root) -> bool | None: + """Return whether the GitHub Actions ecosystem is configured.""" if not file_exists(root, _PATH_DEPENDABOT): return None return file_contains( @@ -86,12 +111,13 @@ def check(root) -> bool | None: class DB005(Dependabot): - "Weekly update interval set" + """A weekly update interval is set.""" requires = {"DB001"} @staticmethod def check(root) -> bool | None | str: + """Return whether the weekly update interval is configured for enough ecosystems.""" if not file_exists(root, _PATH_DEPENDABOT): return None content = file_content(root, _PATH_DEPENDABOT) @@ -102,12 +128,13 @@ def check(root) -> bool | None | str: class DB006(Dependabot): - "Cooldown default-days: 7 configured" + """Cooldown default-days: 7 is configured.""" requires = {"DB001"} @staticmethod def check(root) -> bool | None | str: + """Return whether the Dependabot cooldown policy is set to seven days.""" if not file_exists(root, _PATH_DEPENDABOT): return None if file_contains(root, _PATH_DEPENDABOT, re.compile(r"default-days:\s*7")): @@ -116,12 +143,13 @@ def check(root) -> bool | None | str: class DB007(Dependabot): - "pip uses versioning-strategy: lockfile-only" + """Pip uses the lockfile-only versioning strategy.""" requires = {"DB001"} @staticmethod def check(root) -> bool | None | str: + """Return whether pip uses the lockfile-only versioning strategy.""" if not file_exists(root, _PATH_DEPENDABOT): return None has_uv = file_contains( @@ -140,12 +168,13 @@ def check(root) -> bool | None | str: class DB008(Dependabot): - "pip groups all dependencies together" + """Pip groups all dependencies together.""" requires = {"DB001"} @staticmethod def check(root) -> bool | None | str: + """Return whether the pip group wildcard pattern is defined.""" if not file_exists(root, _PATH_DEPENDABOT): return None if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): diff --git a/src/ansys/pre_commit_hooks/quality_rules/documentation.py b/src/ansys/pre_commit_hooks/quality_rules/documentation.py index 07389246..4b997e0f 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/documentation.py +++ b/src/ansys/pre_commit_hooks/quality_rules/documentation.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Documentation checks.""" @@ -13,82 +32,91 @@ class Documentation: + """Documentation rule family.""" + family = "documentation" class DOC001(Documentation): - "doc/source/ structure exists" + """The doc/source structure exists.""" @staticmethod def check(root) -> bool: + """Return whether the documentation index file exists.""" return file_exists(root, "doc/source/index.rst") class DOC002(Documentation): - "conf.py exists" + """The Sphinx config exists.""" requires = {"DOC001"} @staticmethod def check(root) -> bool: + """Return whether the Sphinx conf.py file exists.""" return file_exists(root, "doc/source/conf.py") class DOC003(Documentation): - "conf.py includes numpydoc" + """The Sphinx config includes numpydoc.""" requires = {"DOC002"} @staticmethod def check(root) -> bool | None: + """Return whether numpydoc is enabled in the Sphinx config.""" if not file_exists(root, "doc/source/conf.py"): return None return file_contains(root, "doc/source/conf.py", "numpydoc") class DOC004(Documentation): - "conf.py includes sphinx_design" + """The Sphinx config includes sphinx_design.""" requires = {"DOC002"} @staticmethod def check(root) -> bool | None: + """Return whether sphinx_design is enabled in the Sphinx config.""" if not file_exists(root, "doc/source/conf.py"): return None return file_contains(root, "doc/source/conf.py", "sphinx_design") class DOC005(Documentation): - "conf.py includes intersphinx" + """The Sphinx config includes intersphinx.""" requires = {"DOC002"} @staticmethod def check(root) -> bool | None: + """Return whether intersphinx is enabled in the Sphinx config.""" if not file_exists(root, "doc/source/conf.py"): return None return file_contains(root, "doc/source/conf.py", "intersphinx") class DOC006(Documentation): - "index.rst has Getting started section" + """The index page has a getting started section.""" requires = {"DOC001"} @staticmethod def check(root) -> bool | None: + """Return whether the docs index includes a getting-started section.""" if not file_exists(root, "doc/source/index.rst"): return None return file_contains(root, "doc/source/index.rst", re.compile(r"getting.started", re.I)) class DOC007(Documentation): - "index.rst has API reference section" + """The index page has an API reference section.""" requires = {"DOC001"} @staticmethod def check(root) -> bool | None: + """Return whether the docs index includes an API reference section.""" if not file_exists(root, "doc/source/index.rst"): return None return file_contains( diff --git a/src/ansys/pre_commit_hooks/quality_rules/labeler.py b/src/ansys/pre_commit_hooks/quality_rules/labeler.py index cd8c84f6..67266b45 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/labeler.py +++ b/src/ansys/pre_commit_hooks/quality_rules/labeler.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Labeler checks.""" @@ -11,56 +30,63 @@ class Labeler: + """Labeler rule family.""" + family = "labeler" class LB001(Labeler): - ".github/labeler.yml exists" + """The .github/labeler.yml file exists.""" @staticmethod def check(root) -> bool: + """Return whether the labeler config exists.""" return file_exists(root, ".github/labeler.yml") class LB002(Labeler): - ".github/labels.yml exists" + """The .github/labels.yml file exists.""" @staticmethod def check(root) -> bool: + """Return whether the labels config exists.""" return file_exists(root, ".github/labels.yml") class LB003(Labeler): - "labels.yml has 'bug' label" + """labels.yml has a bug label.""" requires = {"LB002"} @staticmethod def check(root) -> bool | None: + """Return whether the bug label is present.""" if not file_exists(root, ".github/labels.yml"): return None return file_contains(root, ".github/labels.yml", "bug") class LB004(Labeler): - "labels.yml has 'enhancement' label" + """labels.yml has an enhancement label.""" requires = {"LB002"} @staticmethod def check(root) -> bool | None: + """Return whether the enhancement label is present.""" if not file_exists(root, ".github/labels.yml"): return None return file_contains(root, ".github/labels.yml", "enhancement") class LB005(Labeler): - "labels.yml has 'documentation' label" + """labels.yml has a documentation label.""" requires = {"LB002"} @staticmethod def check(root) -> bool | None: + """Return whether the documentation label is present.""" if not file_exists(root, ".github/labels.yml"): return None return file_contains(root, ".github/labels.yml", "documentation") diff --git a/src/ansys/pre_commit_hooks/quality_rules/mcp.py b/src/ansys/pre_commit_hooks/quality_rules/mcp.py index 829395e4..5700f944 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/mcp.py +++ b/src/ansys/pre_commit_hooks/quality_rules/mcp.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """MCP release readiness checks.""" @@ -13,14 +32,17 @@ class MCP: + """MCP release readiness rule family.""" + family = "mcp" class MCP001(MCP): - "Core governance files all present" + """Core governance files are all present.""" @staticmethod def check(root, is_mcp: bool) -> bool | None: + """Return whether the required governance files exist for an MCP project.""" if not is_mcp: return None required = [ @@ -35,10 +57,11 @@ def check(root, is_mcp: bool) -> bool | None: class MCP002(MCP): - "CI/CD workflows all present" + """All CI/CD workflow files are present.""" @staticmethod def check(root, is_mcp: bool) -> bool | None: + """Return whether the canonical workflow files are present for an MCP project.""" if not is_mcp: return None workflows = [ @@ -51,10 +74,11 @@ def check(root, is_mcp: bool) -> bool | None: class MCP003(MCP): - "tests job wired in PR workflow" + """The PR workflow wires in a tests job.""" @staticmethod def check(root, is_mcp: bool, workflow_map: dict) -> bool | None: + """Return whether the PR workflow includes pytest or a test job.""" if not is_mcp: return None _, content = wf_content(root, "pr", workflow_map) @@ -64,10 +88,11 @@ def check(root, is_mcp: bool, workflow_map: dict) -> bool | None: class MCP004(MCP): - "doc-build job present in PR workflow" + """The PR workflow includes a doc-build job.""" @staticmethod def check(root, is_mcp: bool, workflow_map: dict) -> bool | None: + """Return whether the PR workflow includes doc-build.""" if not is_mcp: return None _, content = wf_content(root, "pr", workflow_map) @@ -77,10 +102,11 @@ def check(root, is_mcp: bool, workflow_map: dict) -> bool | None: class MCP005(MCP): - "README and docs metadata aligned" + """README and docs metadata are aligned.""" @staticmethod def check(root, is_mcp: bool, readme_path: str | None) -> bool | None | str: + """Return whether the README metadata in pyproject.toml matches the repository README.""" if not is_mcp: return None if not file_exists(root, "pyproject.toml"): @@ -96,10 +122,11 @@ def check(root, is_mcp: bool, readme_path: str | None) -> bool | None | str: class MCP006(MCP): - "No TODO/FIXME in doc/source/index.rst" + """No TODO or FIXME markers appear in the docs index.""" @staticmethod def check(root, is_mcp: bool) -> bool | None: + """Return whether the documentation landing page is free of TODO and FIXME markers.""" if not is_mcp: return None if not file_exists(root, "doc/source/index.rst"): @@ -108,10 +135,11 @@ def check(root, is_mcp: bool) -> bool | None: class MCP007(MCP): - "Security checks not bypassed" + """Security checks are not bypassed.""" @staticmethod def check(root, is_mcp: bool, workflow_map: dict) -> bool | None: + """Return whether the workflows do not disable or skip security validation.""" if not is_mcp: return None _, pr_content = wf_content(root, "pr", workflow_map) diff --git a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py index 0e9edeaa..15c33cc5 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py +++ b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Pre-commit configuration checks.""" @@ -25,36 +44,41 @@ class PreCommit: + """Pre-commit rule family.""" + family = "pre_commit" class PC001(PreCommit): - ".pre-commit-config.yaml exists" + """The .pre-commit-config.yaml file exists.""" @staticmethod def check(root) -> bool: + """Return whether the pre-commit config exists.""" return file_exists(root, ".pre-commit-config.yaml") class PC002(PreCommit): - "ruff-pre-commit configured" + """ruff-pre-commit is configured.""" requires = {"PC001"} @staticmethod def check(root) -> bool | None: + """Return whether ruff-pre-commit is present in the config.""" if not file_exists(root, ".pre-commit-config.yaml"): return None return file_contains(root, ".pre-commit-config.yaml", "ruff-pre-commit") class PC003(PreCommit): - "zizmor configured with --pedantic" + """zizmor is configured with the --pedantic flag.""" requires = {"PC001"} @staticmethod def check(root) -> bool | None | str: + """Return whether zizmor is configured with the pedantic option.""" if not file_exists(root, ".pre-commit-config.yaml"): return None has_zizmor = file_contains(root, ".pre-commit-config.yaml", "zizmor") @@ -67,72 +91,78 @@ def check(root) -> bool | None | str: class PC004(PreCommit): - "blacken-docs configured" + """blacken-docs is configured.""" requires = {"PC001"} @staticmethod def check(root) -> bool | None: + """Return whether blacken-docs is configured.""" if not file_exists(root, ".pre-commit-config.yaml"): return None return file_contains(root, ".pre-commit-config.yaml", "blacken-docs") class PC005(PreCommit): - "codespell configured" + """codespell is configured.""" requires = {"PC001"} @staticmethod def check(root) -> bool | None: + """Return whether codespell is configured.""" if not file_exists(root, ".pre-commit-config.yaml"): return None return file_contains(root, ".pre-commit-config.yaml", "codespell") class PC006(PreCommit): - "ansys/pre-commit-hooks configured" + """ansys/pre-commit-hooks is configured.""" requires = {"PC001"} @staticmethod def check(root) -> bool | None: + """Return whether the repository uses the shared Ansys pre-commit hook.""" if not file_exists(root, ".pre-commit-config.yaml"): return None return file_contains(root, ".pre-commit-config.yaml", "ansys/pre-commit-hooks") class PC007(PreCommit): - "google/yamlfmt configured" + """google/yamlfmt is configured.""" requires = {"PC001"} @staticmethod def check(root) -> bool | None: + """Return whether yamlfmt is configured.""" if not file_exists(root, ".pre-commit-config.yaml"): return None return file_contains(root, ".pre-commit-config.yaml", "yamlfmt") class PC008(PreCommit): - "pyright configured" + """pyright is configured.""" requires = {"PC001"} @staticmethod def check(root) -> bool | None: + """Return whether pyright is configured.""" if not file_exists(root, ".pre-commit-config.yaml"): return None return file_contains(root, ".pre-commit-config.yaml", "pyright") class PC009(PreCommit): - "autofix_prs: true enabled" + """autofix_prs: true is enabled.""" requires = {"PC001"} @staticmethod def check(root) -> bool | None | str: + """Return whether pull requests are configured to autogenerate fixes.""" if not file_exists(root, ".pre-commit-config.yaml"): return None if file_contains(root, ".pre-commit-config.yaml", "autofix_prs: true"): @@ -141,12 +171,13 @@ def check(root) -> bool | None | str: class PC010(PreCommit): - "autoupdate_schedule: weekly" + """autoupdate_schedule: weekly is configured.""" requires = {"PC001"} @staticmethod def check(root) -> bool | None | str: + """Return whether the pre-commit autoupdate schedule is weekly.""" if not file_exists(root, ".pre-commit-config.yaml"): return None if file_contains( diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 82e867fd..611ee737 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Project metadata checks.""" @@ -7,7 +26,10 @@ import re -from .common import file_contains, file_content, file_exists +from ansys.pre_commit_hooks.quality_rules.common import ( + file_content, + file_exists, +) __all__ = [ "ProjectMetadata", @@ -26,62 +48,71 @@ class ProjectMetadata: + """Project metadata rule family.""" + family = "project_metadata" class PM001(ProjectMetadata): - "AUTHORS exists" + """The AUTHORS file exists.""" @staticmethod def check(root) -> bool: + """Return whether the AUTHORS file is present.""" return file_exists(root, "AUTHORS") class PM002(ProjectMetadata): - "CHANGELOG.md exists" + """The CHANGELOG.md file exists.""" @staticmethod def check(root) -> bool: + """Return whether the changelog file is present.""" return file_exists(root, "CHANGELOG.md") class PM003(ProjectMetadata): - "CODE_OF_CONDUCT.md exists" + """The CODE_OF_CONDUCT.md file exists.""" @staticmethod def check(root) -> bool: + """Return whether the code of conduct file is present.""" return file_exists(root, "CODE_OF_CONDUCT.md") class PM004(ProjectMetadata): - "CONTRIBUTING.md exists" + """The CONTRIBUTING.md file exists.""" @staticmethod def check(root) -> bool: + """Return whether the contributing guide is present.""" return file_exists(root, "CONTRIBUTING.md") class PM005(ProjectMetadata): - "CONTRIBUTORS.md exists" + """The CONTRIBUTORS.md file exists.""" @staticmethod def check(root) -> bool: + """Return whether the contributors file is present.""" return file_exists(root, "CONTRIBUTORS.md") class PM006(ProjectMetadata): - "LICENSE exists" + """The LICENSE file exists.""" @staticmethod def check(root) -> bool: + """Return whether the license file is present.""" return file_exists(root, "LICENSE") class PM007(ProjectMetadata): - "README exists (.rst preferred)" + """README exists, with README.rst preferred.""" @staticmethod def check(root, readme_path: str | None) -> bool | str: + """Return whether the README is present and in the preferred format.""" if readme_path is None: return False if readme_path == "README.md": @@ -90,28 +121,31 @@ def check(root, readme_path: str | None) -> bool | str: class PM008(ProjectMetadata): - "SECURITY.md exists" + """The SECURITY.md file exists.""" @staticmethod def check(root) -> bool: + """Return whether the security policy file is present.""" return file_exists(root, "SECURITY.md") class PM009(ProjectMetadata): - ".github/CODEOWNERS exists" + """The .github/CODEOWNERS file exists.""" @staticmethod def check(root) -> bool: + """Return whether the code owners file is present.""" return file_exists(root, ".github/CODEOWNERS") class PM010(ProjectMetadata): - "pyproject.toml references README file" + """pyproject.toml references the README file.""" requires = {"PM007"} @staticmethod def check(root, readme_path: str | None) -> bool | None | str: + """Return whether pyproject.toml references the expected README file.""" if not file_exists(root, "pyproject.toml"): return None content = file_content(root, "pyproject.toml") @@ -133,12 +167,13 @@ def check(root, readme_path: str | None) -> bool | None | str: class PM011(ProjectMetadata): - "pyproject.toml references LICENSE file" + """pyproject.toml references the LICENSE file.""" requires = {"PM006"} @staticmethod def check(root) -> bool | None: + """Return whether pyproject.toml references the license file.""" if not file_exists(root, "pyproject.toml"): return None c = file_content(root, "pyproject.toml") diff --git a/src/ansys/pre_commit_hooks/quality_rules/readme.py b/src/ansys/pre_commit_hooks/quality_rules/readme.py index 9b41b0cc..e71fc9b3 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/readme.py +++ b/src/ansys/pre_commit_hooks/quality_rules/readme.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """README checks.""" @@ -7,7 +26,7 @@ import re -from .common import file_contains +from ansys.pre_commit_hooks.quality_rules.common import file_contains __all__ = [ "README", @@ -24,14 +43,17 @@ class README: + """README rule family.""" + family = "readme" class RM000(README): - "README file exists" + """README file exists.""" @staticmethod def check(root, readme_path: str | None) -> bool | str: + """Return whether the repository has a supported README file.""" if readme_path == "README.rst": return True if readme_path == "README.md": @@ -40,19 +62,20 @@ def check(root, readme_path: str | None) -> bool | str: class RM001(README): - "README has PyAnsys badge" + """README has a PyAnsys badge.""" requires = {"RM000"} @staticmethod def check(root, readme_path: str | None) -> bool | None | str: + """Return whether the README contains a PyAnsys badge.""" if not readme_path: return None if file_contains( root, readme_path, re.compile( - r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", + r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", # noqa: E501 re.I, ), ): @@ -61,12 +84,13 @@ def check(root, readme_path: str | None) -> bool | None | str: class RM002(README): - "README has PyPI badge" + """README has a PyPI badge.""" requires = {"RM000"} @staticmethod def check(root, readme_path: str | None) -> bool | None | str: + """Return whether the README contains a PyPI badge.""" if not readme_path: return None if file_contains( @@ -82,12 +106,13 @@ def check(root, readme_path: str | None) -> bool | None | str: class RM003(README): - "README has Codecov badge" + """README has a Codecov badge.""" requires = {"RM000"} @staticmethod def check(root, readme_path: str | None) -> bool | None | str: + """Return whether the README contains a Codecov badge.""" if not readme_path: return None if file_contains( @@ -100,12 +125,13 @@ def check(root, readme_path: str | None) -> bool | None | str: class RM004(README): - "README has MIT license badge" + """README has an MIT license badge.""" requires = {"RM000"} @staticmethod def check(root, readme_path: str | None) -> bool | None | str: + """Return whether the README contains an MIT license badge.""" if not readme_path: return None if file_contains( @@ -118,12 +144,13 @@ def check(root, readme_path: str | None) -> bool | None | str: class RM005(README): - "README has GH-CI badge" + """README has a GH-CI badge.""" requires = {"RM000"} @staticmethod def check(root, readme_path: str | None) -> bool | None | str: + """Return whether the README contains a GitHub Actions badge.""" if not readme_path: return None if file_contains( @@ -136,36 +163,39 @@ def check(root, readme_path: str | None) -> bool | None | str: class RM006(README): - "README has Installation section" + """README has an installation section.""" requires = {"RM000"} @staticmethod def check(root, readme_path: str | None) -> bool | None: + """Return whether the README mentions installation instructions.""" if not readme_path: return None return file_contains(root, readme_path, re.compile(r"install", re.I)) class RM007(README): - "README has Documentation section" + """README has a documentation section.""" requires = {"RM000"} @staticmethod def check(root, readme_path: str | None) -> bool | None: + """Return whether the README contains a documentation section.""" if not readme_path: return None return file_contains(root, readme_path, re.compile(r"documentation", re.I)) class RM008(README): - "README has License section" + """README has a license section.""" requires = {"RM000"} @staticmethod def check(root, readme_path: str | None) -> bool | None: + """Return whether the README contains a license section.""" if not readme_path: return None return file_contains(root, readme_path, re.compile(r"license", re.I)) diff --git a/src/ansys/pre_commit_hooks/quality_rules/security.py b/src/ansys/pre_commit_hooks/quality_rules/security.py index e033368a..3e0bb018 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/security.py +++ b/src/ansys/pre_commit_hooks/quality_rules/security.py @@ -1,5 +1,24 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Security checks.""" @@ -7,52 +26,58 @@ import re -from .common import file_contains, file_exists, wf_content +from ansys.pre_commit_hooks.quality_rules.common import file_contains, file_exists, wf_content __all__ = ["Security", "SEC001", "SEC002", "SEC003", "SEC004", "SEC005"] class Security: + """Security rule family.""" + family = "security" class SEC001(Security): - ".github/zizmor.yml exists" + """The .github/zizmor.yml file exists.""" @staticmethod def check(root) -> bool | str: + """Return whether the Zizmor config is present.""" if file_exists(root, ".github/zizmor.yml"): return True return "⚠️ .github/zizmor.yml not found — optional but recommended." class SEC002(Security): - "zizmor.yml has secrets-outside-env rule" + """The zizmor config includes the secrets-outside-env rule.""" requires = {"SEC001"} @staticmethod def check(root) -> bool | None: + """Return whether the Zizmor config enables the secrets-outside-env rule.""" if not file_exists(root, ".github/zizmor.yml"): return None return file_contains(root, ".github/zizmor.yml", "secrets-outside-env") class SEC003(Security): - "gitleaks hook configured" + """The gitleaks hook is configured.""" @staticmethod def check(root) -> bool | None: + """Return whether the pre-commit config includes gitleaks.""" if not file_exists(root, ".pre-commit-config.yaml"): return None return file_contains(root, ".pre-commit-config.yaml", "gitleaks") class SEC004(Security): - "Workflows pin action SHAs" + """Workflows pin action SHAs.""" @staticmethod def check(root, workflow_map: dict) -> bool | None | str: + """Return whether the PR workflow pins GitHub Actions to full SHAs.""" _, content = wf_content(root, "pr", workflow_map) if not content: return None @@ -62,12 +87,13 @@ def check(root, workflow_map: dict) -> bool | None | str: class SEC005(Security): - "SECURITY.md discourages public issue reporting" + """SECURITY.md discourages public issue reporting.""" requires = {"PM008"} @staticmethod def check(root) -> bool | None | str: + """Return whether the security policy discourages public issue reporting.""" if not file_exists(root, "SECURITY.md"): return None if file_contains(root, "SECURITY.md", re.compile(r"do not|don't|please don", re.I)): diff --git a/src/ansys/pre_commit_hooks/quality_rules/vale.py b/src/ansys/pre_commit_hooks/quality_rules/vale.py index 456fb314..9b2faa69 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/vale.py +++ b/src/ansys/pre_commit_hooks/quality_rules/vale.py @@ -1,66 +1,92 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Vale checks.""" from __future__ import annotations -from .common import file_contains, file_exists +from ansys.pre_commit_hooks.quality_rules.common import file_contains, file_exists __all__ = ["Vale", "VL001", "VL002", "VL003", "VL004", "VL005"] class Vale: + """Vale rule family.""" + family = "vale" class VL001(Vale): - "doc/.vale.ini exists" + """The doc/.vale.ini file exists.""" @staticmethod def check(root) -> bool: + """Return whether the Vale config exists.""" return file_exists(root, "doc/.vale.ini") class VL002(Vale): - "Vale uses Google style package" + """Vale uses the Google style package.""" requires = {"VL001"} @staticmethod def check(root) -> bool | None: + """Return whether the Vale config targets the Google style package.""" if not file_exists(root, "doc/.vale.ini"): return None return file_contains(root, "doc/.vale.ini", "Google") class VL003(Vale): - "Vale uses ANSYS vocabulary" + """Vale uses the ANSYS vocabulary.""" requires = {"VL001"} @staticmethod def check(root) -> bool | None: + """Return whether the Vale config references the ANSYS vocabulary.""" if not file_exists(root, "doc/.vale.ini"): return None return file_contains(root, "doc/.vale.ini", "ANSYS") class VL004(Vale): - "ANSYS accept.txt vocabulary exists" + """The ANSYS accept.txt vocabulary exists.""" requires = {"VL001"} @staticmethod def check(root) -> bool: + """Return whether the accepted vocabulary file exists.""" return file_exists(root, "doc/styles/config/vocabularies/ANSYS/accept.txt") class VL005(Vale): - "ANSYS reject.txt vocabulary exists" + """The ANSYS reject.txt vocabulary exists.""" requires = {"VL001"} @staticmethod def check(root) -> bool: + """Return whether the rejected vocabulary file exists.""" return file_exists(root, "doc/styles/config/vocabularies/ANSYS/reject.txt") diff --git a/tests/test_tech_review.py b/tests/test_tech_review.py index 7e449686..e231f83f 100644 --- a/tests/test_tech_review.py +++ b/tests/test_tech_review.py @@ -120,7 +120,9 @@ def test_fix_missing_mode_bootstraps_repo_files(tmp_path: pytest.TempPathFactory setup_repo(tmp_path) os.chdir(tmp_path) - exit_code = quality_hook.main(["--repo-root", str(tmp_path), "--fix-missing", "--product=techreview"]) + exit_code = quality_hook.main( + ["--repo-root", str(tmp_path), "--fix-missing", "--product=techreview"] + ) assert exit_code == 1 assert pathlib.Path.exists(tmp_path / ".github") From 4e941ab46e4d4cb678882c82d010de98ba8e939f Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 18:38:03 +0530 Subject: [PATCH 14/49] fix: pre-commit --- .../quality_rules/__init__.py | 97 ++++++++++++++++--- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index c0a4c8bf..77c05b57 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -1,12 +1,37 @@ # Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. """Rule definitions for the PyAnsys repository quality report.""" from __future__ import annotations -from .build_system import BS001, BS002, BS003, BS004, BuildSystem -from .cicd import ( +from ansys.pre_commit_hooks.quality_rules.build_system import ( + BS001, + BS002, + BS003, + BS004, + BuildSystem, +) +from ansys.pre_commit_hooks.quality_rules.cicd import ( CI004, CI005, CI006, @@ -22,8 +47,8 @@ CI016, CICD, ) -from .cicd_files import CI001, CI002, CI003, CICDFiles -from .common import ( +from ansys.pre_commit_hooks.quality_rules.cicd_files import CI001, CI002, CI003, CICDFiles +from ansys.pre_commit_hooks.quality_rules.common import ( CANONICAL_WF, _first_doc_line, _interpret, @@ -37,11 +62,39 @@ wf_label, workflow_map, ) -from .dependabot import DB001, DB002, DB003, DB004, DB005, DB006, DB007, DB008, Dependabot -from .documentation import DOC001, DOC002, DOC003, DOC004, DOC005, DOC006, DOC007, Documentation -from .labeler import LB001, LB002, LB003, LB004, LB005, Labeler -from .mcp import MCP, MCP001, MCP002, MCP003, MCP004, MCP005, MCP006, MCP007 -from .pre_commit import ( +from ansys.pre_commit_hooks.quality_rules.dependabot import ( + DB001, + DB002, + DB003, + DB004, + DB005, + DB006, + DB007, + DB008, + Dependabot, +) +from ansys.pre_commit_hooks.quality_rules.documentation import ( + DOC001, + DOC002, + DOC003, + DOC004, + DOC005, + DOC006, + DOC007, + Documentation, +) +from ansys.pre_commit_hooks.quality_rules.labeler import LB001, LB002, LB003, LB004, LB005, Labeler +from ansys.pre_commit_hooks.quality_rules.mcp import ( + MCP, + MCP001, + MCP002, + MCP003, + MCP004, + MCP005, + MCP006, + MCP007, +) +from ansys.pre_commit_hooks.quality_rules.pre_commit import ( PC001, PC002, PC003, @@ -54,7 +107,7 @@ PC010, PreCommit, ) -from .project_metadata import ( +from ansys.pre_commit_hooks.quality_rules.project_metadata import ( PM001, PM002, PM003, @@ -68,9 +121,27 @@ PM011, ProjectMetadata, ) -from .readme import README, RM000, RM001, RM002, RM003, RM004, RM005, RM006, RM007, RM008 -from .security import SEC001, SEC002, SEC003, SEC004, SEC005, Security -from .vale import VL001, VL002, VL003, VL004, VL005, Vale +from ansys.pre_commit_hooks.quality_rules.readme import ( + README, + RM000, + RM001, + RM002, + RM003, + RM004, + RM005, + RM006, + RM007, + RM008, +) +from ansys.pre_commit_hooks.quality_rules.security import ( + SEC001, + SEC002, + SEC003, + SEC004, + SEC005, + Security, +) +from ansys.pre_commit_hooks.quality_rules.vale import VL001, VL002, VL003, VL004, VL005, Vale __all__ = [ "file_exists", From 8e1545b2c02be34abbd229646235dd4adfae54eb Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 19:04:19 +0530 Subject: [PATCH 15/49] fix: merge old tech review --- .../pyansys_quality_report.py | 73 +++---------------- tests/test_pyansys_quality_report.py | 33 +++++++++ 2 files changed, 42 insertions(+), 64 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 5f443a33..cf563b66 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -258,7 +258,7 @@ def _print_report(review: dict[str, Any], *, show_passes: bool = False) -> None: def main(argv: list[str] | None = None) -> int: - """Run the PyAnsys repository quality report.""" + """Run the repository bootstrap and the PyAnsys quality report.""" parser = argparse.ArgumentParser(description="Run the PyAnsys repository quality report.") parser.add_argument("--repo-root", default=".", help="Repository root to review.") parser.add_argument( @@ -278,6 +278,7 @@ def main(argv: list[str] | None = None) -> int: if not repo_root.exists(): raise FileNotFoundError(f"Repo root not found: {repo_root}") + legacy_exit = 0 if args.fix_missing: legacy_argv: list[str] = [] raw_argv = list(argv) if argv is not None else list(__import__("sys").argv[1:]) @@ -299,80 +300,24 @@ def main(argv: list[str] | None = None) -> int: current_dir = Path.cwd() os.chdir(repo_root) try: - return tech_review.main(legacy_argv) + legacy_exit = tech_review.main(legacy_argv) finally: os.chdir(current_dir) - files = _load_files(repo_root) - review = _run_checks(files, is_mcp_flag=is_mcp(MemoryTraversable(files))) - - if args.json: - print(json.dumps(review, indent=2)) - return 1 if review["tally"]["fail"] else 0 - - _print_report(review, show_passes=args.all) - return 1 if review["tally"]["fail"] else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) - - -def main(argv: list[str] | None = None) -> int: - """Run the PyAnsys repository quality report.""" - parser = argparse.ArgumentParser(description="Run the PyAnsys repository quality report.") - parser.add_argument("--repo-root", default=".", help="Repository root to review.") - parser.add_argument( - "--json", action="store_true", help="Emit a JSON report instead of a text summary." - ) - parser.add_argument( - "--all", action="store_true", help="Show all checks, including passing ones." - ) - parser.add_argument( - "--fix-missing", - action="store_true", - help="Generate missing repository scaffolding before running the quality report.", - ) - args, unknown = parser.parse_known_args(argv) - - repo_root = Path(args.repo_root).resolve() - if not repo_root.exists(): - raise FileNotFoundError(f"Repo root not found: {repo_root}") - - if args.fix_missing: - legacy_argv: list[str] = [] - raw_argv = list(argv) if argv is not None else list(__import__("sys").argv[1:]) - - idx = 0 - while idx < len(raw_argv): - token = raw_argv[idx] - if token in {"--repo-root", "--json", "--all", "--fix-missing"}: - idx += 1 - if token == "--repo-root" and idx < len(raw_argv): - idx += 1 - continue - legacy_argv.append(token) - idx += 1 - - if unknown: - legacy_argv.extend(unknown) - - current_dir = Path.cwd() - os.chdir(repo_root) - try: - return tech_review.main(legacy_argv) - finally: - os.chdir(current_dir) + if legacy_exit == 0: + print("\nLegacy tech-review bootstrap complete.") + else: + print(f"\nLegacy tech-review bootstrap reported exit code {legacy_exit}.") files = _load_files(repo_root) review = _run_checks(files, is_mcp_flag=is_mcp(MemoryTraversable(files))) if args.json: print(json.dumps(review, indent=2)) - return 1 if review["tally"]["fail"] else 0 + return 1 if review["tally"]["fail"] or legacy_exit else 0 _print_report(review, show_passes=args.all) - return 1 if review["tally"]["fail"] else 0 + return 1 if review["tally"]["fail"] or legacy_exit else 0 if __name__ == "__main__": diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 41ea1ce6..0f2a1f9a 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -40,6 +40,39 @@ def test_main_reports_quality_summary(tmp_path, capsys): assert "Score" in output or "Summary" in output +def test_fix_missing_runs_quality_report_after_bootstrap(tmp_path, capsys): + """--fix-missing should bootstrap the repo and then continue to the quality report.""" + repo_path = tmp_path / "quality-demo" + repo_path.mkdir() + os.chdir(repo_path) + git.Repo.init(repo_path) + repo = git.Repo(repo_path) + repo.index.commit("initial") + + (repo_path / ".github").mkdir() + (repo_path / "src").mkdir() + (repo_path / "tests").mkdir() + (repo_path / "doc").mkdir() + (repo_path / "LICENSE").write_text("MIT\n", encoding="utf-8") + (repo_path / "pyproject.toml").write_text( + """ +[project] +name = "ansys-demo-library" +version = "0.1.0" +authors = [{name = "Example", email = "example@example.com"}] +maintainers = [{name = "Example", email = "example@example.com"}] +""".strip(), + encoding="utf-8", + ) + + exit_code = hook.main(["--repo-root", str(repo_path), "--fix-missing", "--product=techreview"]) + output = capsys.readouterr().out + + assert exit_code in (0, 1) + assert "PyAnsys quality report" in output + assert "Score" in output or "Summary" in output + + def test_main_colors_status_labels(tmp_path, capsys): """The console report should colorize pass, warn, and fail states.""" repo_path = tmp_path / "quality-demo" From 6a4e20228378fc362bf675d182132a88b76877c4 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 19:23:29 +0530 Subject: [PATCH 16/49] fix: merge old tech review --- .pre-commit-hooks.yaml | 6 -- setup.py | 1 - .../pyansys_quality_report.py | 1 + .../quality_rules/__init__.py | 8 ++ .../quality_rules/project_metadata.py | 87 +++++++++++++++++++ 5 files changed, 96 insertions(+), 7 deletions(-) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 2d4c4749..4b2fd3be 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -5,12 +5,6 @@ language: python files: '(src|examples|tests)/.*\.(py)|\.(proto)' require_serial: true -- id: "tech-review" - name: "Ansys Technical Review" - description: "Perform initial technical review on a repository" - entry: tech-review - language: python - pass_filenames: false - id: "pyansys-quality-report" name: "PyAnsys Quality Report" description: "Generate a PyAnsys repository quality summary" diff --git a/setup.py b/setup.py index ec754913..992d5cc3 100644 --- a/setup.py +++ b/setup.py @@ -73,7 +73,6 @@ entry_points={ "console_scripts": [ "add-license-headers=ansys.pre_commit_hooks.add_license_headers:main", - "tech-review=ansys.pre_commit_hooks.tech_review:main", "pyansys-quality-report=ansys.pre_commit_hooks.pyansys_quality_report:main", ], }, diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index cf563b66..498bbbf3 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -278,6 +278,7 @@ def main(argv: list[str] | None = None) -> int: if not repo_root.exists(): raise FileNotFoundError(f"Repo root not found: {repo_root}") + legacy_exit = 0 legacy_exit = 0 if args.fix_missing: legacy_argv: list[str] = [] diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index 77c05b57..c2ca9906 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -119,6 +119,10 @@ PM009, PM010, PM011, + PM012, + PM013, + PM014, + PM015, ProjectMetadata, ) from ansys.pre_commit_hooks.quality_rules.readme import ( @@ -170,6 +174,10 @@ "PM009", "PM010", "PM011", + "PM012", + "PM013", + "PM014", + "PM015", "CICDFiles", "CI001", "CI002", diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 611ee737..261b9fce 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -44,6 +44,10 @@ "PM009", "PM010", "PM011", + "PM012", + "PM013", + "PM014", + "PM015", ] @@ -184,3 +188,86 @@ def check(root) -> bool | None: or re.search(r"license\s*=\s*\{[^}]*file", c) or re.search(r'license\s*=\s*["\']LICENSE["\']', c) ) + + +class PM012(ProjectMetadata): + """Project name follows the ansys-*-* convention.""" + + @staticmethod + def check(root) -> bool | None | str: + """Return whether the project name matches the PyAnsys naming convention.""" + if not file_exists(root, "pyproject.toml"): + return None + content = file_content(root, "pyproject.toml") + match = re.search(r'^name\s*=\s*["\']([^"\']+)["\']', content, re.M) + if not match: + return "⚠️ project name not found in pyproject.toml." + name = match.group(1) + if re.fullmatch(r"ansys-[a-z0-9-]+-[a-z0-9-]+", name): + return True + return f"⚠️ project name '{name}' does not match ansys-*-*." + + +class PM013(ProjectMetadata): + """Project version follows semantic versioning.""" + + @staticmethod + def check(root) -> bool | None | str: + """Return whether the project version uses semantic versioning.""" + if not file_exists(root, "pyproject.toml"): + return None + content = file_content(root, "pyproject.toml") + match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.M) + if not match: + return "⚠️ project version not found in pyproject.toml." + version = match.group(1) + if re.fullmatch(r"\d+\.\d+\.\d+(?:[.-]?(?:a|b|rc|dev)\d+)?", version): + return True + return f"⚠️ project version '{version}' does not follow semantic versioning." + + +class PM014(ProjectMetadata): + """Project author and maintainer metadata matches the PyAnsys defaults.""" + + @staticmethod + def check(root) -> bool | None | str: + """Return whether author and maintainer metadata are configured as expected.""" + if not file_exists(root, "pyproject.toml"): + return None + content = file_content(root, "pyproject.toml") + name_ok = bool(re.search(r'authors\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', content)) + email_ok = bool(re.search(r'authors\s*=\s*\[[\s\S]*?email\s*=\s*["\']pyansys-core@synopsys.com["\']', content)) + maintainer_name_ok = bool( + re.search( + r'maintainers\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', + content, + ) + ) + maintainer_email_ok = bool( + re.search( + r'maintainers\s*=\s*\[[\s\S]*?email\s*=\s*["\']pyansys-core@synopsys.com["\']', + content, + ) + ) + if name_ok and email_ok and maintainer_name_ok and maintainer_email_ok: + return True + return ( + "⚠️ author/maintainer metadata does not match " + "Synopsys, Inc. and ANSYS, Inc. / pyansys-core@synopsys.com." + ) + + +class PM015(ProjectMetadata): + """The LICENSE file includes the MIT License wording.""" + + requires = {"PM006"} + + @staticmethod + def check(root) -> bool | None: + """Return whether the LICENSE file contains the expected MIT License text.""" + if not file_exists(root, "LICENSE"): + return None + content = file_content(root, "LICENSE") + if "MIT License" in content: + return True + return "⚠️ LICENSE file content is missing \"MIT License\"." From a5fe847d2a25ba9d9ffd4d7dc7cf83cf3a414902 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:53:48 +0000 Subject: [PATCH 17/49] chore: auto fixes from pre-commit hooks --- .../quality_rules/project_metadata.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 261b9fce..d62abca5 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -235,8 +235,17 @@ def check(root) -> bool | None | str: if not file_exists(root, "pyproject.toml"): return None content = file_content(root, "pyproject.toml") - name_ok = bool(re.search(r'authors\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', content)) - email_ok = bool(re.search(r'authors\s*=\s*\[[\s\S]*?email\s*=\s*["\']pyansys-core@synopsys.com["\']', content)) + name_ok = bool( + re.search( + r'authors\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', + content, + ) + ) + email_ok = bool( + re.search( + r'authors\s*=\s*\[[\s\S]*?email\s*=\s*["\']pyansys-core@synopsys.com["\']', content + ) + ) maintainer_name_ok = bool( re.search( r'maintainers\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', @@ -270,4 +279,4 @@ def check(root) -> bool | None: content = file_content(root, "LICENSE") if "MIT License" in content: return True - return "⚠️ LICENSE file content is missing \"MIT License\"." + return '⚠️ LICENSE file content is missing "MIT License".' From a15c09c55fc83192f95232c43e5c810fec4dc735 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 19:46:56 +0530 Subject: [PATCH 18/49] fix: codestyle --- .../quality_rules/project_metadata.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 261b9fce..a3940110 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -235,11 +235,20 @@ def check(root) -> bool | None | str: if not file_exists(root, "pyproject.toml"): return None content = file_content(root, "pyproject.toml") - name_ok = bool(re.search(r'authors\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', content)) - email_ok = bool(re.search(r'authors\s*=\s*\[[\s\S]*?email\s*=\s*["\']pyansys-core@synopsys.com["\']', content)) + name_ok = bool( + re.search( + r'authors\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', + content, + ) + ) + email_ok = bool( + re.search( + r'authors\s*=\s*\[[\s\S]*?email\s*=\s*["\']pyansys-core@synopsys.com["\']', content + ) + ) maintainer_name_ok = bool( re.search( - r'maintainers\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', + r'maintainers\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', # noqa: E501 content, ) ) @@ -270,4 +279,4 @@ def check(root) -> bool | None: content = file_content(root, "LICENSE") if "MIT License" in content: return True - return "⚠️ LICENSE file content is missing \"MIT License\"." + return '⚠️ LICENSE file content is missing "MIT License".' From b114f1417ae614d82148be106db1673767c7db13 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 20:16:39 +0530 Subject: [PATCH 19/49] fix: codes --- .../pyansys_quality_report.py | 484 ++++++++++++++++-- .../pre_commit_hooks/quality_rules/common.py | 18 +- 2 files changed, 460 insertions(+), 42 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 498bbbf3..fe33a6cf 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -12,14 +12,18 @@ import argparse from collections.abc import Iterator +from datetime import date as dt +from enum import Enum +import filecmp from io import BytesIO, StringIO +from itertools import product import json import os from pathlib import Path +import re +from tempfile import NamedTemporaryFile from typing import Any -from ansys.pre_commit_hooks import tech_review - try: from importlib.resources.abc import Traversable except ImportError: # pragma: no cover @@ -65,6 +69,49 @@ ] +HOOK_PATH = Path(__file__).parent.resolve() +"""Location of the pre-commit hook on your system.""" + +LICENSES_JSON = HOOK_PATH / "assets" / "licenses.json" +"""JSON file containing licenses information.""" + +DEFAULT_AUTHOR_MAINT_NAME = "Synopsys, Inc. and ANSYS, Inc." +"""Default name of project authors and maintainers.""" + +DEFAULT_AUTHOR_MAINT_EMAIL = "pyansys-core@synopsys.com" +"""Default email of project authors and maintainers.""" + +DEFAULT_START_YEAR = dt.today().year +"""Default start year of the repository.""" + +DEFAULT_LICENSE = "MIT" +"""Default license of the repository.""" + +JSON_URL = "https://raw.githubusercontent.com/spdx/license-list-data/main/json/licenses.json" +"""URL to retrieve list of license IDs and names.""" + + +class Filenames(Enum): + """Enum of files to check.""" + + AUTHORS = "AUTHORS" + CODE_OF_CONDUCT = "CODE_OF_CONDUCT.md" + CONTRIBUTING = "CONTRIBUTING.md" + CONTRIBUTORS = "CONTRIBUTORS.md" + LICENSE = "LICENSE" + README = "README" + DEPENDABOT = "dependabot.yml" + + +class Directories(Enum): + """Enum of directories to check.""" + + GITHUB = ".github" + DOC = "doc" + SRC = "src" + TESTS = "tests" + + class MemoryTraversable(Traversable): """In-memory Traversable backed by a flat dict mapping path -> content.""" @@ -138,6 +185,343 @@ def __str__(self) -> str: return self._path +def check_dirs_exist(repo_path: Path | str, is_compliant: bool, directories: list[str]) -> bool: + """Check folders exist in the root of the git repository.""" + repo_path = Path(repo_path) + for directory in directories: + full_path = repo_path / directory + if not full_path.exists(): + is_compliant = False + print(f'The "{directory}" directory does not exist. Creating the "{directory}" directory...') + full_path.mkdir(parents=True, exist_ok=True) + + if not is_compliant: + print("") + + return is_compliant + + +def check_config_file( + repo_path: Path | str, + author_maint_name: str, + author_maint_email: str, + is_compliant: bool, + non_compliant_name: bool, +) -> tuple[bool, str, str]: + """Check naming convention, version, author, and maintainer information.""" + repo_path = Path(repo_path) + has_pyproject = (repo_path / "pyproject.toml").exists() + has_setup = (repo_path / "setup.py").exists() + + if (has_pyproject and has_setup) or (has_setup and not has_pyproject): + config_file = "setuptools" + is_compliant, project_name = check_setup_py(author_maint_name, author_maint_email, is_compliant) + elif has_pyproject and not has_setup: + config_file = "pyproject" + is_compliant, project_name = check_pyproject_toml( + repo_path, author_maint_name, author_maint_email, is_compliant, non_compliant_name + ) + else: + config_file = "" + project_name = "" + print("The pyproject.toml and setup.py files do not exist") + print("Cannot get the author and maintainer name and email, project name, and version\n") + + return is_compliant, project_name, config_file + + +def check_pyproject_toml( + repo_path: Path | str, + author_maint_name: str, + author_maint_email: str, + is_compliant: bool, + non_compliant_name: bool, +) -> tuple[bool, str]: + """Check pyproject.toml file for correct naming convention, version, author, and maintainer.""" + repo_path = Path(repo_path) + name = "" + import toml + + with open(repo_path / "pyproject.toml", "r", encoding="utf-8") as project_file: + config = toml.load(project_file) + project = config.get("project", {}) + + if not non_compliant_name: + name = project.get("name", "DNE") + if (name == "DNE") or ((name != "DNE") and not bool(re.match(r"^ansys-[a-z]+-[a-z]+$", name))): + is_compliant = False + print("Project name does not follow naming conventions") + + project_version = project.get("version", "DNE") + if project_version != "DNE": + try: + import semver + + semver.Version.parse(project_version) + except ValueError: + if not bool(re.match(r"^[0-9]+.[0-9]+.dev[0-9]+$", project_version)): + is_compliant = False + print("Project version does not follow semantic versioning") + + for key, value in list(product(["authors", "maintainers"], ["name", "email"])): + project_value = project.get(key, "DNE")[0].get(value, "DNE") + if project_value == "DNE": + is_compliant = False + print(f"Project {key} {value} does not exist in the pyproject.toml file") + else: + if value == "email": + is_compliant = check_auth_maint( + project_value, author_maint_email, f"{key} {value}", is_compliant + ) + elif value == "name": + is_compliant = check_auth_maint( + project_value, author_maint_name, f"{key} {value}", is_compliant + ) + + return is_compliant, name + + +def check_auth_maint( + project_value: str, arg_value: str, err_string: str, is_compliant: bool +) -> bool: + """Check if the author and maintainer names and emails are the same.""" + if project_value != arg_value: + print(f"Project {err_string} is not {arg_value}") + is_compliant = False + return is_compliant + + +def check_setup_py( + author_maint_name: str, + author_maint_email: str, + is_compliant: bool, +) -> tuple[bool, str]: + """Check setup.py file for correct naming convention, version, author, and maintainer.""" + print("The setup.py check is not implemented. Please manually check the following:") + print("- The project name is ansys-*-*") + print("- The project uses semantic versioning (see https://semver.org/)") + print(f"- The author and maintainer name is {author_maint_name}") + print(f"- The author and maintainer email is {author_maint_email}\n") + return is_compliant, "" + + +def download_license_json(url: str, json_file: Path | str) -> bool: + """Download the licenses.json file and restructure it to only include the license ID and name.""" + json_file = Path(json_file) + if not json_file.exists(): + import requests + + response = requests.get(url, timeout=60) + status_code = response.status_code + if status_code == 200: + json_file.write_text(response.text, encoding="utf-8") + restructure_json(json_file) + else: + print("There was a problem downloading license.json. Skipping LICENSE content check") + return False + return True + + +def restructure_json(file: Path | str): + """Remove extra information from licenses.json file.""" + file = Path(file) + licenseid_name_dict = {} + with open(file, "r", encoding="utf-8") as json_file: + existing_json = json.load(json_file) + for license in existing_json["licenses"]: + if not license["isDeprecatedLicenseId"]: + licenseid_name_dict[license["licenseId"]] = license["name"] + with open(file, "w", encoding="utf-8") as json_file: + json_file.write(json.dumps(licenseid_name_dict, indent=4)) + + +def generate_file_from_jinja( + file: str, + project_name: str, + year_str: str, + repo_url: str, + product: str | None, + config_file: str, + doc_repo_name: str, +) -> str: + """Generate file using jinja templates.""" + from jinja2 import Environment, FileSystemLoader + + loader = FileSystemLoader(searchpath=Path.joinpath(HOOK_PATH, "templates")) + env = Environment(loader=loader) # nosec + template = env.get_template(file) + return template.render( + doc_repo_name=doc_repo_name, + project_name=project_name, + year_span=year_str, + repository_url=repo_url, + product=product, + config_file=config_file, + ) + + +def write_content(message: str, file_path: Path | str, file_content: str): + """Write generated content from jinja template to a file.""" + print(message) + Path(file_path).write_text(file_content, encoding="utf-8") + + +def check_file_exists( + repo_path: Path | str, + files: list[str], + project_name: str, + start_year: int, + is_compliant: bool, + license: str, + repository_url: str, + product: str | None, + config_file: str, + doc_repo_name: str, +) -> bool: + """Check files exist; if missing, generate them from the template.""" + repo_path = Path(repo_path) + year_str = start_year if start_year == DEFAULT_START_YEAR else f"{start_year} - {DEFAULT_START_YEAR}" + ref_dict = { + "AUTHORS": "the-authors-file", + "CODE_OF_CONDUCT.md": "the-code-of-conduct-md-file", + "CONTRIBUTING.md": "the-contributing-md-file", + "CONTRIBUTORS.md": "the-contributors-md-file", + "LICENSE": "the-license-file", + "README.rst": "the-readme-file", + "README.md": "the-readme-file", + } + + for file_name in files: + if "dependabot" in file_name: + repo_file_path = repo_path / ".github" / file_name + else: + if "README" in file_name: + if (repo_path / f"{file_name}.md").exists(): + file_name = f"{file_name}.md" + else: + file_name = f"{file_name}.rst" + repo_file_path = repo_path / file_name + + file_content = generate_file_from_jinja( + file_name, project_name, year_str, repository_url, product, config_file, doc_repo_name + ) + + if "AUTHORS" in file_name and (repo_path / f"{file_name}.md").exists(): + repo_file_path = repo_path / f"{file_name}.md" + + if not repo_file_path.exists(): + is_compliant = False + dne_message = f"{file_name} does not exist. Creating file from template..." + if "setuptools" in config_file: + if "dependabot" in file_name: + write_content(dne_message, repo_file_path, file_content) + else: + tech_review_docs = ( + f"https://dev.docs.pyansys.com/packaging/structure.html#{ref_dict[file_name]}" + ) + print(f"{file_name} does not exist. Please see {tech_review_docs}") + else: + if "README" in file_name and product is None: + print("The --product argument is required to generate the README file.") + elif "README" in file_name and project_name == "": + print("The project_name is required to generate the README file.") + elif "dependabot" in file_name and config_file == "": + print("The config_file type is required to generate the dependabot.yml file.") + elif "AUTHORS" in file_name and project_name == "": + print("The project_name is required to generate the AUTHORS file.") + else: + write_content(dne_message, repo_file_path, file_content) + else: + if file_name in (Filenames.CONTRIBUTORS.value, Filenames.LICENSE.value): + is_compliant = check_file_content(repo_file_path, file_content, is_compliant, license) + + return is_compliant + + +def check_file_content(file: Path | str, generated_content: str, is_compliant: bool, license: str) -> bool: + """Check the file content of the LICENSE and CONTRIBUTORS.md files.""" + file = Path(file) + generated_file = NamedTemporaryFile(mode="w", delete=False) + with open(generated_file.name, "w", encoding="utf-8") as f: + f.write(generated_content) + + same_files = filecmp.cmp(file, generated_file.name, shallow=False) + + if file.name == Filenames.CONTRIBUTORS.value and same_files: + is_compliant = False + print("Please update your CONTRIBUTORS.md file.") + elif file.name == Filenames.LICENSE.value: + downloaded = download_license_json(JSON_URL, LICENSES_JSON) + if downloaded: + license_line_found = False + with open(LICENSES_JSON, "r", encoding="utf-8") as f: + license_json = json.load(f) + license_full_name = license_json[license] + + with open(file, "r", encoding="utf-8") as license_file: + for line in license_file: + if license_full_name in line: + license_line_found = True + break + + if not license_line_found: + is_compliant = False + print(f'"The {Filenames.LICENSE.value} file content is missing "{license_full_name}"') + + return is_compliant + + +def _bootstrap_legacy_files( + repo_root: Path | str, + *, + author_maint_name: str, + author_maint_email: str, + license: str, + product: str | None, + repository_url: str | None, + non_compliant_name: bool, +) -> int: + """Apply the legacy tech-review bootstrap logic and file-content checks.""" + repo_root = Path(repo_root) + is_compliant = True + import git + + try: + git_repo = git.Repo(repo_root, search_parent_directories=True) + root = Path(git_repo.git.rev_parse("--show-toplevel")) + except (git.InvalidGitRepositoryError, git.GitCommandError): + root = repo_root + + try: + g = git.Git(root) + all_dates = g.log("--reverse", "--format=%ci") + start_year = int(all_dates[0:4]) if all_dates else DEFAULT_START_YEAR + except Exception: + start_year = DEFAULT_START_YEAR + + is_compliant = check_dirs_exist(root, is_compliant, [directory.value for directory in Directories]) + is_compliant, project_name, config_file = check_config_file( + root, author_maint_name, author_maint_email, is_compliant, non_compliant_name + ) + + check_exists_list = [file.value for file in Filenames] + doc_repo_name = root.name + repo_url = repository_url or f"https://github.com/ansys/{doc_repo_name}" + is_compliant = check_file_exists( + root, + check_exists_list, + project_name, + start_year, + is_compliant, + license, + repo_url, + product, + config_file, + doc_repo_name, + ) + return 0 if is_compliant else 1 + + def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, Any]: """Run the package-based repo review checks against an in-memory file set.""" root = MemoryTraversable(files) @@ -178,15 +562,27 @@ def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, An } ) - tally = {"pass": 0, "fail": 0, "warn": 0, "na": 0} + tally = {"passed": 0, "failed": 0, "warned": 0, "not_applicable": 0} for result in results: - tally[result["status"]] += 1 + if result["status"] == "pass": + tally["passed"] += 1 + elif result["status"] == "fail": + tally["failed"] += 1 + elif result["status"] == "warn": + tally["warned"] += 1 + else: + tally["not_applicable"] += 1 - scored = tally["pass"] + tally["fail"] - score = round(tally["pass"] / scored * 100) if scored else 0 + scored = tally["passed"] + tally["failed"] + score = round(tally["passed"] / scored * 100) if scored else 0 return { "results": results, - "tally": tally, + "tally": { + "pass": tally["passed"], + "fail": tally["failed"], + "warn": tally["warned"], + "na": tally["not_applicable"], + }, "score": score, "workflow_map": fixture_values["workflow_map"], "project_metadata": { @@ -219,14 +615,17 @@ def _load_files(repo_root: Path) -> dict[str, str | None]: def _style_status(status: str, text: str) -> str: """Style a status label for console output.""" - colors = { - "pass": "\033[32m", - "warn": "\033[33m", - "fail": "\033[31m", - "na": "\033[36m", - } + if status == "pass": + color = "\033[32m" + elif status == "warn": + color = "\033[33m" + elif status == "fail": + color = "\033[31m" + elif status == "na": + color = "\033[36m" + else: + color = "" reset = "\033[0m" - color = colors.get(status, "") return f"{color}{text}{reset}" if color else text @@ -272,36 +671,55 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="Generate missing repository scaffolding before running the quality report.", ) + parser.add_argument( + "--author_maint_name", + type=str, + help="Name of the authors and maintainers of the project.", + default=DEFAULT_AUTHOR_MAINT_NAME, + ) + parser.add_argument( + "--author_maint_email", + type=str, + help="Email of the authors and maintainers of the project.", + default=DEFAULT_AUTHOR_MAINT_EMAIL, + ) + parser.add_argument( + "--license", + type=str, + help="License that the repository uses.", + default=DEFAULT_LICENSE, + ) + parser.add_argument( + "--product", + type=str, + help="Ansys product that the repository is related to.", + ) + parser.add_argument( + "--url", + type=str, + help="The repository URL. For example, https://github.com/ansys/pymechanical", + ) + parser.add_argument("--non_compliant_name", action="store_true") args, unknown = parser.parse_known_args(argv) repo_root = Path(args.repo_root).resolve() if not repo_root.exists(): raise FileNotFoundError(f"Repo root not found: {repo_root}") - legacy_exit = 0 legacy_exit = 0 if args.fix_missing: - legacy_argv: list[str] = [] - raw_argv = list(argv) if argv is not None else list(__import__("sys").argv[1:]) - - idx = 0 - while idx < len(raw_argv): - token = raw_argv[idx] - if token in {"--repo-root", "--json", "--all", "--fix-missing"}: - idx += 1 - if token == "--repo-root" and idx < len(raw_argv): - idx += 1 - continue - legacy_argv.append(token) - idx += 1 - - if unknown: - legacy_argv.extend(unknown) - current_dir = Path.cwd() os.chdir(repo_root) try: - legacy_exit = tech_review.main(legacy_argv) + legacy_exit = _bootstrap_legacy_files( + repo_root, + author_maint_name=args.author_maint_name, + author_maint_email=args.author_maint_email, + license=args.license, + product=args.product, + repository_url=args.url, + non_compliant_name=args.non_compliant_name, + ) finally: os.chdir(current_dir) diff --git a/src/ansys/pre_commit_hooks/quality_rules/common.py b/src/ansys/pre_commit_hooks/quality_rules/common.py index f1409b5e..8593966f 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/common.py +++ b/src/ansys/pre_commit_hooks/quality_rules/common.py @@ -52,7 +52,7 @@ def file_exists(root: Traversable, path: str) -> bool: """Return whether a file exists under the repository root.""" try: return root.joinpath(path).is_file() - except Exception: + except (AttributeError, TypeError, ValueError): return False @@ -62,8 +62,8 @@ def file_content(root: Traversable, path: str) -> str: f = root.joinpath(path) if f.is_file(): return f.read_text(encoding="utf-8") - except Exception: - pass + except (AttributeError, OSError, TypeError, UnicodeError, ValueError): + return "" return "" @@ -110,7 +110,7 @@ def _merge_all_workflows(root: Traversable) -> str: for e in root.joinpath(".github/workflows").iterdir() if e.name.endswith((".yml", ".yaml")) ] - except Exception: + except (AttributeError, FileNotFoundError, OSError, TypeError): return "" parts = [] for entry in entries: @@ -118,8 +118,8 @@ def _merge_all_workflows(root: Traversable) -> str: c = entry.read_text(encoding="utf-8") if c: parts.append(c) - except Exception: - pass + except (AttributeError, OSError, TypeError, UnicodeError, ValueError): + continue return "\n\n".join(parts) @@ -139,7 +139,7 @@ def workflow_map(root: Traversable) -> dict[str, dict]: wf_dir = root.joinpath(".github/workflows") try: entries = [e for e in wf_dir.iterdir() if e.name.endswith((".yml", ".yaml"))] - except Exception: + except (AttributeError, FileNotFoundError, OSError, TypeError): entries = [] result: dict[str, dict] = {} @@ -192,11 +192,11 @@ def is_mcp(root: Traversable) -> bool: try: pyproject_text = root.joinpath("pyproject.toml").read_text() return bool(re.search(r"\b(fastmcp|mcp)\b", pyproject_text, re.IGNORECASE)) - except Exception: + except (AttributeError, OSError, TypeError, UnicodeError, ValueError): pass try: return file_exists(root, "src/server.py") or file_exists(root, "server.py") - except Exception: + except (AttributeError, OSError, TypeError, ValueError): return False From fb800d8b4eec36b4e3dea7f3028ecaab9afd5642 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:46:57 +0000 Subject: [PATCH 20/49] chore: auto fixes from pre-commit hooks --- .../pyansys_quality_report.py | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index fe33a6cf..bb44396d 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -192,7 +192,9 @@ def check_dirs_exist(repo_path: Path | str, is_compliant: bool, directories: lis full_path = repo_path / directory if not full_path.exists(): is_compliant = False - print(f'The "{directory}" directory does not exist. Creating the "{directory}" directory...') + print( + f'The "{directory}" directory does not exist. Creating the "{directory}" directory...' + ) full_path.mkdir(parents=True, exist_ok=True) if not is_compliant: @@ -215,7 +217,9 @@ def check_config_file( if (has_pyproject and has_setup) or (has_setup and not has_pyproject): config_file = "setuptools" - is_compliant, project_name = check_setup_py(author_maint_name, author_maint_email, is_compliant) + is_compliant, project_name = check_setup_py( + author_maint_name, author_maint_email, is_compliant + ) elif has_pyproject and not has_setup: config_file = "pyproject" is_compliant, project_name = check_pyproject_toml( @@ -248,7 +252,9 @@ def check_pyproject_toml( if not non_compliant_name: name = project.get("name", "DNE") - if (name == "DNE") or ((name != "DNE") and not bool(re.match(r"^ansys-[a-z]+-[a-z]+$", name))): + if (name == "DNE") or ( + (name != "DNE") and not bool(re.match(r"^ansys-[a-z]+-[a-z]+$", name)) + ): is_compliant = False print("Project name does not follow naming conventions") @@ -380,7 +386,9 @@ def check_file_exists( ) -> bool: """Check files exist; if missing, generate them from the template.""" repo_path = Path(repo_path) - year_str = start_year if start_year == DEFAULT_START_YEAR else f"{start_year} - {DEFAULT_START_YEAR}" + year_str = ( + start_year if start_year == DEFAULT_START_YEAR else f"{start_year} - {DEFAULT_START_YEAR}" + ) ref_dict = { "AUTHORS": "the-authors-file", "CODE_OF_CONDUCT.md": "the-code-of-conduct-md-file", @@ -416,9 +424,7 @@ def check_file_exists( if "dependabot" in file_name: write_content(dne_message, repo_file_path, file_content) else: - tech_review_docs = ( - f"https://dev.docs.pyansys.com/packaging/structure.html#{ref_dict[file_name]}" - ) + tech_review_docs = f"https://dev.docs.pyansys.com/packaging/structure.html#{ref_dict[file_name]}" print(f"{file_name} does not exist. Please see {tech_review_docs}") else: if "README" in file_name and product is None: @@ -433,12 +439,16 @@ def check_file_exists( write_content(dne_message, repo_file_path, file_content) else: if file_name in (Filenames.CONTRIBUTORS.value, Filenames.LICENSE.value): - is_compliant = check_file_content(repo_file_path, file_content, is_compliant, license) + is_compliant = check_file_content( + repo_file_path, file_content, is_compliant, license + ) return is_compliant -def check_file_content(file: Path | str, generated_content: str, is_compliant: bool, license: str) -> bool: +def check_file_content( + file: Path | str, generated_content: str, is_compliant: bool, license: str +) -> bool: """Check the file content of the LICENSE and CONTRIBUTORS.md files.""" file = Path(file) generated_file = NamedTemporaryFile(mode="w", delete=False) @@ -466,7 +476,9 @@ def check_file_content(file: Path | str, generated_content: str, is_compliant: b if not license_line_found: is_compliant = False - print(f'"The {Filenames.LICENSE.value} file content is missing "{license_full_name}"') + print( + f'"The {Filenames.LICENSE.value} file content is missing "{license_full_name}"' + ) return is_compliant @@ -499,7 +511,9 @@ def _bootstrap_legacy_files( except Exception: start_year = DEFAULT_START_YEAR - is_compliant = check_dirs_exist(root, is_compliant, [directory.value for directory in Directories]) + is_compliant = check_dirs_exist( + root, is_compliant, [directory.value for directory in Directories] + ) is_compliant, project_name, config_file = check_config_file( root, author_maint_name, author_maint_email, is_compliant, non_compliant_name ) From a8344e769e9cab16fd23abca11c2584f073af990 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 20:18:45 +0530 Subject: [PATCH 21/49] fix: codestylee --- .../pyansys_quality_report.py | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index fe33a6cf..70be476c 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -192,7 +192,9 @@ def check_dirs_exist(repo_path: Path | str, is_compliant: bool, directories: lis full_path = repo_path / directory if not full_path.exists(): is_compliant = False - print(f'The "{directory}" directory does not exist. Creating the "{directory}" directory...') + print( + f'The "{directory}" directory does not exist. Creating the "{directory}" directory...' # noqa: E501 + ) full_path.mkdir(parents=True, exist_ok=True) if not is_compliant: @@ -215,7 +217,9 @@ def check_config_file( if (has_pyproject and has_setup) or (has_setup and not has_pyproject): config_file = "setuptools" - is_compliant, project_name = check_setup_py(author_maint_name, author_maint_email, is_compliant) + is_compliant, project_name = check_setup_py( + author_maint_name, author_maint_email, is_compliant + ) elif has_pyproject and not has_setup: config_file = "pyproject" is_compliant, project_name = check_pyproject_toml( @@ -248,7 +252,9 @@ def check_pyproject_toml( if not non_compliant_name: name = project.get("name", "DNE") - if (name == "DNE") or ((name != "DNE") and not bool(re.match(r"^ansys-[a-z]+-[a-z]+$", name))): + if (name == "DNE") or ( + (name != "DNE") and not bool(re.match(r"^ansys-[a-z]+-[a-z]+$", name)) + ): is_compliant = False print("Project name does not follow naming conventions") @@ -306,7 +312,7 @@ def check_setup_py( def download_license_json(url: str, json_file: Path | str) -> bool: - """Download the licenses.json file and restructure it to only include the license ID and name.""" + """Download the licenses.json file and restructure to include the license ID and name.""" json_file = Path(json_file) if not json_file.exists(): import requests @@ -380,7 +386,9 @@ def check_file_exists( ) -> bool: """Check files exist; if missing, generate them from the template.""" repo_path = Path(repo_path) - year_str = start_year if start_year == DEFAULT_START_YEAR else f"{start_year} - {DEFAULT_START_YEAR}" + year_str = ( + start_year if start_year == DEFAULT_START_YEAR else f"{start_year} - {DEFAULT_START_YEAR}" + ) ref_dict = { "AUTHORS": "the-authors-file", "CODE_OF_CONDUCT.md": "the-code-of-conduct-md-file", @@ -416,9 +424,7 @@ def check_file_exists( if "dependabot" in file_name: write_content(dne_message, repo_file_path, file_content) else: - tech_review_docs = ( - f"https://dev.docs.pyansys.com/packaging/structure.html#{ref_dict[file_name]}" - ) + tech_review_docs = f"https://dev.docs.pyansys.com/packaging/structure.html#{ref_dict[file_name]}." # noqa: E501 print(f"{file_name} does not exist. Please see {tech_review_docs}") else: if "README" in file_name and product is None: @@ -433,12 +439,16 @@ def check_file_exists( write_content(dne_message, repo_file_path, file_content) else: if file_name in (Filenames.CONTRIBUTORS.value, Filenames.LICENSE.value): - is_compliant = check_file_content(repo_file_path, file_content, is_compliant, license) + is_compliant = check_file_content( + repo_file_path, file_content, is_compliant, license + ) return is_compliant -def check_file_content(file: Path | str, generated_content: str, is_compliant: bool, license: str) -> bool: +def check_file_content( + file: Path | str, generated_content: str, is_compliant: bool, license: str +) -> bool: """Check the file content of the LICENSE and CONTRIBUTORS.md files.""" file = Path(file) generated_file = NamedTemporaryFile(mode="w", delete=False) @@ -466,7 +476,9 @@ def check_file_content(file: Path | str, generated_content: str, is_compliant: b if not license_line_found: is_compliant = False - print(f'"The {Filenames.LICENSE.value} file content is missing "{license_full_name}"') + print( + f'"The {Filenames.LICENSE.value} file content is missing "{license_full_name}"' + ) return is_compliant @@ -499,7 +511,9 @@ def _bootstrap_legacy_files( except Exception: start_year = DEFAULT_START_YEAR - is_compliant = check_dirs_exist(root, is_compliant, [directory.value for directory in Directories]) + is_compliant = check_dirs_exist( + root, is_compliant, [directory.value for directory in Directories] + ) is_compliant, project_name, config_file = check_config_file( root, author_maint_name, author_maint_email, is_compliant, non_compliant_name ) From 9890fc33949ffd8ace337dd54b6d6bc9bfdf4df5 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 20:23:18 +0530 Subject: [PATCH 22/49] fix: codestylee --- .../pyansys_quality_report.py | 83 ++++++++----------- 1 file changed, 34 insertions(+), 49 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 52b8ba47..0aa0f329 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -12,7 +12,7 @@ import argparse from collections.abc import Iterator -from datetime import date as dt +import datetime from enum import Enum import filecmp from io import BytesIO, StringIO @@ -81,7 +81,7 @@ DEFAULT_AUTHOR_MAINT_EMAIL = "pyansys-core@synopsys.com" """Default email of project authors and maintainers.""" -DEFAULT_START_YEAR = dt.today().year +DEFAULT_START_YEAR = datetime.datetime.now(tz=datetime.timezone.utc).date().year """Default start year of the repository.""" DEFAULT_LICENSE = "MIT" @@ -198,7 +198,7 @@ def check_dirs_exist(repo_path: Path | str, is_compliant: bool, directories: lis full_path.mkdir(parents=True, exist_ok=True) if not is_compliant: - print("") + print() return is_compliant @@ -220,9 +220,6 @@ def check_config_file( is_compliant, project_name = check_setup_py( author_maint_name, author_maint_email, is_compliant ) - is_compliant, project_name = check_setup_py( - author_maint_name, author_maint_email, is_compliant - ) elif has_pyproject and not has_setup: config_file = "pyproject" is_compliant, project_name = check_pyproject_toml( @@ -255,9 +252,6 @@ def check_pyproject_toml( if not non_compliant_name: name = project.get("name", "DNE") - if (name == "DNE") or ( - (name != "DNE") and not bool(re.match(r"^ansys-[a-z]+-[a-z]+$", name)) - ): if (name == "DNE") or ( (name != "DNE") and not bool(re.match(r"^ansys-[a-z]+-[a-z]+$", name)) ): @@ -395,9 +389,6 @@ def check_file_exists( year_str = ( start_year if start_year == DEFAULT_START_YEAR else f"{start_year} - {DEFAULT_START_YEAR}" ) - year_str = ( - start_year if start_year == DEFAULT_START_YEAR else f"{start_year} - {DEFAULT_START_YEAR}" - ) ref_dict = { "AUTHORS": "the-authors-file", "CODE_OF_CONDUCT.md": "the-code-of-conduct-md-file", @@ -451,52 +442,46 @@ def check_file_exists( is_compliant = check_file_content( repo_file_path, file_content, is_compliant, license ) - is_compliant = check_file_content( - repo_file_path, file_content, is_compliant, license - ) return is_compliant -def check_file_content( - file: Path | str, generated_content: str, is_compliant: bool, license: str -) -> bool: def check_file_content( file: Path | str, generated_content: str, is_compliant: bool, license: str ) -> bool: """Check the file content of the LICENSE and CONTRIBUTORS.md files.""" file = Path(file) - generated_file = NamedTemporaryFile(mode="w", delete=False) - with open(generated_file.name, "w", encoding="utf-8") as f: - f.write(generated_content) + with NamedTemporaryFile(mode="w", delete=False, encoding="utf-8") as generated_file: + generated_file.write(generated_content) + temp_path = Path(generated_file.name) - same_files = filecmp.cmp(file, generated_file.name, shallow=False) + try: + same_files = filecmp.cmp(file, temp_path, shallow=False) - if file.name == Filenames.CONTRIBUTORS.value and same_files: - is_compliant = False - print("Please update your CONTRIBUTORS.md file.") - elif file.name == Filenames.LICENSE.value: - downloaded = download_license_json(JSON_URL, LICENSES_JSON) - if downloaded: - license_line_found = False - with open(LICENSES_JSON, "r", encoding="utf-8") as f: - license_json = json.load(f) - license_full_name = license_json[license] - - with open(file, "r", encoding="utf-8") as license_file: - for line in license_file: - if license_full_name in line: - license_line_found = True - break - - if not license_line_found: - is_compliant = False - print( - f'"The {Filenames.LICENSE.value} file content is missing "{license_full_name}"' - ) - print( - f'"The {Filenames.LICENSE.value} file content is missing "{license_full_name}"' - ) + if file.name == Filenames.CONTRIBUTORS.value and same_files: + is_compliant = False + print("Please update your CONTRIBUTORS.md file.") + elif file.name == Filenames.LICENSE.value: + downloaded = download_license_json(JSON_URL, LICENSES_JSON) + if downloaded: + license_line_found = False + with open(LICENSES_JSON, "r", encoding="utf-8") as f: + license_json = json.load(f) + license_full_name = license_json[license] + + with open(file, "r", encoding="utf-8") as license_file: + for line in license_file: + if license_full_name in line: + license_line_found = True + break + + if not license_line_found: + is_compliant = False + print( + f'"The {Filenames.LICENSE.value} file content is missing "{license_full_name}"' # noqa: E501 + ) + finally: + temp_path.unlink(missing_ok=True) return is_compliant @@ -526,7 +511,7 @@ def _bootstrap_legacy_files( g = git.Git(root) all_dates = g.log("--reverse", "--format=%ci") start_year = int(all_dates[0:4]) if all_dates else DEFAULT_START_YEAR - except Exception: + except (git.GitCommandError, TypeError, ValueError): start_year = DEFAULT_START_YEAR is_compliant = check_dirs_exist( @@ -735,7 +720,7 @@ def main(argv: list[str] | None = None) -> int: help="The repository URL. For example, https://github.com/ansys/pymechanical", ) parser.add_argument("--non_compliant_name", action="store_true") - args, unknown = parser.parse_known_args(argv) + args, _ = parser.parse_known_args(argv) repo_root = Path(args.repo_root).resolve() if not repo_root.exists(): From 507c0edcc8acc5e2cabe17fe8d2d31775ac5050e Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 20:28:30 +0530 Subject: [PATCH 23/49] fix: updatedoc-build errpr --- doc/source/conf.py | 3 +++ .../quality_rules/dependabot.py | 2 +- .../pre_commit_hooks/quality_rules/labeler.py | 6 +++--- .../quality_rules/pre_commit.py | 18 +++++++++--------- .../quality_rules/project_metadata.py | 4 ++-- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index 82d2594c..ab42f2f8 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -44,6 +44,9 @@ }, } +# Exclude legacy modules that still share public API names with the canonical hook. +autoapi_ignore = ["**/tech_review.py"] + # Sphinx extensions extensions = [ "ansys_sphinx_theme.extension.autoapi", diff --git a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py index a8fb4522..0ce2e7a5 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py +++ b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py @@ -60,7 +60,7 @@ def check(root) -> bool: class DB002(Dependabot): - """dependabot.yml sets version 2.""" + """Dependabot.yml sets version 2.""" requires = {"DB001"} diff --git a/src/ansys/pre_commit_hooks/quality_rules/labeler.py b/src/ansys/pre_commit_hooks/quality_rules/labeler.py index 67266b45..dc324a44 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/labeler.py +++ b/src/ansys/pre_commit_hooks/quality_rules/labeler.py @@ -54,7 +54,7 @@ def check(root) -> bool: class LB003(Labeler): - """labels.yml has a bug label.""" + """Labels.yml has a bug label.""" requires = {"LB002"} @@ -67,7 +67,7 @@ def check(root) -> bool | None: class LB004(Labeler): - """labels.yml has an enhancement label.""" + """Labels.yml has an enhancement label.""" requires = {"LB002"} @@ -80,7 +80,7 @@ def check(root) -> bool | None: class LB005(Labeler): - """labels.yml has a documentation label.""" + """Labels.yml has a documentation label.""" requires = {"LB002"} diff --git a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py index 15c33cc5..60c3a799 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py +++ b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py @@ -59,7 +59,7 @@ def check(root) -> bool: class PC002(PreCommit): - """ruff-pre-commit is configured.""" + """Ruff-pre-commit is configured.""" requires = {"PC001"} @@ -72,7 +72,7 @@ def check(root) -> bool | None: class PC003(PreCommit): - """zizmor is configured with the --pedantic flag.""" + """Zizmor is configured with the --pedantic flag.""" requires = {"PC001"} @@ -91,7 +91,7 @@ def check(root) -> bool | None | str: class PC004(PreCommit): - """blacken-docs is configured.""" + """Blacken-docs is configured.""" requires = {"PC001"} @@ -104,7 +104,7 @@ def check(root) -> bool | None: class PC005(PreCommit): - """codespell is configured.""" + """Codespell is configured.""" requires = {"PC001"} @@ -117,7 +117,7 @@ def check(root) -> bool | None: class PC006(PreCommit): - """ansys/pre-commit-hooks is configured.""" + """Ansys/pre-commit-hooks is configured.""" requires = {"PC001"} @@ -130,7 +130,7 @@ def check(root) -> bool | None: class PC007(PreCommit): - """google/yamlfmt is configured.""" + """Google/yamlfmt is configured.""" requires = {"PC001"} @@ -143,7 +143,7 @@ def check(root) -> bool | None: class PC008(PreCommit): - """pyright is configured.""" + """Pyright is configured.""" requires = {"PC001"} @@ -156,7 +156,7 @@ def check(root) -> bool | None: class PC009(PreCommit): - """autofix_prs: true is enabled.""" + """Autofix_prs: true is enabled.""" requires = {"PC001"} @@ -171,7 +171,7 @@ def check(root) -> bool | None | str: class PC010(PreCommit): - """autoupdate_schedule: weekly is configured.""" + """Autoupdate_schedule: weekly is configured.""" requires = {"PC001"} diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index a3940110..39bd949e 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -143,7 +143,7 @@ def check(root) -> bool: class PM010(ProjectMetadata): - """pyproject.toml references the README file.""" + """Pyproject.toml references the README file.""" requires = {"PM007"} @@ -171,7 +171,7 @@ def check(root, readme_path: str | None) -> bool | None | str: class PM011(ProjectMetadata): - """pyproject.toml references the LICENSE file.""" + """Pyproject.toml references the LICENSE file.""" requires = {"PM006"} From da5b0b709fb37976b137b484dc8afb585f329861 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 20:53:58 +0530 Subject: [PATCH 24/49] fix: update license --- .../quality_rules/project_metadata.py | 12 ++++++++---- tests/test_pyansys_quality_report.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 39bd949e..639b97a2 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -267,16 +267,20 @@ def check(root) -> bool | None | str: class PM015(ProjectMetadata): - """The LICENSE file includes the MIT License wording.""" + """The LICENSE file includes recognized project license wording.""" requires = {"PM006"} @staticmethod def check(root) -> bool | None: - """Return whether the LICENSE file contains the expected MIT License text.""" + """Return whether the LICENSE file contains expected MIT or Apache 2.0 text.""" if not file_exists(root, "LICENSE"): return None content = file_content(root, "LICENSE") - if "MIT License" in content: + if ( + "MIT License" in content + or re.search(r"Apache License.*Version 2\.0", content, re.IGNORECASE | re.DOTALL) + or "Apache License" in content + ): return True - return '⚠️ LICENSE file content is missing "MIT License".' + return '⚠️ LICENSE file content is missing a recognized license statement (MIT or Apache 2.0).' diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 0f2a1f9a..dd53c3a2 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -136,6 +136,21 @@ def class_names(path: Path) -> set[str]: assert expected - family_names == actual +def test_pm015_accepts_apache_license(tmp_path): + """Project metadata should accept Apache 2.0 as a valid license text.""" + repo_path = tmp_path / "apache-license-project" + repo_path.mkdir() + + (repo_path / "LICENSE").write_text( + "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n", + encoding="utf-8", + ) + + import ansys.pre_commit_hooks.quality_rules.project_metadata as project_metadata + + assert project_metadata.PM015.check(repo_path) is True + + def test_quality_rules_are_grouped_package(): """Quality rules should be exposed from a package with one module per check family.""" import ansys.pre_commit_hooks.quality_rules as quality_rules From cfb7b111bb3a746586836b3da3fe2cbc33bae5d5 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 20:54:24 +0530 Subject: [PATCH 25/49] fix: update license --- src/ansys/pre_commit_hooks/quality_rules/project_metadata.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 639b97a2..091b9b40 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -283,4 +283,6 @@ def check(root) -> bool | None: or "Apache License" in content ): return True - return '⚠️ LICENSE file content is missing a recognized license statement (MIT or Apache 2.0).' + return ( + "⚠️ LICENSE file content is missing a recognized license statement (MIT or Apache 2.0)." + ) From 1a34264386300450b81e512386ea5c4b56a915d6 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 21:24:36 +0530 Subject: [PATCH 26/49] fix: update packages --- .../pyansys_quality_report.py | 76 +++++++++++-------- .../quality_rules/__init__.py | 74 +++++++++--------- .../pre_commit_hooks/quality_rules/common.py | 23 ++++-- tests/test_pyansys_quality_report.py | 10 +++ 4 files changed, 111 insertions(+), 72 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 0aa0f329..70bb8e28 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -31,8 +31,8 @@ from ansys.pre_commit_hooks.quality_rules import ( _first_doc_line, - _interpret, is_mcp, + normalize_check_result, readme_path, repo_review_checks, repo_review_families, @@ -542,10 +542,9 @@ def _bootstrap_legacy_files( return 0 if is_compliant else 1 -def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, Any]: - """Run the package-based repo review checks against an in-memory file set.""" - root = MemoryTraversable(files) - fixture_values = { +def _build_fixture_values(root: MemoryTraversable, is_mcp_flag: bool) -> dict[str, Any]: + """Create the shared repository context used by the quality-rule checks.""" + return { "root": root, "package": root, "workflow_map": workflow_map(root), @@ -553,35 +552,34 @@ def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, An "is_mcp": is_mcp_flag or is_mcp(root), } - checks = repo_review_checks() - families = repo_review_families() - results = [] - for code, check_obj in checks.items(): - try: - import inspect - - signature = inspect.signature(check_obj.check) - kwargs = { - key: fixture_values[key] for key in signature.parameters if key in fixture_values - } - raw = check_obj.check(**kwargs) - except (AttributeError, TypeError, ValueError) as exc: # pragma: no cover - raw = f"⚠️ Check error: {exc}" - - status, detail = _interpret(raw, check_obj) - results.append( - { - "id": code, - "family": check_obj.family, - "family_name": families.get(check_obj.family, {}).get("name", check_obj.family), - "label": type(check_obj).__doc__ or code, - "description": _first_doc_line(check_obj), - "status": status, - "detail": detail, - } - ) +def _execute_check(check_obj: Any, *, code: str, fixture_values: dict[str, Any], families: dict[str, dict]) -> dict[str, Any]: + """Execute a single rule object and return its normalized report payload.""" + try: + import inspect + signature = inspect.signature(check_obj.check) + kwargs = { + key: fixture_values[key] for key in signature.parameters if key in fixture_values + } + raw = check_obj.check(**kwargs) + except (AttributeError, TypeError, ValueError) as exc: # pragma: no cover + raw = f"⚠️ Check error: {exc}" + + status, detail = normalize_check_result(raw, check_obj) + return { + "id": code, + "family": check_obj.family, + "family_name": families.get(check_obj.family, {}).get("name", check_obj.family), + "label": type(check_obj).__doc__ or code, + "description": _first_doc_line(check_obj), + "status": status, + "detail": detail, + } + + +def _tally_results(results: list[dict[str, Any]]) -> dict[str, int]: + """Summarize rule outcomes into pass/fail/warn/na counts.""" tally = {"passed": 0, "failed": 0, "warned": 0, "not_applicable": 0} for result in results: if result["status"] == "pass": @@ -592,7 +590,21 @@ def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, An tally["warned"] += 1 else: tally["not_applicable"] += 1 + return tally + + +def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, Any]: + """Run the package-based repo review checks against an in-memory file set.""" + root = MemoryTraversable(files) + fixture_values = _build_fixture_values(root, is_mcp_flag) + checks = repo_review_checks() + families = repo_review_families() + results = [ + _execute_check(check_obj, code=code, fixture_values=fixture_values, families=families) + for code, check_obj in checks.items() + ] + tally = _tally_results(results) scored = tally["passed"] + tally["failed"] score = round(tally["passed"] / scored * 100) if scored else 0 return { diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index c2ca9906..82095597 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -51,12 +51,12 @@ from ansys.pre_commit_hooks.quality_rules.common import ( CANONICAL_WF, _first_doc_line, - _interpret, all_workflows_content, file_contains, file_content, file_exists, is_mcp, + normalize_check_result, readme_path, wf_content, wf_label, @@ -158,10 +158,10 @@ "workflow_map", "readme_path", "is_mcp", + "normalize_check_result", "repo_review_families", "repo_review_checks", "_first_doc_line", - "_interpret", "ProjectMetadata", "PM001", "PM002", @@ -268,42 +268,46 @@ ] +QUALITY_RULE_FAMILIES = ( + ProjectMetadata, + CICDFiles, + CICD, + Dependabot, + PreCommit, + Documentation, + README, + BuildSystem, + Security, + Labeler, + Vale, + MCP, +) + +QUALITY_RULE_METADATA = { + "project_metadata": {"name": "Project Metadata", "order": 10}, + "cicd_files": {"name": "CI/CD — Workflow File Names", "order": 20}, + "cicd": {"name": "CI/CD — Content Checks", "order": 25}, + "dependabot": {"name": "Dependabot", "order": 30}, + "pre_commit": {"name": "Pre-commit", "order": 40}, + "documentation": {"name": "Documentation", "order": 50}, + "readme": {"name": "README", "order": 60}, + "build_system": {"name": "Build System", "order": 70}, + "security": {"name": "Security", "order": 80}, + "labeler": {"name": "Labeler", "order": 90}, + "vale": {"name": "Vale", "order": 100}, + "mcp": {"name": "MCP Release Readiness", "order": 110}, +} + + def repo_review_families() -> dict[str, dict]: - """Return the metadata for each quality-report family.""" - return { - "project_metadata": {"name": "Project Metadata", "order": 10}, - "cicd_files": {"name": "CI/CD — Workflow File Names", "order": 20}, - "cicd": {"name": "CI/CD — Content Checks", "order": 25}, - "dependabot": {"name": "Dependabot", "order": 30}, - "pre_commit": {"name": "Pre-commit", "order": 40}, - "documentation": {"name": "Documentation", "order": 50}, - "readme": {"name": "README", "order": 60}, - "build_system": {"name": "Build System", "order": 70}, - "security": {"name": "Security", "order": 80}, - "labeler": {"name": "Labeler", "order": 90}, - "vale": {"name": "Vale", "order": 100}, - "mcp": {"name": "MCP Release Readiness", "order": 110}, - } + """Return the family metadata used by the quality report.""" + return dict(QUALITY_RULE_METADATA) -def repo_review_checks() -> dict: - """Return the rule family classes used by the quality report.""" - families = [ - ProjectMetadata, - CICDFiles, - CICD, - Dependabot, - PreCommit, - Documentation, - README, - BuildSystem, - Security, - Labeler, - Vale, - MCP, - ] - result = {} - for family in families: +def repo_review_checks() -> dict[str, object]: + """Return all discovered rule objects keyed by their rule class name.""" + result: dict[str, object] = {} + for family in QUALITY_RULE_FAMILIES: for cls in family.__subclasses__(): result[cls.__name__] = cls() return result diff --git a/src/ansys/pre_commit_hooks/quality_rules/common.py b/src/ansys/pre_commit_hooks/quality_rules/common.py index 8593966f..6f809d60 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/common.py +++ b/src/ansys/pre_commit_hooks/quality_rules/common.py @@ -43,8 +43,8 @@ "workflow_map", "readme_path", "is_mcp", + "normalize_check_result", "_first_doc_line", - "_interpret", ] @@ -207,17 +207,30 @@ def _first_doc_line(obj: Any) -> str: return lines[0] if lines else "" -def _interpret(raw: bool | str | None, check_obj: Any) -> tuple[str, str]: - """Interpret the raw check result into a status and detail message.""" +def normalize_check_result(raw: bool | str | None, check_obj: Any | None = None) -> tuple[str, str]: + """Normalize a raw rule result to the canonical status/detail contract. + + The canonical contract is intentionally simple and shared across the whole + quality-rule package: ``pass``, ``warn``, ``fail``, and ``na`` are the only + valid statuses. + """ if raw is True: return "pass", "" if raw is None: return "na", "" - if isinstance(raw, str) and raw.startswith("⚠️ "): - return "warn", raw.removeprefix("⚠️ ") + if isinstance(raw, str): + if raw.startswith("⚠️ "): + return "warn", raw.removeprefix("⚠️ ") + if raw: + return "warn", raw + return "fail", "" if raw is False: + if check_obj is None: + return "fail", "" doc = (check_obj.check.__doc__ or "").strip() lines = [line.strip() for line in doc.splitlines() if line.strip()] detail = lines[-1] if len(lines) > 1 else (lines[0] if lines else "") return "fail", detail return "fail", str(raw) + + diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index dd53c3a2..bfa38932 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -159,3 +159,13 @@ def test_quality_rules_are_grouped_package(): assert hasattr(quality_rules, "PM001") assert hasattr(project_metadata, "PM001") assert callable(quality_rules.repo_review_checks) + + +def test_normalize_check_result_standardizes_rule_status(): + """Rule evaluation results should normalize to the canonical pass/warn/fail/na model.""" + from ansys.pre_commit_hooks.quality_rules.common import normalize_check_result + + assert normalize_check_result(True) == ("pass", "") + assert normalize_check_result(None) == ("na", "") + assert normalize_check_result("⚠️ check warning") == ("warn", "check warning") + assert normalize_check_result(False) == ("fail", "") From 0cfb897554c2105d74860e980878f7467f287dad Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:54:52 +0000 Subject: [PATCH 27/49] chore: auto fixes from pre-commit hooks --- src/ansys/pre_commit_hooks/pyansys_quality_report.py | 8 ++++---- src/ansys/pre_commit_hooks/quality_rules/common.py | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 70bb8e28..0dfa5cb5 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -553,15 +553,15 @@ def _build_fixture_values(root: MemoryTraversable, is_mcp_flag: bool) -> dict[st } -def _execute_check(check_obj: Any, *, code: str, fixture_values: dict[str, Any], families: dict[str, dict]) -> dict[str, Any]: +def _execute_check( + check_obj: Any, *, code: str, fixture_values: dict[str, Any], families: dict[str, dict] +) -> dict[str, Any]: """Execute a single rule object and return its normalized report payload.""" try: import inspect signature = inspect.signature(check_obj.check) - kwargs = { - key: fixture_values[key] for key in signature.parameters if key in fixture_values - } + kwargs = {key: fixture_values[key] for key in signature.parameters if key in fixture_values} raw = check_obj.check(**kwargs) except (AttributeError, TypeError, ValueError) as exc: # pragma: no cover raw = f"⚠️ Check error: {exc}" diff --git a/src/ansys/pre_commit_hooks/quality_rules/common.py b/src/ansys/pre_commit_hooks/quality_rules/common.py index 6f809d60..4a2c491e 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/common.py +++ b/src/ansys/pre_commit_hooks/quality_rules/common.py @@ -232,5 +232,3 @@ def normalize_check_result(raw: bool | str | None, check_obj: Any | None = None) detail = lines[-1] if len(lines) > 1 else (lines[0] if lines else "") return "fail", detail return "fail", str(raw) - - From 2feea01661104e903486f8bfd183efdf0e4223c2 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Wed, 2 Sep 2026 21:31:35 +0530 Subject: [PATCH 28/49] fix: update packages --- .../pyansys_quality_report.py | 34 ++++++++++++------- .../pre_commit_hooks/quality_rules/common.py | 2 -- tests/test_pyansys_quality_report.py | 18 ++++++++++ 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 70bb8e28..cf1da171 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -464,21 +464,31 @@ def check_file_content( elif file.name == Filenames.LICENSE.value: downloaded = download_license_json(JSON_URL, LICENSES_JSON) if downloaded: - license_line_found = False with open(LICENSES_JSON, "r", encoding="utf-8") as f: license_json = json.load(f) - license_full_name = license_json[license] + + accepted_names = { + value + for key in {license, "MIT", "Apache-2.0"} + if (value := license_json.get(key)) + } + accepted_names.update( + { + "MIT License", + "Apache License, Version 2.0", + "Apache License 2.0", + "Apache License", + } + ) with open(file, "r", encoding="utf-8") as license_file: - for line in license_file: - if license_full_name in line: - license_line_found = True - break + file_text = license_file.read().lower() - if not license_line_found: + if not any(name.lower() in file_text for name in accepted_names): + requested_name = license_json.get(license, license) is_compliant = False print( - f'"The {Filenames.LICENSE.value} file content is missing "{license_full_name}"' # noqa: E501 + f'"The {Filenames.LICENSE.value} file content is missing "{requested_name}"' # noqa: E501 ) finally: temp_path.unlink(missing_ok=True) @@ -553,15 +563,15 @@ def _build_fixture_values(root: MemoryTraversable, is_mcp_flag: bool) -> dict[st } -def _execute_check(check_obj: Any, *, code: str, fixture_values: dict[str, Any], families: dict[str, dict]) -> dict[str, Any]: +def _execute_check( + check_obj: Any, *, code: str, fixture_values: dict[str, Any], families: dict[str, dict] +) -> dict[str, Any]: """Execute a single rule object and return its normalized report payload.""" try: import inspect signature = inspect.signature(check_obj.check) - kwargs = { - key: fixture_values[key] for key in signature.parameters if key in fixture_values - } + kwargs = {key: fixture_values[key] for key in signature.parameters if key in fixture_values} raw = check_obj.check(**kwargs) except (AttributeError, TypeError, ValueError) as exc: # pragma: no cover raw = f"⚠️ Check error: {exc}" diff --git a/src/ansys/pre_commit_hooks/quality_rules/common.py b/src/ansys/pre_commit_hooks/quality_rules/common.py index 6f809d60..4a2c491e 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/common.py +++ b/src/ansys/pre_commit_hooks/quality_rules/common.py @@ -232,5 +232,3 @@ def normalize_check_result(raw: bool | str | None, check_obj: Any | None = None) detail = lines[-1] if len(lines) > 1 else (lines[0] if lines else "") return "fail", detail return "fail", str(raw) - - diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index bfa38932..47e86e65 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -161,6 +161,24 @@ def test_quality_rules_are_grouped_package(): assert callable(quality_rules.repo_review_checks) +def test_legacy_license_check_accepts_apache_2_0_by_default(tmp_path): + """The legacy bootstrap should not reject a valid Apache 2.0 LICENSE no configured.""" + license_path = tmp_path / "LICENSE" + license_path.write_text( + "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n", + encoding="utf-8", + ) + + result = hook.check_file_content( + license_path, + "MIT License\n", + True, + hook.DEFAULT_LICENSE, + ) + + assert result is True + + def test_normalize_check_result_standardizes_rule_status(): """Rule evaluation results should normalize to the canonical pass/warn/fail/na model.""" from ansys.pre_commit_hooks.quality_rules.common import normalize_check_result From 85177d171414a721efacf29da365e5cca6f91de7 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 12:14:00 +0530 Subject: [PATCH 29/49] fix: build backend module --- .../quality_rules/build_system.py | 58 +++++++++++++++---- tests/test_tech_review.py | 14 +++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/build_system.py b/src/ansys/pre_commit_hooks/quality_rules/build_system.py index 37bec430..636f3469 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/build_system.py +++ b/src/ansys/pre_commit_hooks/quality_rules/build_system.py @@ -42,13 +42,21 @@ def _detect_backend(content: str) -> tuple[str, str]: - m = re.search(r'build-backend\s*=\s*["\']([^"\']+)["\']', content) - backend = m.group(1) if m else "" + """Detect the configured build backend from pyproject.toml.""" + match = re.search( + r'build-backend\s*=\s*["\']([^"\']+)["\']', + content, + ) + + backend = match.group(1) if match else "" + for pattern, name in _BACKENDS.items(): if pattern in backend: return name, pattern.split(".")[0].replace("_core", "") + if "[build-system]" in content: return "Other", "other" + return "Unknown", "unknown" @@ -66,6 +74,7 @@ def check(root) -> bool | None: """Return whether a build-system table is present in pyproject.toml.""" if not file_exists(root, "pyproject.toml"): return None + return file_contains(root, "pyproject.toml", "[build-system]") @@ -79,13 +88,18 @@ def check(root) -> bool | None | str: """Return whether the project uses a supported modern build backend.""" if not file_exists(root, "pyproject.toml"): return None + name, key = _detect_backend(file_content(root, "pyproject.toml")) + if key == "unknown": return False + if key == "setuptools": return ( - f"⚠️ Uses {name} — consider migrating to Flit, Hatch, or Poetry for simpler config." + f"⚠️ Uses {name} — consider migrating to Flit, Hatch, " + "or Poetry for simpler config." ) + return True @@ -95,12 +109,24 @@ class BS003(BuildSystem): @staticmethod def check(root) -> bool | str: """Return whether the project uses only pyproject.toml for packaging metadata.""" - has_py = file_exists(root, "setup.py") - has_cfg = file_exists(root, "setup.cfg") - if not has_py and not has_cfg: + has_setup_py = file_exists(root, "setup.py") + has_setup_cfg = file_exists(root, "setup.cfg") + + if not has_setup_py and not has_setup_cfg: return True - found = [f for f, present in [("setup.py", has_py), ("setup.cfg", has_cfg)] if present] - return f"⚠️ Legacy file(s) found: {', '.join(found)}. Remove in favour of pyproject.toml." + + found = [ + filename + for filename, exists in ( + ("setup.py", has_setup_py), + ("setup.cfg", has_setup_cfg), + ) + if exists + ] + + return ( + f"⚠️ Legacy file(s) found: {', '.join(found)}. " "Remove in favour of pyproject.toml." + ) class BS004(BuildSystem): @@ -113,10 +139,18 @@ def check(root) -> bool | None | str: """Return whether the build backend requirement includes a version pin.""" if not file_exists(root, "pyproject.toml"): return None + content = file_content(root, "pyproject.toml") - m = re.search(r"requires\s*=\s*\[([^\]]+)\]", content) - if not m: + + match = re.search( + r"requires\s*=\s*\[([^\]]+)\]", + content, + ) + + if not match: return False - if re.search(r"[><=!~]", m.group(1)): + + if re.search(r"[><=!~]", match.group(1)): return True - return "⚠️ Build backend in requires has no version pin (e.g. >=x.y)." + + return "⚠️ Build backend in requires has no version pin " "(e.g. >=x.y)." diff --git a/tests/test_tech_review.py b/tests/test_tech_review.py index e231f83f..faabe3b0 100644 --- a/tests/test_tech_review.py +++ b/tests/test_tech_review.py @@ -32,6 +32,7 @@ from ansys.pre_commit_hooks.add_license_headers import check_same_content import ansys.pre_commit_hooks.pyansys_quality_report as quality_hook +from ansys.pre_commit_hooks.quality_rules.build_system import _detect_backend import ansys.pre_commit_hooks.tech_review as hook git_repo = git.Repo(os.getcwd(), search_parent_directories=True) @@ -39,6 +40,19 @@ TEST_TECH_REVIEW_FILES = REPO_PATH / "tests" / "test_tech_review_files" +def test_detect_backend(): + """The build backend detector should identify common pyproject backends.""" + assert _detect_backend( + '[build-system]\nrequires = ["setuptools>=68"]\nbuild-backend = "setuptools.build_meta"\n' + ) == ("Setuptools", "setuptools") + assert _detect_backend( + '[build-system]\nrequires = ["flit_core >=3.8"]\nbuild-backend = "flit_core.buildapi"\n' + ) == ("Flit", "flit") + assert _detect_backend( + '[build-system]\nrequires = ["poetry-core>=1.0"]\nbuild-backend = "poetry.core.masonry.api"\n' # noqa: E501 + ) == ("Poetry", "poetry") + + def setup_repo(tmp_path): """Move to temporary directory, set up git repo, & create test file.""" # Make "pytechreview" folder in tmp_path From e1128dad45018af6fd43b39ccaaccd9b579c1f29 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 12:19:32 +0530 Subject: [PATCH 30/49] fix: ci_cd files modules --- .../quality_rules/cicd_files.py | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py b/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py index 7d3f2244..88b780da 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py +++ b/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py @@ -19,7 +19,6 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. - """CI/CD workflow file naming checks.""" from __future__ import annotations @@ -34,6 +33,22 @@ class CICDFiles: family = "cicd_files" + @staticmethod + def _check_workflow( + root, + workflow_map: dict, + workflow_type: str, + expected_file: str, + ) -> bool | str: + """Validate that the expected workflow file exists.""" + if file_exists(root, CANONICAL_WF[workflow_type]): + return True + + return ( + f"⚠️ Canonical {expected_file} not found " + f"— detected: {wf_label(workflow_type, workflow_map)}" + ) + class CI001(CICDFiles): """The ci_cd_main.yml workflow file exists.""" @@ -41,10 +56,12 @@ class CI001(CICDFiles): @staticmethod def check(root, workflow_map: dict) -> bool | str: """Return whether the canonical main workflow file is present.""" - if file_exists(root, CANONICAL_WF["main"]): - return True - lbl = wf_label("main", workflow_map) - return f"⚠️ Canonical ci_cd_main.yml not found — detected: {lbl}" + return CICDFiles._check_workflow( + root, + workflow_map, + "main", + "ci_cd_main.yml", + ) class CI002(CICDFiles): @@ -53,10 +70,12 @@ class CI002(CICDFiles): @staticmethod def check(root, workflow_map: dict) -> bool | str: """Return whether the canonical PR workflow file is present.""" - if file_exists(root, CANONICAL_WF["pr"]): - return True - lbl = wf_label("pr", workflow_map) - return f"⚠️ Canonical ci_cd_pr.yml not found — detected: {lbl}" + return CICDFiles._check_workflow( + root, + workflow_map, + "pr", + "ci_cd_pr.yml", + ) class CI003(CICDFiles): @@ -65,7 +84,9 @@ class CI003(CICDFiles): @staticmethod def check(root, workflow_map: dict) -> bool | str: """Return whether the canonical release workflow file is present.""" - if file_exists(root, CANONICAL_WF["release"]): - return True - lbl = wf_label("release", workflow_map) - return f"⚠️ Canonical ci_cd_release.yml not found — detected: {lbl}" + return CICDFiles._check_workflow( + root, + workflow_map, + "release", + "ci_cd_release.yml", + ) From 4ed5b309537d24bd4ec70a7b117310e6200e292c Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 12:32:52 +0530 Subject: [PATCH 31/49] fix: cicd module and sematic versioning --- .../pre_commit_hooks/quality_rules/cicd.py | 129 ++++++++++++++---- .../quality_rules/project_metadata.py | 16 ++- tests/test_pyansys_quality_report.py | 9 ++ 3 files changed, 122 insertions(+), 32 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/cicd.py b/src/ansys/pre_commit_hooks/quality_rules/cicd.py index 41934984..76858a96 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/cicd.py +++ b/src/ansys/pre_commit_hooks/quality_rules/cicd.py @@ -59,17 +59,19 @@ class CI004(CICD): def check(root, workflow_map: dict) -> bool | None | str: """Return whether the PR and main workflows define concurrency blocks.""" roles = [("pr", "ci_cd_pr.yml"), ("main", "ci_cd_main.yml")] - present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + + present = [ + (role, label, content) + for role, label in roles + if (content := wf_content(root, role, workflow_map)[1]) + ] + if not present: return None - missing = [ - lbl - for role, lbl in present - if "concurrency:" not in wf_content(root, role, workflow_map)[1] - ] - if not missing: - return True - return f"⚠️ concurrency: block missing in: {', '.join(missing)}" + + missing = [label for _, label, content in present if "concurrency:" not in content] + + return True if not missing else f"⚠️ concurrency: block missing in: {', '.join(missing)}" class CI005(CICD): @@ -79,14 +81,22 @@ class CI005(CICD): def check(root, workflow_map: dict) -> bool | None | str: """Return whether the PR and release workflows have explicit root permissions.""" roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] - present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + + present = [ + (role, label, content) + for role, label in roles + if (content := wf_content(root, role, workflow_map)[1]) + ] + if not present: return None + missing = [ - lbl - for role, lbl in present - if not re.search(r"^permissions:\s*\{\}", wf_content(root, role, workflow_map)[1], re.M) + label + for _, label, content in present + if not re.search(r"^permissions:\s*\{\}", content, re.MULTILINE) ] + return True if not missing else f"Missing root permissions: {{}} in: {', '.join(missing)}" @@ -97,17 +107,25 @@ class CI006(CICD): def check(root, workflow_map: dict) -> bool | None | str: """Return whether workflows disable persisting credentials during checkout.""" roles = [("pr", "ci_cd_pr.yml"), ("release", "ci_cd_release.yml")] - present = [(role, lbl) for role, lbl in roles if wf_content(root, role, workflow_map)[1]] + + present = [ + (role, label, content) + for role, label in roles + if (content := wf_content(root, role, workflow_map)[1]) + ] + if not present: return None + missing = [ - lbl - for role, lbl in present - if "persist-credentials: false" not in wf_content(root, role, workflow_map)[1] + label for _, label, content in present if "persist-credentials: false" not in content ] - if not missing: - return True - return f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" + + return ( + True + if not missing + else f"⚠️ persist-credentials: false missing in: {', '.join(missing)}" + ) class CI007(CICD): @@ -117,9 +135,17 @@ class CI007(CICD): def check(root) -> bool | None: """Return whether the workflows include a labeler action.""" content = all_workflows_content(root) + if not content: return None - return bool(re.search(r"ansys/actions/[^\s]*label|\blabeler\b", content, re.IGNORECASE)) + + return bool( + re.search( + r"ansys/actions/[^\s]*label|\blabeler\b", + content, + re.IGNORECASE, + ) + ) class CI008(CICD): @@ -129,8 +155,10 @@ class CI008(CICD): def check(root) -> bool | None: """Return whether the workflows include the vulnerability check action.""" content = all_workflows_content(root) + if not content: return None + return "ansys/actions/check-vulnerabilities" in content @@ -141,10 +169,13 @@ class CI009(CICD): def check(root) -> bool | None | str: """Return whether the workflows include the code-style action.""" content = all_workflows_content(root) + if not content: return None + if "ansys/actions/code-style" in content: return True + return "⚠️ ansys/actions/code-style not found in any workflow file." @@ -155,10 +186,16 @@ class CI010(CICD): def check(root) -> bool | None: """Return whether workflows enforce the PR title check.""" content = all_workflows_content(root) + if not content: return None + return bool( - re.search(r"ansys/actions/check-pr-title|check-pr-title", content, re.IGNORECASE) + re.search( + r"ansys/actions/check-pr-title|check-pr-title", + content, + re.IGNORECASE, + ) ) @@ -169,10 +206,16 @@ class CI011(CICD): def check(root) -> bool | None: """Return whether workflows include changelog-fragment validation.""" content = all_workflows_content(root) + if not content: return None + return bool( - re.search(r"ansys/actions/[^\s]*changelog|changelog-fragment", content, re.IGNORECASE) + re.search( + r"ansys/actions/[^\s]*changelog|changelog-fragment", + content, + re.IGNORECASE, + ) ) @@ -183,9 +226,17 @@ class CI012(CICD): def check(root) -> bool | None: """Return whether the workflows include the doc-style action.""" content = all_workflows_content(root) + if not content: return None - return bool(re.search(r"ansys/actions/check-doc-style|doc-style", content, re.IGNORECASE)) + + return bool( + re.search( + r"ansys/actions/check-doc-style|doc-style", + content, + re.IGNORECASE, + ) + ) class CI013(CICD): @@ -195,9 +246,17 @@ class CI013(CICD): def check(root) -> bool | None: """Return whether the workflows include the doc-build action.""" content = all_workflows_content(root) + if not content: return None - return bool(re.search(r"ansys/actions/doc-build|\bdoc-build\b", content, re.IGNORECASE)) + + return bool( + re.search( + r"ansys/actions/doc-build|\bdoc-build\b", + content, + re.IGNORECASE, + ) + ) class CI014(CICD): @@ -207,10 +266,16 @@ class CI014(CICD): def check(root) -> bool | None: """Return whether the workflows include the build-wheelhouse action.""" content = all_workflows_content(root) + if not content: return None + return bool( - re.search(r"ansys/actions/build-wheelhouse|build-wheelhouse", content, re.IGNORECASE) + re.search( + r"ansys/actions/build-wheelhouse|build-wheelhouse", + content, + re.IGNORECASE, + ) ) @@ -221,11 +286,13 @@ class CI015(CICD): def check(root) -> bool | None: """Return whether the workflows include pytest-based tests.""" content = all_workflows_content(root) + if not content: return None + return bool( re.search( - r"ansys/actions/tests-pytest|ansys/actions/tests|\btests\b|pytest", + r"ansys/actions/tests-pytest|" r"ansys/actions/tests|" r"\btests\b|" r"pytest", content, re.IGNORECASE, ) @@ -239,8 +306,14 @@ class CI016(CICD): def check(root) -> bool | None: """Return whether workflows include changelog updates during release.""" content = all_workflows_content(root) + if not content: return None + return bool( - re.search(r"ansys/actions/release-github|update-changelog", content, re.IGNORECASE) + re.search( + r"ansys/actions/release-github|update-changelog", + content, + re.IGNORECASE, + ) ) diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 091b9b40..c75018e2 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -209,11 +209,11 @@ def check(root) -> bool | None | str: class PM013(ProjectMetadata): - """Project version follows semantic versioning.""" + """Project version follows semantic versioning or accepted Python dev versions.""" @staticmethod def check(root) -> bool | None | str: - """Return whether the project version uses semantic versioning.""" + """Return whether the project version uses a valid release or dev version.""" if not file_exists(root, "pyproject.toml"): return None content = file_content(root, "pyproject.toml") @@ -221,9 +221,17 @@ def check(root) -> bool | None | str: if not match: return "⚠️ project version not found in pyproject.toml." version = match.group(1) - if re.fullmatch(r"\d+\.\d+\.\d+(?:[.-]?(?:a|b|rc|dev)\d+)?", version): + + semver_pattern = r"\d+\.\d+\.\d+(?:-(?:a|b|beta|rc|dev)\.?\d+)?" + pep440_dev_pattern = r"\d+\.\d+\.\d+\.dev\d+" + + if re.fullmatch(semver_pattern, version) or re.fullmatch(pep440_dev_pattern, version): return True - return f"⚠️ project version '{version}' does not follow semantic versioning." + + return ( + f"⚠️ project version '{version}' does not follow semantic versioning " + "or the accepted Python dev-version form." + ) class PM014(ProjectMetadata): diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 47e86e65..e4903b8e 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -10,6 +10,15 @@ import git import ansys.pre_commit_hooks.pyansys_quality_report as hook +from ansys.pre_commit_hooks.quality_rules.project_metadata import PM013 + + +def test_pm013_accepts_supported_version_formats(tmp_path): + """Development versions in Python packaging should be accepted alongside SemVer.""" + for version in ["1.2.3", "1.2.3-rc.1", "1.2.3.dev0", "1.2.3.dev1"]: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(f'[project]\nversion = "{version}"\n', encoding="utf-8") + assert PM013.check(tmp_path) is True def test_main_reports_quality_summary(tmp_path, capsys): From ca4a45340bf279c085f53ca3dca6d0575b74c466 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 13:29:50 +0530 Subject: [PATCH 32/49] fix: common module --- .../pre_commit_hooks/quality_rules/common.py | 124 +++++++++++++----- .../quality_rules/project_metadata.py | 2 +- tests/test_pyansys_quality_report.py | 15 +++ 3 files changed, 109 insertions(+), 32 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/common.py b/src/ansys/pre_commit_hooks/quality_rules/common.py index 4a2c491e..a8c54ab5 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/common.py +++ b/src/ansys/pre_commit_hooks/quality_rules/common.py @@ -48,6 +48,13 @@ ] +CANONICAL_WF: dict[str, str] = { + "main": ".github/workflows/ci_cd_main.yml", + "pr": ".github/workflows/ci_cd_pr.yml", + "release": ".github/workflows/ci_cd_release.yml", +} + + def file_exists(root: Traversable, path: str) -> bool: """Return whether a file exists under the repository root.""" try: @@ -59,29 +66,32 @@ def file_exists(root: Traversable, path: str) -> bool: def file_content(root: Traversable, path: str) -> str: """Return the text content of a file under the repository root.""" try: - f = root.joinpath(path) - if f.is_file(): - return f.read_text(encoding="utf-8") + file_path = root.joinpath(path) + + if file_path.is_file(): + return file_path.read_text(encoding="utf-8") + except (AttributeError, OSError, TypeError, UnicodeError, ValueError): return "" + return "" -def file_contains(root: Traversable, path: str, pattern: str | re.Pattern) -> bool: +def file_contains( + root: Traversable, + path: str, + pattern: str | re.Pattern, +) -> bool: """Return whether a file contains the given string or regex pattern.""" content = file_content(root, path) + if not content: return False + if isinstance(pattern, str): return pattern in content - return bool(pattern.search(content)) - -CANONICAL_WF = { - "main": ".github/workflows/ci_cd_main.yml", - "pr": ".github/workflows/ci_cd_pr.yml", - "release": ".github/workflows/ci_cd_release.yml", -} + return bool(pattern.search(content)) def all_workflows_content(root: Traversable) -> str: @@ -89,16 +99,23 @@ def all_workflows_content(root: Traversable) -> str: return _merge_all_workflows(root) -def wf_content(root: Traversable, role: str, workflow_map: dict) -> tuple[bool, str]: +def wf_content( + root: Traversable, + role: str, + workflow_map: dict, +) -> tuple[bool, str]: """Return the content for the workflow matching the given role.""" canonical = CANONICAL_WF[role] + if file_exists(root, canonical): return True, file_content(root, canonical) entry = workflow_map.get(role) + if entry and not entry.get("is_fallback"): path = entry.get("path", "") return False, file_content(root, path) if path else "" + return False, _merge_all_workflows(root) @@ -106,45 +123,58 @@ def _merge_all_workflows(root: Traversable) -> str: """Merge the contents of all workflow files into a single string.""" try: entries = [ - e - for e in root.joinpath(".github/workflows").iterdir() - if e.name.endswith((".yml", ".yaml")) + entry + for entry in root.joinpath(".github/workflows").iterdir() + if entry.name.endswith((".yml", ".yaml")) ] except (AttributeError, FileNotFoundError, OSError, TypeError): return "" - parts = [] + + parts: list[str] = [] + for entry in entries: try: - c = entry.read_text(encoding="utf-8") - if c: - parts.append(c) + content = entry.read_text(encoding="utf-8") + + if content: + parts.append(content) + except (AttributeError, OSError, TypeError, UnicodeError, ValueError): continue + return "\n\n".join(parts) def wf_label(role: str, workflow_map: dict) -> str: """Return a human-readable label for a workflow role.""" entry = workflow_map.get(role) + if not entry: return CANONICAL_WF.get(role, role) + if entry.get("is_fallback"): sources = entry.get("sources", []) return f"{len(sources)} workflow file(s) ({', '.join(sources)})" + return entry.get("name", role) def workflow_map(root: Traversable) -> dict[str, dict]: """Classify workflow files into canonical roles for repository checks.""" - wf_dir = root.joinpath(".github/workflows") + workflow_dir = root.joinpath(".github/workflows") + try: - entries = [e for e in wf_dir.iterdir() if e.name.endswith((".yml", ".yaml"))] + entries = [ + entry for entry in workflow_dir.iterdir() if entry.name.endswith((".yml", ".yaml")) + ] except (AttributeError, FileNotFoundError, OSError, TypeError): entries = [] result: dict[str, dict] = {} + for entry in entries: role = _classify_workflow(entry.name) + if role != "unknown" and role not in result: result[role] = { "name": entry.name, @@ -159,22 +189,31 @@ def workflow_map(root: Traversable) -> dict[str, dict]: "name": f"{len(entries)} workflow(s)", "path": None, "is_fallback": True, - "sources": [e.name for e in entries], + "sources": [entry.name for entry in entries], } return result def _classify_workflow(name: str) -> str: - n = name.lower() - if re.search(r"release|publish|deploy", n): + """Classify a workflow filename into a canonical workflow role.""" + workflow_name = name.lower() + + if re.search(r"release|publish|deploy", workflow_name): return "release" - if re.search(r"\bpr\b|pull.?request|pull_request", n): + + if re.search(r"(^|[_-])pr(?:[_-]|[.]|$)|pull.?request|pull_request", workflow_name): return "pr" - if re.search(r"main|push|branch|nightly|schedule|ci_cd_main|ci.main", n): + + if re.search( + r"main|push|branch|nightly|schedule|ci_cd_main|ci.main", + workflow_name, + ): return "main" - if re.search(r"\bci\b|build|test", n): + + if re.search(r"\bci\b|build|test", workflow_name): return "pr" + return "unknown" @@ -182,8 +221,10 @@ def readme_path(root: Traversable) -> str | None: """Return the preferred README filename if present.""" if file_exists(root, "README.rst"): return "README.rst" + if file_exists(root, "README.md"): return "README.md" + return None @@ -191,9 +232,18 @@ def is_mcp(root: Traversable) -> bool: """Return whether the repository appears to be an MCP project.""" try: pyproject_text = root.joinpath("pyproject.toml").read_text() - return bool(re.search(r"\b(fastmcp|mcp)\b", pyproject_text, re.IGNORECASE)) + + return bool( + re.search( + r"\b(fastmcp|mcp)\b", + pyproject_text, + re.IGNORECASE, + ) + ) + except (AttributeError, OSError, TypeError, UnicodeError, ValueError): pass + try: return file_exists(root, "src/server.py") or file_exists(root, "server.py") except (AttributeError, OSError, TypeError, ValueError): @@ -202,12 +252,15 @@ def is_mcp(root: Traversable) -> bool: def _first_doc_line(obj: Any) -> str: """Return the first line of the check method's docstring, if present.""" - doc = (obj.check.__doc__ or "").strip() - lines = [line.strip() for line in doc.splitlines() if line.strip()] + lines = [line.strip() for line in (obj.check.__doc__ or "").splitlines() if line.strip()] + return lines[0] if lines else "" -def normalize_check_result(raw: bool | str | None, check_obj: Any | None = None) -> tuple[str, str]: +def normalize_check_result( + raw: bool | str | None, + check_obj: Any | None = None, +) -> tuple[str, str]: """Normalize a raw rule result to the canonical status/detail contract. The canonical contract is intentionally simple and shared across the whole @@ -216,19 +269,28 @@ def normalize_check_result(raw: bool | str | None, check_obj: Any | None = None) """ if raw is True: return "pass", "" + if raw is None: return "na", "" + if isinstance(raw, str): if raw.startswith("⚠️ "): return "warn", raw.removeprefix("⚠️ ") + if raw: return "warn", raw + return "fail", "" + if raw is False: if check_obj is None: return "fail", "" + doc = (check_obj.check.__doc__ or "").strip() lines = [line.strip() for line in doc.splitlines() if line.strip()] + detail = lines[-1] if len(lines) > 1 else (lines[0] if lines else "") + return "fail", detail + return "fail", str(raw) diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index c75018e2..5ec1e30b 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -223,7 +223,7 @@ def check(root) -> bool | None | str: version = match.group(1) semver_pattern = r"\d+\.\d+\.\d+(?:-(?:a|b|beta|rc|dev)\.?\d+)?" - pep440_dev_pattern = r"\d+\.\d+\.\d+\.dev\d+" + pep440_dev_pattern = r"\d+\.\d+(?:\.\d+)?\.dev\d+" if re.fullmatch(semver_pattern, version) or re.fullmatch(pep440_dev_pattern, version): return True diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index e4903b8e..559cc63b 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -10,9 +10,24 @@ import git import ansys.pre_commit_hooks.pyansys_quality_report as hook +from ansys.pre_commit_hooks.quality_rules.common import workflow_map from ansys.pre_commit_hooks.quality_rules.project_metadata import PM013 +def test_workflow_map_classifies_ci_cd_roles(tmp_path): + """Workflow filenames like ci_cd_pr.yml should map to their canonical roles.""" + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "ci_cd_main.yml").write_text("name: main\n", encoding="utf-8") + (workflows / "ci_cd_pr.yml").write_text("name: pr\n", encoding="utf-8") + (workflows / "ci_cd_release.yml").write_text("name: release\n", encoding="utf-8") + + result = workflow_map(tmp_path) + + assert set(result) >= {"main", "pr", "release"} + assert result["pr"]["name"] == "ci_cd_pr.yml" + + def test_pm013_accepts_supported_version_formats(tmp_path): """Development versions in Python packaging should be accepted alongside SemVer.""" for version in ["1.2.3", "1.2.3-rc.1", "1.2.3.dev0", "1.2.3.dev1"]: From a1804e9fe692c3bd433fa9f7fd9463b142594249 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 13:43:13 +0530 Subject: [PATCH 33/49] fix: dependabot module --- .../quality_rules/dependabot.py | 77 +++++++++++++++---- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py index 0ce2e7a5..954f3e52 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py +++ b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py @@ -69,7 +69,12 @@ def check(root) -> bool | None: """Return whether the Dependabot config uses the expected schema version.""" if not file_exists(root, _PATH_DEPENDABOT): return None - return file_contains(root, _PATH_DEPENDABOT, re.compile(r"^version:\s*2\s*$", re.M)) + + return file_contains( + root, + _PATH_DEPENDABOT, + re.compile(r"^version:\s*2\s*$", re.MULTILINE), + ) class DB003(Dependabot): @@ -82,16 +87,25 @@ def check(root) -> bool | None | str: """Return whether a supported dependency ecosystem is configured.""" if not file_exists(root, _PATH_DEPENDABOT): return None + has_pip = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + root, + _PATH_DEPENDABOT, + re.compile(r'package-ecosystem:\s*["\']?pip["\']?'), ) + has_uv = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + root, + _PATH_DEPENDABOT, + re.compile(r'package-ecosystem:\s*["\']?uv["\']?'), ) + if has_pip: return True + if has_uv: - return "⚠️ uv ecosystem configured (pip preferred for PyAnsys standard)." + return "⚠️ uv ecosystem configured " "(pip preferred for PyAnsys standard)." + return False @@ -105,8 +119,11 @@ def check(root) -> bool | None: """Return whether the GitHub Actions ecosystem is configured.""" if not file_exists(root, _PATH_DEPENDABOT): return None + return file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?github-actions[\"']?") + root, + _PATH_DEPENDABOT, + re.compile(r'package-ecosystem:\s*["\']?github-actions["\']?'), ) @@ -120,11 +137,20 @@ def check(root) -> bool | None | str: """Return whether the weekly update interval is configured for enough ecosystems.""" if not file_exists(root, _PATH_DEPENDABOT): return None + content = file_content(root, _PATH_DEPENDABOT) - count = len(re.findall(r"interval:\s*[\"']?weekly[\"']?", content)) + + count = len( + re.findall( + r'interval:\s*["\']?weekly["\']?', + content, + ) + ) + if count >= 2: return True - return f"⚠️ Only {count} ecosystem(s) use weekly interval (expected ≥2)." + + return f"⚠️ Only {count} ecosystem(s) use weekly interval " "(expected ≥2)." class DB006(Dependabot): @@ -137,8 +163,14 @@ def check(root) -> bool | None | str: """Return whether the Dependabot cooldown policy is set to seven days.""" if not file_exists(root, _PATH_DEPENDABOT): return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r"default-days:\s*7")): + + if file_contains( + root, + _PATH_DEPENDABOT, + re.compile(r"default-days:\s*7"), + ): return True + return "⚠️ Cooldown default-days: 7 not found in dependabot.yml." @@ -152,19 +184,30 @@ def check(root) -> bool | None | str: """Return whether pip uses the lockfile-only versioning strategy.""" if not file_exists(root, _PATH_DEPENDABOT): return None + has_uv = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?uv[\"']?") + root, + _PATH_DEPENDABOT, + re.compile(r'package-ecosystem:\s*["\']?uv["\']?'), ) + has_pip = file_contains( - root, _PATH_DEPENDABOT, re.compile(r"package-ecosystem:\s*[\"']?pip[\"']?") + root, + _PATH_DEPENDABOT, + re.compile(r'package-ecosystem:\s*["\']?pip["\']?'), ) + if has_uv and not has_pip: return None + if file_contains( - root, _PATH_DEPENDABOT, re.compile(r"versioning-strategy:\s*[\"']?lockfile-only[\"']?") + root, + _PATH_DEPENDABOT, + re.compile(r'versioning-strategy:\s*["\']?lockfile-only["\']?'), ): return True - return "⚠️ versioning-strategy: lockfile-only not found for pip ecosystem." + + return "⚠️ versioning-strategy: lockfile-only " "not found for pip ecosystem." class DB008(Dependabot): @@ -177,6 +220,12 @@ def check(root) -> bool | None | str: """Return whether the pip group wildcard pattern is defined.""" if not file_exists(root, _PATH_DEPENDABOT): return None - if file_contains(root, _PATH_DEPENDABOT, re.compile(r'patterns:\s*\n\s+- ["\']?\*["\']?')): + + if file_contains( + root, + _PATH_DEPENDABOT, + re.compile(r'patterns:\s*\n\s*-\s*["\']?\*["\']?'), + ): return True - return '⚠️ pip groups wildcard pattern "- "*"" not found in dependabot.yml.' + + return "⚠️ pip groups wildcard pattern " '"- \\"*\\"" not found in dependabot.yml.' From f844b64aa56f6e02cbfe2e6e3767a7e1ef01934f Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 16:50:50 +0530 Subject: [PATCH 34/49] fix: documentation module --- .../quality_rules/documentation.py | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/documentation.py b/src/ansys/pre_commit_hooks/quality_rules/documentation.py index 4b997e0f..e4509b2c 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/documentation.py +++ b/src/ansys/pre_commit_hooks/quality_rules/documentation.py @@ -28,7 +28,16 @@ from .common import file_contains, file_exists -__all__ = ["Documentation", "DOC001", "DOC002", "DOC003", "DOC004", "DOC005", "DOC006", "DOC007"] +__all__ = [ + "Documentation", + "DOC001", + "DOC002", + "DOC003", + "DOC004", + "DOC005", + "DOC006", + "DOC007", +] class Documentation: @@ -53,7 +62,7 @@ class DOC002(Documentation): @staticmethod def check(root) -> bool: - """Return whether the Sphinx conf.py file exists.""" + """Return whether the Sphinx configuration file exists.""" return file_exists(root, "doc/source/conf.py") @@ -64,9 +73,10 @@ class DOC003(Documentation): @staticmethod def check(root) -> bool | None: - """Return whether numpydoc is enabled in the Sphinx config.""" + """Return whether numpydoc is enabled in the Sphinx configuration.""" if not file_exists(root, "doc/source/conf.py"): return None + return file_contains(root, "doc/source/conf.py", "numpydoc") @@ -77,9 +87,10 @@ class DOC004(Documentation): @staticmethod def check(root) -> bool | None: - """Return whether sphinx_design is enabled in the Sphinx config.""" + """Return whether sphinx_design is enabled in the Sphinx configuration.""" if not file_exists(root, "doc/source/conf.py"): return None + return file_contains(root, "doc/source/conf.py", "sphinx_design") @@ -90,9 +101,10 @@ class DOC005(Documentation): @staticmethod def check(root) -> bool | None: - """Return whether intersphinx is enabled in the Sphinx config.""" + """Return whether intersphinx is enabled in the Sphinx configuration.""" if not file_exists(root, "doc/source/conf.py"): return None + return file_contains(root, "doc/source/conf.py", "intersphinx") @@ -103,10 +115,15 @@ class DOC006(Documentation): @staticmethod def check(root) -> bool | None: - """Return whether the docs index includes a getting-started section.""" + """Return whether the documentation index includes a getting-started section.""" if not file_exists(root, "doc/source/index.rst"): return None - return file_contains(root, "doc/source/index.rst", re.compile(r"getting.started", re.I)) + + return file_contains( + root, + "doc/source/index.rst", + re.compile(r"getting.started", re.IGNORECASE), + ) class DOC007(Documentation): @@ -116,9 +133,15 @@ class DOC007(Documentation): @staticmethod def check(root) -> bool | None: - """Return whether the docs index includes an API reference section.""" + """Return whether the documentation index includes an API reference section.""" if not file_exists(root, "doc/source/index.rst"): return None + return file_contains( - root, "doc/source/index.rst", re.compile(r"api.reference|api_reference", re.I) + root, + "doc/source/index.rst", + re.compile( + r"api.reference|api_reference", + re.IGNORECASE, + ), ) From cf47ad157d6da7c00bf096b23897568551b8600f Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 17:04:34 +0530 Subject: [PATCH 35/49] fix: pre-commit and labeler --- .../pre_commit_hooks/quality_rules/labeler.py | 17 ++- .../quality_rules/pre_commit.py | 104 +++++++++++++----- 2 files changed, 92 insertions(+), 29 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/labeler.py b/src/ansys/pre_commit_hooks/quality_rules/labeler.py index dc324a44..7d482b17 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/labeler.py +++ b/src/ansys/pre_commit_hooks/quality_rules/labeler.py @@ -19,14 +19,20 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. - """Labeler checks.""" from __future__ import annotations from .common import file_contains, file_exists -__all__ = ["Labeler", "LB001", "LB002", "LB003", "LB004", "LB005"] +__all__ = [ + "Labeler", + "LB001", + "LB002", + "LB003", + "LB004", + "LB005", +] class Labeler: @@ -40,7 +46,7 @@ class LB001(Labeler): @staticmethod def check(root) -> bool: - """Return whether the labeler config exists.""" + """Return whether the labeler configuration exists.""" return file_exists(root, ".github/labeler.yml") @@ -49,7 +55,7 @@ class LB002(Labeler): @staticmethod def check(root) -> bool: - """Return whether the labels config exists.""" + """Return whether the labels configuration exists.""" return file_exists(root, ".github/labels.yml") @@ -63,6 +69,7 @@ def check(root) -> bool | None: """Return whether the bug label is present.""" if not file_exists(root, ".github/labels.yml"): return None + return file_contains(root, ".github/labels.yml", "bug") @@ -76,6 +83,7 @@ def check(root) -> bool | None: """Return whether the enhancement label is present.""" if not file_exists(root, ".github/labels.yml"): return None + return file_contains(root, ".github/labels.yml", "enhancement") @@ -89,4 +97,5 @@ def check(root) -> bool | None: """Return whether the documentation label is present.""" if not file_exists(root, ".github/labels.yml"): return None + return file_contains(root, ".github/labels.yml", "documentation") diff --git a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py index 60c3a799..99b2274d 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py +++ b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py @@ -20,7 +20,24 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Pre-commit configuration checks.""" +"""Pre-commit configuration checks. + +This rule set validates the presence of a standard PyAnsys pre-commit +configuration and verifies that the following tools and hooks are configured: + +* ruff-pre-commit +* zizmor (--pedantic) +* blacken-docs +* codespell +* ansys/pre-commit-hooks +* yamlfmt +* pyright + +The checks also verify repository maintenance settings such as: + +* autofix_prs: true +* autoupdate_schedule: weekly +""" from __future__ import annotations @@ -42,6 +59,8 @@ "PC010", ] +_PRE_COMMIT_CONFIG = ".pre-commit-config.yaml" + class PreCommit: """Pre-commit rule family.""" @@ -54,8 +73,8 @@ class PC001(PreCommit): @staticmethod def check(root) -> bool: - """Return whether the pre-commit config exists.""" - return file_exists(root, ".pre-commit-config.yaml") + """Return whether the pre-commit configuration exists.""" + return file_exists(root, _PRE_COMMIT_CONFIG) class PC002(PreCommit): @@ -65,10 +84,11 @@ class PC002(PreCommit): @staticmethod def check(root) -> bool | None: - """Return whether ruff-pre-commit is present in the config.""" - if not file_exists(root, ".pre-commit-config.yaml"): + """Return whether ruff-pre-commit is configured.""" + if not file_exists(root, _PRE_COMMIT_CONFIG): return None - return file_contains(root, ".pre-commit-config.yaml", "ruff-pre-commit") + + return file_contains(root, _PRE_COMMIT_CONFIG, "ruff-pre-commit") class PC003(PreCommit): @@ -79,14 +99,27 @@ class PC003(PreCommit): @staticmethod def check(root) -> bool | None | str: """Return whether zizmor is configured with the pedantic option.""" - if not file_exists(root, ".pre-commit-config.yaml"): + if not file_exists(root, _PRE_COMMIT_CONFIG): return None - has_zizmor = file_contains(root, ".pre-commit-config.yaml", "zizmor") - has_pedantic = file_contains(root, ".pre-commit-config.yaml", "--pedantic") + + has_zizmor = file_contains( + root, + _PRE_COMMIT_CONFIG, + "zizmor", + ) + + has_pedantic = file_contains( + root, + _PRE_COMMIT_CONFIG, + "--pedantic", + ) + if not has_zizmor: return False + if not has_pedantic: return "⚠️ zizmor found but --pedantic flag not set." + return True @@ -98,9 +131,10 @@ class PC004(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether blacken-docs is configured.""" - if not file_exists(root, ".pre-commit-config.yaml"): + if not file_exists(root, _PRE_COMMIT_CONFIG): return None - return file_contains(root, ".pre-commit-config.yaml", "blacken-docs") + + return file_contains(root, _PRE_COMMIT_CONFIG, "blacken-docs") class PC005(PreCommit): @@ -111,9 +145,10 @@ class PC005(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether codespell is configured.""" - if not file_exists(root, ".pre-commit-config.yaml"): + if not file_exists(root, _PRE_COMMIT_CONFIG): return None - return file_contains(root, ".pre-commit-config.yaml", "codespell") + + return file_contains(root, _PRE_COMMIT_CONFIG, "codespell") class PC006(PreCommit): @@ -123,10 +158,15 @@ class PC006(PreCommit): @staticmethod def check(root) -> bool | None: - """Return whether the repository uses the shared Ansys pre-commit hook.""" - if not file_exists(root, ".pre-commit-config.yaml"): + """Return whether the shared Ansys pre-commit hooks are configured.""" + if not file_exists(root, _PRE_COMMIT_CONFIG): return None - return file_contains(root, ".pre-commit-config.yaml", "ansys/pre-commit-hooks") + + return file_contains( + root, + _PRE_COMMIT_CONFIG, + "ansys/pre-commit-hooks", + ) class PC007(PreCommit): @@ -137,9 +177,10 @@ class PC007(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether yamlfmt is configured.""" - if not file_exists(root, ".pre-commit-config.yaml"): + if not file_exists(root, _PRE_COMMIT_CONFIG): return None - return file_contains(root, ".pre-commit-config.yaml", "yamlfmt") + + return file_contains(root, _PRE_COMMIT_CONFIG, "yamlfmt") class PC008(PreCommit): @@ -150,9 +191,10 @@ class PC008(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether pyright is configured.""" - if not file_exists(root, ".pre-commit-config.yaml"): + if not file_exists(root, _PRE_COMMIT_CONFIG): return None - return file_contains(root, ".pre-commit-config.yaml", "pyright") + + return file_contains(root, _PRE_COMMIT_CONFIG, "pyright") class PC009(PreCommit): @@ -162,11 +204,17 @@ class PC009(PreCommit): @staticmethod def check(root) -> bool | None | str: - """Return whether pull requests are configured to autogenerate fixes.""" - if not file_exists(root, ".pre-commit-config.yaml"): + """Return whether automatic pull-request fixes are enabled.""" + if not file_exists(root, _PRE_COMMIT_CONFIG): return None - if file_contains(root, ".pre-commit-config.yaml", "autofix_prs: true"): + + if file_contains( + root, + _PRE_COMMIT_CONFIG, + "autofix_prs: true", + ): return True + return "⚠️ autofix_prs: true not set in ci: block." @@ -178,10 +226,16 @@ class PC010(PreCommit): @staticmethod def check(root) -> bool | None | str: """Return whether the pre-commit autoupdate schedule is weekly.""" - if not file_exists(root, ".pre-commit-config.yaml"): + if not file_exists(root, _PRE_COMMIT_CONFIG): return None + if file_contains( - root, ".pre-commit-config.yaml", re.compile(r"autoupdate_schedule:\s*weekly") + root, + _PRE_COMMIT_CONFIG, + re.compile( + r"autoupdate_schedule:\s*weekly", + ), ): return True + return "⚠️ autoupdate_schedule: weekly not found." From 7dacc8af8bca37c530d705f71a2c4221e48a8d2d Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 18:27:10 +0530 Subject: [PATCH 36/49] fix: project metadata module --- .../quality_rules/project_metadata.py | 178 +++++++++++++----- 1 file changed, 136 insertions(+), 42 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 5ec1e30b..9fd14677 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -20,7 +20,36 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Project metadata checks.""" +"""Project metadata checks. + +This rule set validates repository metadata against PyAnsys standards. + +The checks cover: + +* Governance and repository files + - AUTHORS + - CHANGELOG.md + - CODE_OF_CONDUCT.md + - CONTRIBUTING.md + - CONTRIBUTORS.md + - LICENSE + - SECURITY.md + - .github/CODEOWNERS + +* README requirements + - README exists + - README.rst preferred over README.md + - pyproject.toml references the README + +* Packaging metadata + - LICENSE metadata is declared + - Project name follows ansys-*-* convention + - Project version follows accepted versioning schemes + - Author and maintainer metadata is configured + +* Licensing + - LICENSE file contains recognized MIT or Apache 2.0 wording +""" from __future__ import annotations @@ -50,6 +79,12 @@ "PM015", ] +_PYPROJECT = "pyproject.toml" +_LICENSE = "LICENSE" + +_DEFAULT_AUTHOR = "Synopsys, Inc. and ANSYS, Inc." +_DEFAULT_EMAIL = "pyansys-core@synopsys.com" + class ProjectMetadata: """Project metadata rule family.""" @@ -80,7 +115,7 @@ class PM003(ProjectMetadata): @staticmethod def check(root) -> bool: - """Return whether the code of conduct file is present.""" + """Return whether the code-of-conduct file is present.""" return file_exists(root, "CODE_OF_CONDUCT.md") @@ -108,7 +143,7 @@ class PM006(ProjectMetadata): @staticmethod def check(root) -> bool: """Return whether the license file is present.""" - return file_exists(root, "LICENSE") + return file_exists(root, _LICENSE) class PM007(ProjectMetadata): @@ -119,8 +154,10 @@ def check(root, readme_path: str | None) -> bool | str: """Return whether the README is present and in the preferred format.""" if readme_path is None: return False + if readme_path == "README.md": return "⚠️ README.md found — README.rst is the preferred format." + return True @@ -138,7 +175,7 @@ class PM009(ProjectMetadata): @staticmethod def check(root) -> bool: - """Return whether the code owners file is present.""" + """Return whether the CODEOWNERS file is present.""" return file_exists(root, ".github/CODEOWNERS") @@ -150,23 +187,31 @@ class PM010(ProjectMetadata): @staticmethod def check(root, readme_path: str | None) -> bool | None | str: """Return whether pyproject.toml references the expected README file.""" - if not file_exists(root, "pyproject.toml"): + if not file_exists(root, _PYPROJECT): return None - content = file_content(root, "pyproject.toml") + + content = file_content(root, _PYPROJECT) + if re.search(r"poetry\.core|poetry-core", content): - m = re.search( - r"\[tool\.poetry\][\s\S]*?readme\s*=\s*[\"']([^\"']+)[\"']", + match = re.search( + r"\[^\"']+[\"']", content, - re.M, + re.MULTILINE, ) - if m: + + if match: return True + return False - rm = readme_path or "README.rst" - if rm in content: + + readme = readme_path or "README.rst" + + if readme in content: return True + if "README" in content: - return "⚠️ readme key found but exact README filename not confirmed." + return "⚠️ readme key found but exact README filename " "not confirmed." + return False @@ -178,15 +223,27 @@ class PM011(ProjectMetadata): @staticmethod def check(root) -> bool | None: """Return whether pyproject.toml references the license file.""" - if not file_exists(root, "pyproject.toml"): + if not file_exists(root, _PYPROJECT): return None - c = file_content(root, "pyproject.toml") - if re.search(r"poetry\.core|poetry-core", c): - return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", c, re.M)) + + content = file_content(root, _PYPROJECT) + + if re.search(r"poetry\.core|poetry-core", content): + return bool( + re.search( + r"\[tool\.poetry\][\s\S]*?license\s*=", + content, + re.MULTILINE, + ) + ) + return bool( - re.search(r"license-files\s*=", c) - or re.search(r"license\s*=\s*\{[^}]*file", c) - or re.search(r'license\s*=\s*["\']LICENSE["\']', c) + re.search(r"license-files\s*=", content) + or re.search(r"license\s*=\s*\{[^}]*file", content) + or re.search( + r'license\s*=\s*["\']LICENSE["\']', + content, + ) ) @@ -196,81 +253,110 @@ class PM012(ProjectMetadata): @staticmethod def check(root) -> bool | None | str: """Return whether the project name matches the PyAnsys naming convention.""" - if not file_exists(root, "pyproject.toml"): + if not file_exists(root, _PYPROJECT): return None - content = file_content(root, "pyproject.toml") - match = re.search(r'^name\s*=\s*["\']([^"\']+)["\']', content, re.M) + + content = file_content(root, _PYPROJECT) + + match = re.search( + r'^name\s*=\s*["\']([^"\']+)["\']', + content, + re.MULTILINE, + ) + if not match: return "⚠️ project name not found in pyproject.toml." + name = match.group(1) + if re.fullmatch(r"ansys-[a-z0-9-]+-[a-z0-9-]+", name): return True + return f"⚠️ project name '{name}' does not match ansys-*-*." class PM013(ProjectMetadata): - """Project version follows semantic versioning or accepted Python dev versions.""" + """Project version follows semantic versioning or accepted dev versions.""" @staticmethod def check(root) -> bool | None | str: """Return whether the project version uses a valid release or dev version.""" - if not file_exists(root, "pyproject.toml"): + if not file_exists(root, _PYPROJECT): return None - content = file_content(root, "pyproject.toml") - match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.M) + + content = file_content(root, _PYPROJECT) + + match = re.search( + r'^version\s*=\s*["\']([^"\']+)["\']', + content, + re.MULTILINE, + ) + if not match: return "⚠️ project version not found in pyproject.toml." + version = match.group(1) - semver_pattern = r"\d+\.\d+\.\d+(?:-(?:a|b|beta|rc|dev)\.?\d+)?" + semver_pattern = r"\d+\.\d+\.\d+" r"(?:-(?:a|b|beta|rc|dev)\.?\d+)?" + pep440_dev_pattern = r"\d+\.\d+(?:\.\d+)?\.dev\d+" if re.fullmatch(semver_pattern, version) or re.fullmatch(pep440_dev_pattern, version): return True return ( - f"⚠️ project version '{version}' does not follow semantic versioning " - "or the accepted Python dev-version form." + f"⚠️ project version '{version}' does not follow " + "semantic versioning or the accepted Python " + "dev-version form." ) class PM014(ProjectMetadata): - """Project author and maintainer metadata matches the PyAnsys defaults.""" + """Project author and maintainer metadata matches PyAnsys defaults.""" @staticmethod def check(root) -> bool | None | str: """Return whether author and maintainer metadata are configured as expected.""" - if not file_exists(root, "pyproject.toml"): + if not file_exists(root, _PYPROJECT): return None - content = file_content(root, "pyproject.toml") + + content = file_content(root, _PYPROJECT) + name_ok = bool( re.search( - r'authors\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', + rf'authors\s*=\s*\[[\s\S]*?name\s*=\s*["\']{re.escape(_DEFAULT_AUTHOR)}["\']', content, ) ) + email_ok = bool( re.search( - r'authors\s*=\s*\[[\s\S]*?email\s*=\s*["\']pyansys-core@synopsys.com["\']', content + rf'authors\s*=\s*\[[\s\S]*?email\s*=\s*["\']{re.escape(_DEFAULT_EMAIL)}["\']', + content, ) ) + maintainer_name_ok = bool( re.search( - r'maintainers\s*=\s*\[[\s\S]*?name\s*=\s*["\']Synopsys, Inc\. and ANSYS, Inc\.["\']', # noqa: E501 + rf'maintainers\s*=\s*\[[\s\S]*?name\s*=\s*["\']{re.escape(_DEFAULT_AUTHOR)}["\']', content, ) ) + maintainer_email_ok = bool( re.search( - r'maintainers\s*=\s*\[[\s\S]*?email\s*=\s*["\']pyansys-core@synopsys.com["\']', + rf'maintainers\s*=\s*\[[\s\S]*?email\s*=\s*["\']{re.escape(_DEFAULT_EMAIL)}["\']', content, ) ) + if name_ok and email_ok and maintainer_name_ok and maintainer_email_ok: return True + return ( "⚠️ author/maintainer metadata does not match " - "Synopsys, Inc. and ANSYS, Inc. / pyansys-core@synopsys.com." + "Synopsys, Inc. and ANSYS, Inc. / " + "pyansys-core@synopsys.com." ) @@ -282,15 +368,23 @@ class PM015(ProjectMetadata): @staticmethod def check(root) -> bool | None: """Return whether the LICENSE file contains expected MIT or Apache 2.0 text.""" - if not file_exists(root, "LICENSE"): + if not file_exists(root, _LICENSE): return None - content = file_content(root, "LICENSE") + + content = file_content(root, _LICENSE) + if ( "MIT License" in content - or re.search(r"Apache License.*Version 2\.0", content, re.IGNORECASE | re.DOTALL) + or re.search( + r"Apache License.*Version 2\.0", + content, + re.IGNORECASE | re.DOTALL, + ) or "Apache License" in content ): return True + return ( - "⚠️ LICENSE file content is missing a recognized license statement (MIT or Apache 2.0)." + "⚠️ LICENSE file content is missing a recognized " + "license statement (MIT or Apache 2.0)." ) From 5791a2b4e070c4a9445eaab6c53c476d46822978 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 18:38:28 +0530 Subject: [PATCH 37/49] fix: readme module --- .../pre_commit_hooks/quality_rules/readme.py | 84 ++++++++++++++++--- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/readme.py b/src/ansys/pre_commit_hooks/quality_rules/readme.py index e71fc9b3..c0dcc690 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/readme.py +++ b/src/ansys/pre_commit_hooks/quality_rules/readme.py @@ -20,7 +20,29 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""README checks.""" +"""README quality checks. + +This rule set validates that the repository README follows PyAnsys +documentation standards and includes commonly expected project badges. + +The checks cover: + +* README availability + - README.rst (preferred) + - README.md (supported but not preferred) + +* Badges + - PyAnsys badge + - PyPI badge + - Codecov badge + - MIT License badge + - GitHub Actions CI badge + +* Content sections + - Installation + - Documentation + - License +""" from __future__ import annotations @@ -56,8 +78,10 @@ def check(root, readme_path: str | None) -> bool | str: """Return whether the repository has a supported README file.""" if readme_path == "README.rst": return True + if readme_path == "README.md": return "⚠️ README.md found — PyAnsys preferred format is README.rst." + return False @@ -71,15 +95,19 @@ def check(root, readme_path: str | None) -> bool | None | str: """Return whether the README contains a PyAnsys badge.""" if not readme_path: return None + if file_contains( root, readme_path, re.compile( - r"badge\.svg[^)\"']*pyansys|pyansys[^)\"']*badge\.svg|img\.shields\.io[^)\"']*pyansys", # noqa: E501 - re.I, + r"badge\.svg[^)\"']*pyansys|" + r"pyansys[^)\"']*badge\.svg|" + r"img\.shields\.io[^)\"']*pyansys", + re.IGNORECASE, ), ): return True + return f"⚠️ PyAnsys badge image not found in {readme_path}." @@ -93,15 +121,19 @@ def check(root, readme_path: str | None) -> bool | None | str: """Return whether the README contains a PyPI badge.""" if not readme_path: return None + if file_contains( root, readme_path, re.compile( - r"img\.shields\.io[^)\"']*pypi|pypi\.org/project[^)\"']*badge|badge\.fury\.io/py", - re.I, + r"img\.shields\.io[^)\"']*pypi|" + r"pypi\.org/project[^)\"']*badge|" + r"badge\.fury\.io/py", + re.IGNORECASE, ), ): return True + return f"⚠️ PyPI badge image not found in {readme_path}." @@ -115,12 +147,17 @@ def check(root, readme_path: str | None) -> bool | None | str: """Return whether the README contains a Codecov badge.""" if not readme_path: return None + if file_contains( root, readme_path, - re.compile(r"codecov\.io[^)\"']*badge|badge\.svg[^)\"']*codecov", re.I), + re.compile( + r"codecov\.io[^)\"']*badge|" r"badge\.svg[^)\"']*codecov", + re.IGNORECASE, + ), ): return True + return f"⚠️ Codecov badge image not found in {readme_path}." @@ -134,12 +171,17 @@ def check(root, readme_path: str | None) -> bool | None | str: """Return whether the README contains an MIT license badge.""" if not readme_path: return None + if file_contains( root, readme_path, - re.compile(r"shields\.io[^)\"']*mit|img\.shields\.io[^)\"']*license", re.I), + re.compile( + r"shields\.io[^)\"']*mit|" r"img\.shields\.io[^)\"']*license", + re.IGNORECASE, + ), ): return True + return f"⚠️ MIT license badge image not found in {readme_path}." @@ -153,12 +195,17 @@ def check(root, readme_path: str | None) -> bool | None | str: """Return whether the README contains a GitHub Actions badge.""" if not readme_path: return None + if file_contains( root, readme_path, - re.compile(r"github\.com/[^/]+/[^/]+/actions/workflows/[^)\"']+badge\.svg", re.I), + re.compile( + r"github\.com/[^/]+/[^/]+/actions/workflows/" r'[^)"\']+badge\.svg', + re.IGNORECASE, + ), ): return True + return f"⚠️ GH-CI workflow badge.svg URL not found in {readme_path}." @@ -172,7 +219,12 @@ def check(root, readme_path: str | None) -> bool | None: """Return whether the README mentions installation instructions.""" if not readme_path: return None - return file_contains(root, readme_path, re.compile(r"install", re.I)) + + return file_contains( + root, + readme_path, + re.compile(r"install", re.IGNORECASE), + ) class RM007(README): @@ -185,7 +237,12 @@ def check(root, readme_path: str | None) -> bool | None: """Return whether the README contains a documentation section.""" if not readme_path: return None - return file_contains(root, readme_path, re.compile(r"documentation", re.I)) + + return file_contains( + root, + readme_path, + re.compile(r"documentation", re.IGNORECASE), + ) class RM008(README): @@ -198,4 +255,9 @@ def check(root, readme_path: str | None) -> bool | None: """Return whether the README contains a license section.""" if not readme_path: return None - return file_contains(root, readme_path, re.compile(r"license", re.I)) + + return file_contains( + root, + readme_path, + re.compile(r"license", re.IGNORECASE), + ) From 05a8c2166b065ba7bfb1ee4a9b9ffc51d11b0671 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 18:47:50 +0530 Subject: [PATCH 38/49] fix: security module and also all the docstrings --- .../quality_rules/__init__.py | 18 ++- .../quality_rules/build_system.py | 12 +- .../pre_commit_hooks/quality_rules/cicd.py | 14 ++- .../quality_rules/cicd_files.py | 13 ++- .../pre_commit_hooks/quality_rules/common.py | 14 ++- .../quality_rules/dependabot.py | 14 ++- .../quality_rules/documentation.py | 13 ++- .../pre_commit_hooks/quality_rules/labeler.py | 14 ++- .../pre_commit_hooks/quality_rules/mcp.py | 15 ++- .../quality_rules/pre_commit.py | 22 ++-- .../quality_rules/security.py | 103 ++++++++++++++---- .../pre_commit_hooks/quality_rules/vale.py | 13 ++- 12 files changed, 223 insertions(+), 42 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index 82095597..126379aa 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -20,7 +20,23 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Rule definitions for the PyAnsys repository quality report.""" +"""Rule definitions for the PyAnsys repository quality report. + +This package contains the repository quality checks used to review PyAnsys +projects for consistent metadata, automation, documentation, and security +standards. + +The checks are grouped into rule families such as: + +* build system validation +* CI/CD workflow validation +* Dependabot configuration +* documentation requirements +* project metadata +* README quality +* security checks +* Vale linting configuration +""" from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/build_system.py b/src/ansys/pre_commit_hooks/quality_rules/build_system.py index 636f3469..ce1a45fc 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/build_system.py +++ b/src/ansys/pre_commit_hooks/quality_rules/build_system.py @@ -20,7 +20,17 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Build system checks.""" +"""Build system checks. + +This rule set validates repository build metadata and ensures the project uses +an acceptable Python packaging backend. + +The checks cover: + +* build-system table presence +* setuptools, Poetry, Hatchling, Flit, or PDM detection +* backend preference validation against the repository standard +""" from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/cicd.py b/src/ansys/pre_commit_hooks/quality_rules/cicd.py index 76858a96..edd90ea1 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/cicd.py +++ b/src/ansys/pre_commit_hooks/quality_rules/cicd.py @@ -20,7 +20,19 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""CI/CD content checks.""" +"""CI/CD content checks. + +This rule set validates the expected GitHub Actions workflow policy for +repository automation. + +The checks cover: + +* concurrency blocks in the PR and main workflows +* root permissions configuration +* workflow triggers and required jobs +* SHA-pinned action references +* required security and release controls +""" from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py b/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py index 88b780da..7f2b33e9 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py +++ b/src/ansys/pre_commit_hooks/quality_rules/cicd_files.py @@ -19,7 +19,18 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""CI/CD workflow file naming checks.""" +"""CI/CD workflow file naming checks. + +This rule set validates the presence of the canonical workflow files used by +repository automation. + +The checks cover: + +* ci_cd_main.yml presence +* ci_cd_pr.yml presence +* ci_cd_release.yml presence +* workflow-role naming consistency +""" from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/common.py b/src/ansys/pre_commit_hooks/quality_rules/common.py index a8c54ab5..06714f2d 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/common.py +++ b/src/ansys/pre_commit_hooks/quality_rules/common.py @@ -20,7 +20,19 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Shared helpers used by the repository quality checks.""" +"""Shared helpers used by the repository quality checks. + +This module provides the common file, workflow, and README utilities used by +all rule families. + +The helpers cover: + +* file existence and content checks +* workflow discovery and classification +* canonical workflow lookup +* README path detection +* result normalization for quality reports +""" from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py index 954f3e52..ae39e9a7 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py +++ b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py @@ -20,7 +20,19 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Dependabot checks.""" +"""Dependabot checks. + +This rule set validates the repository Dependabot configuration and expected +update automation settings. + +The checks cover: + +* Dependabot config presence +* schema version validation +* ecosystem configuration +* update schedule and grouping policy +* security-relevant dependency settings +""" from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/documentation.py b/src/ansys/pre_commit_hooks/quality_rules/documentation.py index e4509b2c..87ad35b6 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/documentation.py +++ b/src/ansys/pre_commit_hooks/quality_rules/documentation.py @@ -20,7 +20,18 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Documentation checks.""" +"""Documentation checks. + +This rule set validates the repository documentation structure and Sphinx +configuration. + +The checks cover: + +* doc/source directory presence +* Sphinx config file presence +* numpydoc configuration +* expected documentation conventions and structure +""" from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/labeler.py b/src/ansys/pre_commit_hooks/quality_rules/labeler.py index 7d482b17..4ca58581 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/labeler.py +++ b/src/ansys/pre_commit_hooks/quality_rules/labeler.py @@ -19,7 +19,19 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Labeler checks.""" +"""Labeler checks. + +This rule set validates the repository label configuration used for issue and +pull request triage. + +The checks cover: + +* labeler configuration presence +* labels.yml presence +* bug label definition +* enhancement label definition +* documentation label definition +""" from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/mcp.py b/src/ansys/pre_commit_hooks/quality_rules/mcp.py index 5700f944..34d5335c 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/mcp.py +++ b/src/ansys/pre_commit_hooks/quality_rules/mcp.py @@ -20,7 +20,20 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""MCP release readiness checks.""" +"""MCP release readiness checks. + +This rule set validates the minimum governance and release-readiness checks +required for MCP projects. + +The checks cover: + +* required governance files +* canonical workflow presence +* test and documentation jobs in the PR workflow +* README and docs metadata alignment +* TODO/FIXME policy in the docs index +* security checks and release safeguards +""" from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py index 99b2274d..a37f1d14 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py +++ b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py @@ -22,21 +22,17 @@ """Pre-commit configuration checks. -This rule set validates the presence of a standard PyAnsys pre-commit -configuration and verifies that the following tools and hooks are configured: +This rule set validates the repository pre-commit configuration and required +repository automation hooks. -* ruff-pre-commit -* zizmor (--pedantic) -* blacken-docs -* codespell -* ansys/pre-commit-hooks -* yamlfmt -* pyright +The checks cover: -The checks also verify repository maintenance settings such as: - -* autofix_prs: true -* autoupdate_schedule: weekly +* .pre-commit-config.yaml presence +* ruff-pre-commit configuration +* zizmor configuration and pedantic mode +* formatting and spelling hooks +* ansys/pre-commit-hooks integration +* repository maintenance settings such as autofix and weekly updates """ from __future__ import annotations diff --git a/src/ansys/pre_commit_hooks/quality_rules/security.py b/src/ansys/pre_commit_hooks/quality_rules/security.py index 3e0bb018..f5549da7 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/security.py +++ b/src/ansys/pre_commit_hooks/quality_rules/security.py @@ -20,15 +20,49 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Security checks.""" +"""Security checks. + +This rule set validates repository security configurations and +secure-development best practices. + +The checks cover: + +* Zizmor configuration + - .github/zizmor.yml exists + - secrets-outside-env rule enabled + +* Secret scanning + - gitleaks pre-commit hook configured + +* GitHub Actions security + - Actions are pinned to full commit SHAs + +* Security policy + - SECURITY.md discourages public disclosure of vulnerabilities +""" from __future__ import annotations import re -from ansys.pre_commit_hooks.quality_rules.common import file_contains, file_exists, wf_content +from ansys.pre_commit_hooks.quality_rules.common import ( + file_contains, + file_exists, + wf_content, +) -__all__ = ["Security", "SEC001", "SEC002", "SEC003", "SEC004", "SEC005"] +__all__ = [ + "Security", + "SEC001", + "SEC002", + "SEC003", + "SEC004", + "SEC005", +] + +_ZIZMOR_CONFIG = ".github/zizmor.yml" +_PRE_COMMIT_CONFIG = ".pre-commit-config.yaml" +_SECURITY_POLICY = "SECURITY.md" class Security: @@ -42,10 +76,11 @@ class SEC001(Security): @staticmethod def check(root) -> bool | str: - """Return whether the Zizmor config is present.""" - if file_exists(root, ".github/zizmor.yml"): + """Return whether the Zizmor configuration is present.""" + if file_exists(root, _ZIZMOR_CONFIG): return True - return "⚠️ .github/zizmor.yml not found — optional but recommended." + + return "⚠️ .github/zizmor.yml not found " "— optional but recommended." class SEC002(Security): @@ -55,10 +90,15 @@ class SEC002(Security): @staticmethod def check(root) -> bool | None: - """Return whether the Zizmor config enables the secrets-outside-env rule.""" - if not file_exists(root, ".github/zizmor.yml"): + """Return whether the Zizmor configuration enables the secrets-outside-env rule.""" + if not file_exists(root, _ZIZMOR_CONFIG): return None - return file_contains(root, ".github/zizmor.yml", "secrets-outside-env") + + return file_contains( + root, + _ZIZMOR_CONFIG, + "secrets-outside-env", + ) class SEC003(Security): @@ -66,10 +106,15 @@ class SEC003(Security): @staticmethod def check(root) -> bool | None: - """Return whether the pre-commit config includes gitleaks.""" - if not file_exists(root, ".pre-commit-config.yaml"): + """Return whether the pre-commit configuration includes gitleaks.""" + if not file_exists(root, _PRE_COMMIT_CONFIG): return None - return file_contains(root, ".pre-commit-config.yaml", "gitleaks") + + return file_contains( + root, + _PRE_COMMIT_CONFIG, + "gitleaks", + ) class SEC004(Security): @@ -77,13 +122,24 @@ class SEC004(Security): @staticmethod def check(root, workflow_map: dict) -> bool | None | str: - """Return whether the PR workflow pins GitHub Actions to full SHAs.""" - _, content = wf_content(root, "pr", workflow_map) + """Return whether the PR workflow pins GitHub Actions to full commit SHAs.""" + _, content = wf_content( + root, + "pr", + workflow_map, + ) + if not content: return None - if re.search(r"uses:\s*\S+@[0-9a-f]{40}", content, re.I): + + if re.search( + r"uses:\s*\S+@[0-9a-f]{40}", + content, + re.IGNORECASE, + ): return True - return "⚠️ No SHA-pinned actions detected in PR workflow. Use full commit SHAs." + + return "⚠️ No SHA-pinned actions detected in PR workflow. " "Use full commit SHAs." class SEC005(Security): @@ -94,8 +150,17 @@ class SEC005(Security): @staticmethod def check(root) -> bool | None | str: """Return whether the security policy discourages public issue reporting.""" - if not file_exists(root, "SECURITY.md"): + if not file_exists(root, _SECURITY_POLICY): return None - if file_contains(root, "SECURITY.md", re.compile(r"do not|don't|please don", re.I)): + + if file_contains( + root, + _SECURITY_POLICY, + re.compile( + r"do not|don't|please don", + re.IGNORECASE, + ), + ): return True - return "⚠️ SECURITY.md may not clearly discourage public issue reporting." + + return "⚠️ SECURITY.md may not clearly discourage " "public issue reporting." diff --git a/src/ansys/pre_commit_hooks/quality_rules/vale.py b/src/ansys/pre_commit_hooks/quality_rules/vale.py index 9b2faa69..9e799740 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/vale.py +++ b/src/ansys/pre_commit_hooks/quality_rules/vale.py @@ -20,7 +20,18 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Vale checks.""" +"""Vale checks. + +This rule set validates the documentation quality workflow used by the +repository. + +The checks cover: + +* Vale configuration presence +* Google style package configuration +* ANSYS vocabulary inclusion +* ANSYS accept.txt and reject.txt vocabulary files +""" from __future__ import annotations From 61447b85cc85db951bc115823c3f6e1396ccbffe Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 18:50:16 +0530 Subject: [PATCH 39/49] fix: vale module --- .../pre_commit_hooks/quality_rules/vale.py | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/vale.py b/src/ansys/pre_commit_hooks/quality_rules/vale.py index 9e799740..3d455cd0 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/vale.py +++ b/src/ansys/pre_commit_hooks/quality_rules/vale.py @@ -20,24 +20,42 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Vale checks. +"""Vale configuration checks. -This rule set validates the documentation quality workflow used by the -repository. +This rule set validates that Vale is configured according to PyAnsys +documentation standards. The checks cover: -* Vale configuration presence -* Google style package configuration -* ANSYS vocabulary inclusion -* ANSYS accept.txt and reject.txt vocabulary files +* Vale configuration + - doc/.vale.ini exists + - Google style package is configured + - ANSYS vocabulary is configured + +* Vocabulary files + - ANSYS accept.txt exists + - ANSYS reject.txt exists (recommended) """ from __future__ import annotations -from ansys.pre_commit_hooks.quality_rules.common import file_contains, file_exists +from ansys.pre_commit_hooks.quality_rules.common import ( + file_contains, + file_exists, +) + +__all__ = [ + "Vale", + "VL001", + "VL002", + "VL003", + "VL004", + "VL005", +] -__all__ = ["Vale", "VL001", "VL002", "VL003", "VL004", "VL005"] +_VALE_CONFIG = "doc/.vale.ini" +_ACCEPT_VOCAB = "doc/styles/config/vocabularies/ANSYS/accept.txt" +_REJECT_VOCAB = "doc/styles/config/vocabularies/ANSYS/reject.txt" class Vale: @@ -51,8 +69,8 @@ class VL001(Vale): @staticmethod def check(root) -> bool: - """Return whether the Vale config exists.""" - return file_exists(root, "doc/.vale.ini") + """Return whether the Vale configuration exists.""" + return file_exists(root, _VALE_CONFIG) class VL002(Vale): @@ -62,10 +80,11 @@ class VL002(Vale): @staticmethod def check(root) -> bool | None: - """Return whether the Vale config targets the Google style package.""" - if not file_exists(root, "doc/.vale.ini"): + """Return whether the Vale configuration uses the Google style package.""" + if not file_exists(root, _VALE_CONFIG): return None - return file_contains(root, "doc/.vale.ini", "Google") + + return file_contains(root, _VALE_CONFIG, "Google") class VL003(Vale): @@ -75,10 +94,11 @@ class VL003(Vale): @staticmethod def check(root) -> bool | None: - """Return whether the Vale config references the ANSYS vocabulary.""" - if not file_exists(root, "doc/.vale.ini"): + """Return whether the Vale configuration references the ANSYS vocabulary.""" + if not file_exists(root, _VALE_CONFIG): return None - return file_contains(root, "doc/.vale.ini", "ANSYS") + + return file_contains(root, _VALE_CONFIG, "ANSYS") class VL004(Vale): @@ -89,7 +109,7 @@ class VL004(Vale): @staticmethod def check(root) -> bool: """Return whether the accepted vocabulary file exists.""" - return file_exists(root, "doc/styles/config/vocabularies/ANSYS/accept.txt") + return file_exists(root, _ACCEPT_VOCAB) class VL005(Vale): @@ -98,6 +118,9 @@ class VL005(Vale): requires = {"VL001"} @staticmethod - def check(root) -> bool: - """Return whether the rejected vocabulary file exists.""" - return file_exists(root, "doc/styles/config/vocabularies/ANSYS/reject.txt") + def check(root) -> bool | str: + """Return whether the recommended rejected vocabulary file exists.""" + if file_exists(root, _REJECT_VOCAB): + return True + + return "⚠️ ANSYS reject.txt vocabulary not found. " "The file is optional but recommended." From 78cb6f8b175e9f817cfa0929f2cec277a0c17766 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 3 Sep 2026 20:56:21 +0530 Subject: [PATCH 40/49] feat: add additional tests --- .../quality_rules/__init__.py | 4 ++ .../quality_rules/project_metadata.py | 53 +++++++++++++++++++ tests/test_pyansys_quality_report.py | 42 ++++++++++++++- 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index 126379aa..716a8654 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -139,6 +139,8 @@ PM013, PM014, PM015, + PM016, + PM017, ProjectMetadata, ) from ansys.pre_commit_hooks.quality_rules.readme import ( @@ -194,6 +196,8 @@ "PM013", "PM014", "PM015", + "PM016", + "PM017", "CICDFiles", "CI001", "CI002", diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 9fd14677..b30da7fc 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -77,6 +77,8 @@ "PM013", "PM014", "PM015", + "PM016", + "PM017", ] _PYPROJECT = "pyproject.toml" @@ -388,3 +390,54 @@ def check(root) -> bool | None: "⚠️ LICENSE file content is missing a recognized " "license statement (MIT or Apache 2.0)." ) + + +class PM016(ProjectMetadata): + """The .github/CODEOWNERS file contains at least one valid owner entry.""" + + requires = {"PM009"} + + @staticmethod + def check(root) -> bool | None | str: + """Return whether the CODEOWNERS file contains at least one valid ownership entry.""" + if not file_exists(root, ".github/CODEOWNERS"): + return None + + content = file_content(root, ".github/CODEOWNERS") + + if re.search(r"^\s*[^#\n]+\s+@\S+", content, re.MULTILINE): + return True + + return "⚠️ .github/CODEOWNERS exists but has no owner entries." + + +def _validate_python_version_spec(spec: str) -> bool | str: + """Validate that a Python version spec defines supported lower and upper bounds.""" + if not re.search(r">=\d+\.\d+", spec) or not re.search(r"[<,]=?\d+", spec): + return ( + f"⚠️ requires-python '{spec}' does not declare a supported PyAnsys version range. " + "Use >=3.10,<4 or a more recent support window." + ) + + return True + + +class PM017(ProjectMetadata): + """Project declares supported Python versions with explicit bounds.""" + + @staticmethod + def check(root) -> bool | None | str: + """Return whether Python support is declared with both lower and upper bounds.""" + if file_exists(root, _PYPROJECT): + content = file_content(root, _PYPROJECT) + match = re.search(r'requires-python\s*=\s*["\']([^"\']+)["\']', content) + if match: + return _validate_python_version_spec(match.group(1)) + + if file_exists(root, "setup.py"): + content = file_content(root, "setup.py") + match = re.search(r'python_requires\s*=\s*["\']([^"\']+)["\']', content) + if match: + return _validate_python_version_spec(match.group(1)) + + return None diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 559cc63b..5b26e5fc 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -11,7 +11,7 @@ import ansys.pre_commit_hooks.pyansys_quality_report as hook from ansys.pre_commit_hooks.quality_rules.common import workflow_map -from ansys.pre_commit_hooks.quality_rules.project_metadata import PM013 +from ansys.pre_commit_hooks.quality_rules.project_metadata import PM013, PM016, PM017 def test_workflow_map_classifies_ci_cd_roles(tmp_path): @@ -175,12 +175,52 @@ def test_pm015_accepts_apache_license(tmp_path): assert project_metadata.PM015.check(repo_path) is True +def test_pm016_warns_on_empty_codeowners(tmp_path): + """CODEOWNERS should require at least one actual owner entry.""" + repo_path = tmp_path / "codeowners-project" + repo_path.mkdir() + (repo_path / ".github").mkdir() + (repo_path / ".github" / "CODEOWNERS").write_text("# comment only\n", encoding="utf-8") + + assert PM016.check(repo_path) == "⚠️ .github/CODEOWNERS exists but has no owner entries." + + +def test_pm016_accepts_valid_codeowners(tmp_path): + """CODEOWNERS with an actual @owner entry should pass.""" + repo_path = tmp_path / "codeowners-project" + repo_path.mkdir() + (repo_path / ".github").mkdir() + (repo_path / ".github" / "CODEOWNERS").write_text("* @ansys/maintainers\n", encoding="utf-8") + + assert PM016.check(repo_path) is True + + +def test_pm017_requires_python_bounds_in_pyproject(tmp_path): + """Project metadata should require explicit lower and upper bounds for Python support.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nrequires-python = ">=3.10,<4"\n', encoding="utf-8") + + assert PM017.check(tmp_path) is True + + +def test_pm017_rejects_missing_upper_bound(tmp_path): + """Lower-only Python support declarations should fail the metadata standard.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nrequires-python = ">=3.10"\n', encoding="utf-8") + + result = PM017.check(tmp_path) + assert isinstance(result, str) + assert "supported PyAnsys version range" in result + + def test_quality_rules_are_grouped_package(): """Quality rules should be exposed from a package with one module per check family.""" import ansys.pre_commit_hooks.quality_rules as quality_rules import ansys.pre_commit_hooks.quality_rules.project_metadata as project_metadata assert hasattr(quality_rules, "PM001") + assert hasattr(quality_rules, "PM016") + assert hasattr(quality_rules, "PM017") assert hasattr(project_metadata, "PM001") assert callable(quality_rules.repo_review_checks) From fe736c96fdbf2f249ca46fa2da30c2a815d26618 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Fri, 4 Sep 2026 11:02:05 +0530 Subject: [PATCH 41/49] feat: add additional metadata --- .../quality_rules/__init__.py | 136 +++++++++--------- .../quality_rules/project_metadata.py | 56 +++++++- tests/test_pyansys_quality_report.py | 42 ++++-- 3 files changed, 160 insertions(+), 74 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index 716a8654..b9aca568 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -141,6 +141,9 @@ PM015, PM016, PM017, + PM021, + PM022, + PM024, ProjectMetadata, ) from ansys.pre_commit_hooks.quality_rules.readme import ( @@ -166,43 +169,29 @@ from ansys.pre_commit_hooks.quality_rules.vale import VL001, VL002, VL003, VL004, VL005, Vale __all__ = [ - "file_exists", - "file_content", - "file_contains", - "CANONICAL_WF", - "all_workflows_content", - "wf_content", - "wf_label", - "workflow_map", - "readme_path", - "is_mcp", - "normalize_check_result", - "repo_review_families", - "repo_review_checks", "_first_doc_line", - "ProjectMetadata", - "PM001", - "PM002", - "PM003", - "PM004", - "PM005", - "PM006", - "PM007", - "PM008", - "PM009", - "PM010", - "PM011", - "PM012", - "PM013", - "PM014", - "PM015", - "PM016", - "PM017", - "CICDFiles", + "repo_review_checks", + "repo_review_families", + "normalize_check_result", + "is_mcp", + "readme_path", + "workflow_map", + "wf_label", + "wf_content", + "all_workflows_content", + "CANONICAL_WF", + "file_contains", + "file_content", + "file_exists", + "BS001", + "BS002", + "BS003", + "BS004", + "BuildSystem", "CI001", "CI002", "CI003", - "CICD", + "CICDFiles", "CI004", "CI005", "CI006", @@ -216,7 +205,7 @@ "CI014", "CI015", "CI016", - "Dependabot", + "CICD", "DB001", "DB002", "DB003", @@ -225,7 +214,7 @@ "DB006", "DB007", "DB008", - "Documentation", + "Dependabot", "DOC001", "DOC002", "DOC003", @@ -233,40 +222,13 @@ "DOC005", "DOC006", "DOC007", - "README", - "RM000", - "RM001", - "RM002", - "RM003", - "RM004", - "RM005", - "RM006", - "RM007", - "RM008", - "BuildSystem", - "BS001", - "BS002", - "BS003", - "BS004", - "Security", - "SEC001", - "SEC002", - "SEC003", - "SEC004", - "SEC005", - "Labeler", + "Documentation", "LB001", "LB002", "LB003", "LB004", "LB005", - "Vale", - "VL001", - "VL002", - "VL003", - "VL004", - "VL005", - "MCP", + "Labeler", "MCP001", "MCP002", "MCP003", @@ -274,7 +236,7 @@ "MCP005", "MCP006", "MCP007", - "PreCommit", + "MCP", "PC001", "PC002", "PC003", @@ -285,6 +247,50 @@ "PC008", "PC009", "PC010", + "PreCommit", + "PM001", + "PM002", + "PM003", + "PM004", + "PM005", + "PM006", + "PM007", + "PM008", + "PM009", + "PM010", + "PM011", + "PM012", + "PM013", + "PM014", + "PM015", + "PM016", + "PM017", + "PM021", + "PM022", + "PM024", + "ProjectMetadata", + "RM000", + "RM001", + "RM002", + "RM003", + "RM004", + "RM005", + "RM006", + "RM007", + "RM008", + "README", + "SEC001", + "SEC002", + "SEC003", + "SEC004", + "SEC005", + "Security", + "VL001", + "VL002", + "VL003", + "VL004", + "VL005", + "Vale", ] diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index b30da7fc..ea6aa837 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -61,7 +61,6 @@ ) __all__ = [ - "ProjectMetadata", "PM001", "PM002", "PM003", @@ -79,6 +78,10 @@ "PM015", "PM016", "PM017", + "PM021", + "PM022", + "PM024", + "ProjectMetadata", ] _PYPROJECT = "pyproject.toml" @@ -88,6 +91,22 @@ _DEFAULT_EMAIL = "pyansys-core@synopsys.com" +def _has_any_file(root, *paths: str) -> bool: + """Return whether any of the provided file paths exist under the repo root.""" + return any(file_exists(root, path) for path in paths) + + +def _has_any_directory(root, *paths: str) -> bool: + """Return whether any of the provided directory paths exist under the repo root.""" + for path in paths: + try: + if root.joinpath(path).is_dir(): + return True + except (AttributeError, TypeError, ValueError): + continue + return False + + class ProjectMetadata: """Project metadata rule family.""" @@ -441,3 +460,38 @@ def check(root) -> bool | None | str: return _validate_python_version_spec(match.group(1)) return None + + +class PM021(ProjectMetadata): + """The project includes a docs directory.""" + + @staticmethod + def check(root) -> bool: + """Return whether a documentation directory is present.""" + return _has_any_directory(root, "docs", "doc") + + +class PM022(ProjectMetadata): + """The project includes a tests directory.""" + + @staticmethod + def check(root) -> bool: + """Return whether a test directory is present.""" + return _has_any_directory(root, "tests", "test") + + +class PM024(ProjectMetadata): + """The project supports a task runner such as nox, tox, or pixi.""" + + @staticmethod + def check(root) -> bool: + """Return whether an easy task runner is configured for the project.""" + return _has_any_file( + root, + "tox.ini", + "noxfile.py", + "noxfile.toml", + "pixi.toml", + "justfile", + "Makefile", + ) diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 5b26e5fc..f2c0d29c 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -9,9 +9,18 @@ import git +from ansys.pre_commit_hooks import quality_rules import ansys.pre_commit_hooks.pyansys_quality_report as hook +from ansys.pre_commit_hooks.quality_rules import project_metadata from ansys.pre_commit_hooks.quality_rules.common import workflow_map -from ansys.pre_commit_hooks.quality_rules.project_metadata import PM013, PM016, PM017 +from ansys.pre_commit_hooks.quality_rules.project_metadata import ( + PM013, + PM016, + PM017, + PM021, + PM022, + PM024, +) def test_workflow_map_classifies_ci_cd_roles(tmp_path): @@ -127,8 +136,6 @@ def test_main_colors_status_labels(tmp_path, capsys): def test_hook_covers_all_repo_review_checks(): """The package-level rule registry should expose the complete local check set.""" - import ansys.pre_commit_hooks.quality_rules as quality_rules - checks_dir = Path(quality_rules.__file__).resolve().parent def class_names(path: Path) -> set[str]: @@ -170,8 +177,6 @@ def test_pm015_accepts_apache_license(tmp_path): encoding="utf-8", ) - import ansys.pre_commit_hooks.quality_rules.project_metadata as project_metadata - assert project_metadata.PM015.check(repo_path) is True @@ -195,6 +200,30 @@ def test_pm016_accepts_valid_codeowners(tmp_path): assert PM016.check(repo_path) is True +def test_pm021_requires_docs_directory(tmp_path): + """Projects should have a docs or doc directory for documentation.""" + assert PM021.check(tmp_path) is False + + (tmp_path / "doc").mkdir() + assert PM021.check(tmp_path) is True + + +def test_pm022_requires_tests_directory(tmp_path): + """Projects should have a tests directory.""" + assert PM022.check(tmp_path) is False + + (tmp_path / "tests").mkdir() + assert PM022.check(tmp_path) is True + + +def test_pm024_requires_task_runner_configuration(tmp_path): + """Projects should expose a task runner configuration file for common workflows.""" + assert PM024.check(tmp_path) is False + + (tmp_path / "tox.ini").write_text("[tox]\nenvlist = py\n", encoding="utf-8") + assert PM024.check(tmp_path) is True + + def test_pm017_requires_python_bounds_in_pyproject(tmp_path): """Project metadata should require explicit lower and upper bounds for Python support.""" pyproject = tmp_path / "pyproject.toml" @@ -215,9 +244,6 @@ def test_pm017_rejects_missing_upper_bound(tmp_path): def test_quality_rules_are_grouped_package(): """Quality rules should be exposed from a package with one module per check family.""" - import ansys.pre_commit_hooks.quality_rules as quality_rules - import ansys.pre_commit_hooks.quality_rules.project_metadata as project_metadata - assert hasattr(quality_rules, "PM001") assert hasattr(quality_rules, "PM016") assert hasattr(quality_rules, "PM017") From f9114499126a2c573a9dbf6347872131bda022e3 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Fri, 4 Sep 2026 11:35:30 +0530 Subject: [PATCH 42/49] feat: add additional metadata tests --- .../pyansys_quality_report.py | 40 +++++++++++++++++++ tests/test_pyansys_quality_report.py | 17 ++++++++ 2 files changed, 57 insertions(+) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index cf1da171..abfdf7a9 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -61,6 +61,12 @@ "pyproject.toml", "setup.py", "setup.cfg", + "tox.ini", + "noxfile.py", + "noxfile.toml", + "pixi.toml", + "justfile", + "Makefile", "doc/.vale.ini", "doc/source/index.rst", "doc/source/conf.py", @@ -68,6 +74,25 @@ "doc/styles/config/vocabularies/ANSYS/reject.txt", ] +_TASK_RUNNER_FILES = ( + "tox.ini", + "noxfile.py", + "noxfile.toml", + "pixi.toml", + "justfile", + "Makefile", +) + +_REPO_DIRECTORIES = ( + "tests", + "test", + "docs", + "doc", + "src", + "examples", + ".github", +) + HOOK_PATH = Path(__file__).parent.resolve() """Location of the pre-commit hook on your system.""" @@ -645,6 +670,21 @@ def _load_files(repo_root: Path) -> dict[str, str | None]: else: files[relative_path] = None + for directory in _REPO_DIRECTORIES: + candidate = repo_root / directory + if candidate.is_dir(): + files[f"{directory.rstrip('/')}/"] = None + for file in candidate.rglob("*"): + if file.is_file(): + files[file.relative_to(repo_root).as_posix()] = file.read_text( + encoding="utf-8", errors="replace" + ) + + for filename in _TASK_RUNNER_FILES: + candidate = repo_root / filename + if candidate.is_file(): + files[filename] = candidate.read_text(encoding="utf-8", errors="replace") + workflows = repo_root / ".github" / "workflows" if workflows.is_dir(): for workflow in workflows.glob("*.y*ml"): diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index f2c0d29c..5786eb57 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -200,6 +200,23 @@ def test_pm016_accepts_valid_codeowners(tmp_path): assert PM016.check(repo_path) is True +def test_load_files_keeps_repo_directories_and_task_runner_files_for_virtual_review(tmp_path): + """Virtual repo reviews should recognize real directories and task-runner files.""" + repo_path = tmp_path / "virtual-review-repo" + repo_path.mkdir() + (repo_path / "tests").mkdir() + (repo_path / "tests" / "test_example.py").write_text( + "def test_ok():\n assert True\n", encoding="utf-8" + ) + (repo_path / "tox.ini").write_text("[tox]\nenvlist = py\n", encoding="utf-8") + + files = hook._load_files(repo_path) + root = hook.MemoryTraversable(files) + + assert PM022.check(root) is True + assert PM024.check(root) is True + + def test_pm021_requires_docs_directory(tmp_path): """Projects should have a docs or doc directory for documentation.""" assert PM021.check(tmp_path) is False From 606ba9350942068e1bce534a3946c2bac4fdbc31 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Fri, 4 Sep 2026 12:30:36 +0530 Subject: [PATCH 43/49] feat: add configuration --- .../pyansys_quality_report.py | 76 ++++++++++++++++++- tests/test_pyansys_quality_report.py | 61 +++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index abfdf7a9..afb42bf2 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -628,15 +628,79 @@ def _tally_results(results: list[dict[str, Any]]) -> dict[str, int]: return tally -def _run_checks(files: dict[str, str | None], is_mcp_flag: bool) -> dict[str, Any]: +def _normalize_ignore_codes( + values: list[str] | tuple[str, ...] | set[str] | str | None, +) -> set[str]: + """Normalize ignore values from CLI or TOML into a canonical uppercase code set.""" + items: list[str] + if values is None: + items = [] + elif isinstance(values, str): + items = [values] + else: + items = list(values) + + normalized: set[str] = set() + for value in items: + for part in str(value).split(","): + code = part.strip().strip("[](){} ") + if code: + normalized.add(code.upper()) + return normalized + + +def _read_pyproject_ignore(repo_root: Path) -> set[str]: + """Load ignore codes from pyproject.toml configuration if present.""" + config_file = repo_root / "pyproject.toml" + if not config_file.exists(): + return set() + + try: + import tomllib + except ModuleNotFoundError: # pragma: no cover + import tomli as tomllib + + try: + pyproject = tomllib.loads(config_file.read_text(encoding="utf-8")) + except (OSError, TypeError, ValueError): + return set() + + tool_section = pyproject.get("tool", {}) + options: list[str] = [] + + for section_name in ("ansys-pre-commit-hooks", "ansys_pre_commit_hooks"): + if isinstance(tool_section.get(section_name), dict): + options.extend(tool_section[section_name].get("ignore", [])) + + ansys_section = tool_section.get("ansys") + if isinstance(ansys_section, dict): + for section_name in ( + "pre_commit_hooks", + "pre-commit-hooks", + "quality_report", + "quality-report", + ): + if isinstance(ansys_section.get(section_name), dict): + options.extend(ansys_section[section_name].get("ignore", [])) + + return _normalize_ignore_codes(options) + + +def _run_checks( + files: dict[str, str | None], + is_mcp_flag: bool, + ignored_codes: set[str] | None = None, +) -> dict[str, Any]: """Run the package-based repo review checks against an in-memory file set.""" root = MemoryTraversable(files) fixture_values = _build_fixture_values(root, is_mcp_flag) checks = repo_review_checks() families = repo_review_families() + ignored = _normalize_ignore_codes(ignored_codes or set()) results = [ _execute_check(check_obj, code=code, fixture_values=fixture_values, families=families) for code, check_obj in checks.items() + if code not in ignored ] tally = _tally_results(results) @@ -748,6 +812,12 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--all", action="store_true", help="Show all checks, including passing ones." ) + parser.add_argument( + "--ignore", + action="append", + default=[], + help="Comma-separated list of quality-check codes to ignore, such as PM022,PM024.", + ) parser.add_argument( "--fix-missing", action="store_true", @@ -811,7 +881,9 @@ def main(argv: list[str] | None = None) -> int: print(f"\nLegacy tech-review bootstrap reported exit code {legacy_exit}.") files = _load_files(repo_root) - review = _run_checks(files, is_mcp_flag=is_mcp(MemoryTraversable(files))) + ignored = _normalize_ignore_codes(args.ignore) + ignored |= _read_pyproject_ignore(repo_root) + review = _run_checks(files, is_mcp_flag=is_mcp(MemoryTraversable(files)), ignored_codes=ignored) if args.json: print(json.dumps(review, indent=2)) diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 5786eb57..63b15207 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -217,6 +217,67 @@ def test_load_files_keeps_repo_directories_and_task_runner_files_for_virtual_rev assert PM024.check(root) is True +def test_pyansys_quality_report_ignore_option_skips_selected_checks(tmp_path, capsys): + """--ignore should remove selected checks from the quality report output.""" + repo_path = tmp_path / "quality-demo" + repo_path.mkdir() + os.chdir(repo_path) + git.Repo.init(repo_path) + + (repo_path / "tests").mkdir() + (repo_path / ".pre-commit-config.yaml").write_text("repos: []\n", encoding="utf-8") + (repo_path / "README.rst").write_text("Demo\n=====\n", encoding="utf-8") + (repo_path / "pyproject.toml").write_text( + """ +[project] +name = "ansys-demo-library" +version = "0.1.0" +authors = [{name = "Example", email = "example@example.com"}] +maintainers = [{name = "Example", email = "example@example.com"}] +""".strip(), + encoding="utf-8", + ) + + exit_code = hook.main(["--repo-root", str(repo_path), "--ignore", "PM022,PM024"]) + output = capsys.readouterr().out + + assert exit_code in (0, 1) + assert "PM022" not in output + assert "PM024" not in output + + +def test_pyansys_quality_report_reads_ignore_from_pyproject_toml(tmp_path, capsys): + """Ignoring checks in pyproject.toml should suppress those checks in the report.""" + repo_path = tmp_path / "quality-demo" + repo_path.mkdir() + os.chdir(repo_path) + git.Repo.init(repo_path) + + (repo_path / "tests").mkdir() + (repo_path / ".pre-commit-config.yaml").write_text("repos: []\n", encoding="utf-8") + (repo_path / "README.rst").write_text("Demo\n=====\n", encoding="utf-8") + (repo_path / "pyproject.toml").write_text( + """ +[project] +name = "ansys-demo-library" +version = "0.1.0" +authors = [{name = "Example", email = "example@example.com"}] +maintainers = [{name = "Example", email = "example@example.com"}] + +[tool.ansys-pre-commit-hooks] +ignore = ["PM022", "PM024"] +""".strip(), + encoding="utf-8", + ) + + exit_code = hook.main(["--repo-root", str(repo_path)]) + output = capsys.readouterr().out + + assert exit_code in (0, 1) + assert "PM022" not in output + assert "PM024" not in output + + def test_pm021_requires_docs_directory(tmp_path): """Projects should have a docs or doc directory for documentation.""" assert PM021.check(tmp_path) is False From 871222872548db24b35b6e201bd4267752cf22b6 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Fri, 4 Sep 2026 12:59:08 +0530 Subject: [PATCH 44/49] fix: suggestions by @jorgepiloto --- .pre-commit-config.yaml | 6 +++ .../pyansys_quality_report.py | 5 +-- .../quality_rules/project_metadata.py | 34 ++++++++------- tests/test_pyansys_quality_report.py | 41 +++++++++++++++++++ 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9919310f..29e1e64d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,3 +63,9 @@ repos: args: - --product=pre_commit_hooks - --non_compliant_name + +- repo: https://github.com/ansys/pre-commit-hooks + rev: f911449 + hooks: + - id: pyansys-quality-report + args: [--repo-root, ., --all, --fix-missing] \ No newline at end of file diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index afb42bf2..a6fb0a03 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -549,9 +549,6 @@ def _bootstrap_legacy_files( except (git.GitCommandError, TypeError, ValueError): start_year = DEFAULT_START_YEAR - is_compliant = check_dirs_exist( - root, is_compliant, [directory.value for directory in Directories] - ) is_compliant = check_dirs_exist( root, is_compliant, [directory.value for directory in Directories] ) @@ -852,7 +849,7 @@ def main(argv: list[str] | None = None) -> int: help="The repository URL. For example, https://github.com/ansys/pymechanical", ) parser.add_argument("--non_compliant_name", action="store_true") - args, _ = parser.parse_known_args(argv) + args = parser.parse_args(argv) repo_root = Path(args.repo_root).resolve() if not repo_root.exists(): diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index ea6aa837..826fe080 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -214,15 +214,13 @@ def check(root, readme_path: str | None) -> bool | None | str: content = file_content(root, _PYPROJECT) if re.search(r"poetry\.core|poetry-core", content): - match = re.search( - r"\[^\"']+[\"']", - content, - re.MULTILINE, - ) - - if match: + readme = readme_path or "README.rst" + if readme in content: return True + if "README" in content: + return "⚠️ readme key found but exact README filename not confirmed." + return False readme = readme_path or "README.rst" @@ -343,31 +341,37 @@ def check(root) -> bool | None | str: content = file_content(root, _PYPROJECT) + authors_match = re.search(r"authors\s*=\s*\[([\s\S]*?)\]", content) + maintainers_match = re.search(r"maintainers\s*=\s*\[([\s\S]*?)\]", content) + + author_block = authors_match.group(1) if authors_match else "" + maintainer_block = maintainers_match.group(1) if maintainers_match else "" + name_ok = bool( re.search( - rf'authors\s*=\s*\[[\s\S]*?name\s*=\s*["\']{re.escape(_DEFAULT_AUTHOR)}["\']', - content, + rf'name\s*=\s*["\']{re.escape(_DEFAULT_AUTHOR)}["\']', + author_block, ) ) email_ok = bool( re.search( - rf'authors\s*=\s*\[[\s\S]*?email\s*=\s*["\']{re.escape(_DEFAULT_EMAIL)}["\']', - content, + rf'email\s*=\s*["\']{re.escape(_DEFAULT_EMAIL)}["\']', + author_block, ) ) maintainer_name_ok = bool( re.search( - rf'maintainers\s*=\s*\[[\s\S]*?name\s*=\s*["\']{re.escape(_DEFAULT_AUTHOR)}["\']', - content, + rf'name\s*=\s*["\']{re.escape(_DEFAULT_AUTHOR)}["\']', + maintainer_block, ) ) maintainer_email_ok = bool( re.search( - rf'maintainers\s*=\s*\[[\s\S]*?email\s*=\s*["\']{re.escape(_DEFAULT_EMAIL)}["\']', - content, + rf'email\s*=\s*["\']{re.escape(_DEFAULT_EMAIL)}["\']', + maintainer_block, ) ) diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 63b15207..3c9ee796 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -45,6 +45,26 @@ def test_pm013_accepts_supported_version_formats(tmp_path): assert PM013.check(tmp_path) is True +def test_pm010_accepts_poetry_readme_reference(tmp_path): + """Poetry projects should validate the README path the same way as other build systems.""" + repo_path = tmp_path / "poetry-project" + repo_path.mkdir() + + (repo_path / "README.rst").write_text("Poetry\n======\n", encoding="utf-8") + (repo_path / "pyproject.toml").write_text( + """ +[tool.poetry] +name = "ansys-demo-library" +version = "0.1.0" +description = "Demo" +readme = "README.rst" +""".strip(), + encoding="utf-8", + ) + + assert project_metadata.PM010.check(repo_path, "README.rst") is True + + def test_main_reports_quality_summary(tmp_path, capsys): """The quality report hook should run and print a summary for the repo.""" repo_path = tmp_path / "quality-demo" @@ -180,6 +200,27 @@ def test_pm015_accepts_apache_license(tmp_path): assert project_metadata.PM015.check(repo_path) is True +def test_pm014_rejects_wrong_author_block_when_maintainer_block_is_valid(tmp_path): + """Author validation must stay limited to the authors array and not bleed into maintainers.""" + repo_path = tmp_path / "author-bounds-project" + repo_path.mkdir() + + (repo_path / "pyproject.toml").write_text( + """ +[project] +authors = [{ name = "Wrong Author", email = "wrong@example.com" }] +maintainers = [{ name = "Synopsys, Inc. and ANSYS, Inc.", email = "pyansys-core@synopsys.com" }] +""".strip(), + encoding="utf-8", + ) + + assert project_metadata.PM014.check(repo_path) == ( + "⚠️ author/maintainer metadata does not match " + "Synopsys, Inc. and ANSYS, Inc. / " + "pyansys-core@synopsys.com." + ) + + def test_pm016_warns_on_empty_codeowners(tmp_path): """CODEOWNERS should require at least one actual owner entry.""" repo_path = tmp_path / "codeowners-project" From e48c33bc26fe73d5547aff5c66ec778b18357346 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Fri, 4 Sep 2026 13:55:01 +0530 Subject: [PATCH 45/49] fix: suggestions by @jorgepiloto --- .../quality_rules/build_system.py | 7 +-- .../pre_commit_hooks/quality_rules/common.py | 13 ++++++ .../quality_rules/dependabot.py | 45 +++++++++---------- .../quality_rules/documentation.py | 27 +++-------- .../pre_commit_hooks/quality_rules/labeler.py | 17 ++----- .../quality_rules/pre_commit.py | 32 +++---------- .../quality_rules/security.py | 34 ++++++++------ .../pre_commit_hooks/quality_rules/vale.py | 12 ++--- tests/test_pyansys_quality_report.py | 28 +++++++++++- 9 files changed, 103 insertions(+), 112 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/build_system.py b/src/ansys/pre_commit_hooks/quality_rules/build_system.py index ce1a45fc..e487b45f 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/build_system.py +++ b/src/ansys/pre_commit_hooks/quality_rules/build_system.py @@ -36,7 +36,7 @@ import re -from .common import file_contains, file_content, file_exists +from .common import checked_contains, file_content, file_exists __all__ = ["BuildSystem", "BS001", "BS002", "BS003", "BS004"] @@ -82,10 +82,7 @@ class BS001(BuildSystem): @staticmethod def check(root) -> bool | None: """Return whether a build-system table is present in pyproject.toml.""" - if not file_exists(root, "pyproject.toml"): - return None - - return file_contains(root, "pyproject.toml", "[build-system]") + return checked_contains(root, "pyproject.toml", "[build-system]") class BS002(BuildSystem): diff --git a/src/ansys/pre_commit_hooks/quality_rules/common.py b/src/ansys/pre_commit_hooks/quality_rules/common.py index 06714f2d..ef48ac02 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/common.py +++ b/src/ansys/pre_commit_hooks/quality_rules/common.py @@ -48,6 +48,7 @@ "file_exists", "file_content", "file_contains", + "checked_contains", "CANONICAL_WF", "all_workflows_content", "wf_content", @@ -306,3 +307,15 @@ def normalize_check_result( return "fail", detail return "fail", str(raw) + + +def checked_contains( + root: Traversable, + path: str, + pattern: str | re.Pattern, +) -> bool | None: + """Return None if the file is missing; otherwise, return the file_contains result.""" + if not file_exists(root, path): + return None + + return file_contains(root, path, pattern) diff --git a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py index ae39e9a7..ca592a48 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/dependabot.py +++ b/src/ansys/pre_commit_hooks/quality_rules/dependabot.py @@ -38,7 +38,7 @@ import re -from .common import file_contains, file_content, file_exists +from .common import checked_contains, file_contains, file_content, file_exists __all__ = [ "Dependabot", @@ -79,10 +79,7 @@ class DB002(Dependabot): @staticmethod def check(root) -> bool | None: """Return whether the Dependabot config uses the expected schema version.""" - if not file_exists(root, _PATH_DEPENDABOT): - return None - - return file_contains( + return checked_contains( root, _PATH_DEPENDABOT, re.compile(r"^version:\s*2\s*$", re.MULTILINE), @@ -129,10 +126,7 @@ class DB004(Dependabot): @staticmethod def check(root) -> bool | None: """Return whether the GitHub Actions ecosystem is configured.""" - if not file_exists(root, _PATH_DEPENDABOT): - return None - - return file_contains( + return checked_contains( root, _PATH_DEPENDABOT, re.compile(r'package-ecosystem:\s*["\']?github-actions["\']?'), @@ -173,14 +167,15 @@ class DB006(Dependabot): @staticmethod def check(root) -> bool | None | str: """Return whether the Dependabot cooldown policy is set to seven days.""" - if not file_exists(root, _PATH_DEPENDABOT): - return None - - if file_contains( + result = checked_contains( root, _PATH_DEPENDABOT, re.compile(r"default-days:\s*7"), - ): + ) + + if result is None: + return None + if result: return True return "⚠️ Cooldown default-days: 7 not found in dependabot.yml." @@ -212,11 +207,15 @@ def check(root) -> bool | None | str: if has_uv and not has_pip: return None - if file_contains( + result = checked_contains( root, _PATH_DEPENDABOT, re.compile(r'versioning-strategy:\s*["\']?lockfile-only["\']?'), - ): + ) + + if result is None: + return None + if result: return True return "⚠️ versioning-strategy: lockfile-only " "not found for pip ecosystem." @@ -230,14 +229,12 @@ class DB008(Dependabot): @staticmethod def check(root) -> bool | None | str: """Return whether the pip group wildcard pattern is defined.""" - if not file_exists(root, _PATH_DEPENDABOT): - return None + pattern = re.compile(r'patterns:\s*\n\s*-\s*["\']?\*["\']?') + result = checked_contains(root, _PATH_DEPENDABOT, pattern) - if file_contains( - root, - _PATH_DEPENDABOT, - re.compile(r'patterns:\s*\n\s*-\s*["\']?\*["\']?'), - ): + if result is None: + return None + if result: return True - return "⚠️ pip groups wildcard pattern " '"- \\"*\\"" not found in dependabot.yml.' + return '⚠️ pip groups wildcard pattern "- "*"" not found in dependabot.yml.' diff --git a/src/ansys/pre_commit_hooks/quality_rules/documentation.py b/src/ansys/pre_commit_hooks/quality_rules/documentation.py index 87ad35b6..73af5fda 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/documentation.py +++ b/src/ansys/pre_commit_hooks/quality_rules/documentation.py @@ -37,7 +37,7 @@ import re -from .common import file_contains, file_exists +from ansys.pre_commit_hooks.quality_rules.common import checked_contains, file_exists __all__ = [ "Documentation", @@ -85,10 +85,7 @@ class DOC003(Documentation): @staticmethod def check(root) -> bool | None: """Return whether numpydoc is enabled in the Sphinx configuration.""" - if not file_exists(root, "doc/source/conf.py"): - return None - - return file_contains(root, "doc/source/conf.py", "numpydoc") + return checked_contains(root, "doc/source/conf.py", "numpydoc") class DOC004(Documentation): @@ -99,10 +96,7 @@ class DOC004(Documentation): @staticmethod def check(root) -> bool | None: """Return whether sphinx_design is enabled in the Sphinx configuration.""" - if not file_exists(root, "doc/source/conf.py"): - return None - - return file_contains(root, "doc/source/conf.py", "sphinx_design") + return checked_contains(root, "doc/source/conf.py", "sphinx_design") class DOC005(Documentation): @@ -113,10 +107,7 @@ class DOC005(Documentation): @staticmethod def check(root) -> bool | None: """Return whether intersphinx is enabled in the Sphinx configuration.""" - if not file_exists(root, "doc/source/conf.py"): - return None - - return file_contains(root, "doc/source/conf.py", "intersphinx") + return checked_contains(root, "doc/source/conf.py", "intersphinx") class DOC006(Documentation): @@ -127,10 +118,7 @@ class DOC006(Documentation): @staticmethod def check(root) -> bool | None: """Return whether the documentation index includes a getting-started section.""" - if not file_exists(root, "doc/source/index.rst"): - return None - - return file_contains( + return checked_contains( root, "doc/source/index.rst", re.compile(r"getting.started", re.IGNORECASE), @@ -145,10 +133,7 @@ class DOC007(Documentation): @staticmethod def check(root) -> bool | None: """Return whether the documentation index includes an API reference section.""" - if not file_exists(root, "doc/source/index.rst"): - return None - - return file_contains( + return checked_contains( root, "doc/source/index.rst", re.compile( diff --git a/src/ansys/pre_commit_hooks/quality_rules/labeler.py b/src/ansys/pre_commit_hooks/quality_rules/labeler.py index 4ca58581..35d79940 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/labeler.py +++ b/src/ansys/pre_commit_hooks/quality_rules/labeler.py @@ -35,7 +35,7 @@ from __future__ import annotations -from .common import file_contains, file_exists +from .common import checked_contains, file_exists __all__ = [ "Labeler", @@ -79,10 +79,7 @@ class LB003(Labeler): @staticmethod def check(root) -> bool | None: """Return whether the bug label is present.""" - if not file_exists(root, ".github/labels.yml"): - return None - - return file_contains(root, ".github/labels.yml", "bug") + return checked_contains(root, ".github/labels.yml", "bug") class LB004(Labeler): @@ -93,10 +90,7 @@ class LB004(Labeler): @staticmethod def check(root) -> bool | None: """Return whether the enhancement label is present.""" - if not file_exists(root, ".github/labels.yml"): - return None - - return file_contains(root, ".github/labels.yml", "enhancement") + return checked_contains(root, ".github/labels.yml", "enhancement") class LB005(Labeler): @@ -107,7 +101,4 @@ class LB005(Labeler): @staticmethod def check(root) -> bool | None: """Return whether the documentation label is present.""" - if not file_exists(root, ".github/labels.yml"): - return None - - return file_contains(root, ".github/labels.yml", "documentation") + return checked_contains(root, ".github/labels.yml", "documentation") diff --git a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py index a37f1d14..d3bbc292 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py +++ b/src/ansys/pre_commit_hooks/quality_rules/pre_commit.py @@ -39,7 +39,7 @@ import re -from .common import file_contains, file_exists +from .common import checked_contains, file_contains, file_exists __all__ = [ "PreCommit", @@ -81,10 +81,7 @@ class PC002(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether ruff-pre-commit is configured.""" - if not file_exists(root, _PRE_COMMIT_CONFIG): - return None - - return file_contains(root, _PRE_COMMIT_CONFIG, "ruff-pre-commit") + return checked_contains(root, _PRE_COMMIT_CONFIG, "ruff-pre-commit") class PC003(PreCommit): @@ -127,10 +124,7 @@ class PC004(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether blacken-docs is configured.""" - if not file_exists(root, _PRE_COMMIT_CONFIG): - return None - - return file_contains(root, _PRE_COMMIT_CONFIG, "blacken-docs") + return checked_contains(root, _PRE_COMMIT_CONFIG, "blacken-docs") class PC005(PreCommit): @@ -141,10 +135,7 @@ class PC005(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether codespell is configured.""" - if not file_exists(root, _PRE_COMMIT_CONFIG): - return None - - return file_contains(root, _PRE_COMMIT_CONFIG, "codespell") + return checked_contains(root, _PRE_COMMIT_CONFIG, "codespell") class PC006(PreCommit): @@ -155,10 +146,7 @@ class PC006(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether the shared Ansys pre-commit hooks are configured.""" - if not file_exists(root, _PRE_COMMIT_CONFIG): - return None - - return file_contains( + return checked_contains( root, _PRE_COMMIT_CONFIG, "ansys/pre-commit-hooks", @@ -173,10 +161,7 @@ class PC007(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether yamlfmt is configured.""" - if not file_exists(root, _PRE_COMMIT_CONFIG): - return None - - return file_contains(root, _PRE_COMMIT_CONFIG, "yamlfmt") + return checked_contains(root, _PRE_COMMIT_CONFIG, "yamlfmt") class PC008(PreCommit): @@ -187,10 +172,7 @@ class PC008(PreCommit): @staticmethod def check(root) -> bool | None: """Return whether pyright is configured.""" - if not file_exists(root, _PRE_COMMIT_CONFIG): - return None - - return file_contains(root, _PRE_COMMIT_CONFIG, "pyright") + return checked_contains(root, _PRE_COMMIT_CONFIG, "pyright") class PC009(PreCommit): diff --git a/src/ansys/pre_commit_hooks/quality_rules/security.py b/src/ansys/pre_commit_hooks/quality_rules/security.py index f5549da7..e0517243 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/security.py +++ b/src/ansys/pre_commit_hooks/quality_rules/security.py @@ -46,6 +46,7 @@ import re from ansys.pre_commit_hooks.quality_rules.common import ( + checked_contains, file_contains, file_exists, wf_content, @@ -91,10 +92,7 @@ class SEC002(Security): @staticmethod def check(root) -> bool | None: """Return whether the Zizmor configuration enables the secrets-outside-env rule.""" - if not file_exists(root, _ZIZMOR_CONFIG): - return None - - return file_contains( + return checked_contains( root, _ZIZMOR_CONFIG, "secrets-outside-env", @@ -107,10 +105,7 @@ class SEC003(Security): @staticmethod def check(root) -> bool | None: """Return whether the pre-commit configuration includes gitleaks.""" - if not file_exists(root, _PRE_COMMIT_CONFIG): - return None - - return file_contains( + return checked_contains( root, _PRE_COMMIT_CONFIG, "gitleaks", @@ -132,14 +127,25 @@ def check(root, workflow_map: dict) -> bool | None | str: if not content: return None - if re.search( - r"uses:\s*\S+@[0-9a-f]{40}", - content, - re.IGNORECASE, - ): + uses_lines = re.findall(r"^\s*-?\s*uses:\s*([^\n#]+)", content, re.MULTILINE) + if not uses_lines: + return None + + pinned = 0 + for uses in uses_lines: + value = uses.strip() + if re.fullmatch( + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@(?:[0-9a-fA-F]{40}|[A-Fa-f0-9]{40})", value + ): + pinned += 1 + + if pinned == len(uses_lines): return True - return "⚠️ No SHA-pinned actions detected in PR workflow. " "Use full commit SHAs." + return ( + "⚠️ Some GitHub Actions in the PR workflow are not pinned to full commit SHAs. " + "Use full commit SHAs for all actions." + ) class SEC005(Security): diff --git a/src/ansys/pre_commit_hooks/quality_rules/vale.py b/src/ansys/pre_commit_hooks/quality_rules/vale.py index 3d455cd0..1acb177c 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/vale.py +++ b/src/ansys/pre_commit_hooks/quality_rules/vale.py @@ -40,7 +40,7 @@ from __future__ import annotations from ansys.pre_commit_hooks.quality_rules.common import ( - file_contains, + checked_contains, file_exists, ) @@ -81,10 +81,7 @@ class VL002(Vale): @staticmethod def check(root) -> bool | None: """Return whether the Vale configuration uses the Google style package.""" - if not file_exists(root, _VALE_CONFIG): - return None - - return file_contains(root, _VALE_CONFIG, "Google") + return checked_contains(root, _VALE_CONFIG, "Google") class VL003(Vale): @@ -95,10 +92,7 @@ class VL003(Vale): @staticmethod def check(root) -> bool | None: """Return whether the Vale configuration references the ANSYS vocabulary.""" - if not file_exists(root, _VALE_CONFIG): - return None - - return file_contains(root, _VALE_CONFIG, "ANSYS") + return checked_contains(root, _VALE_CONFIG, "ANSYS") class VL004(Vale): diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 3c9ee796..9a2607d5 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -11,7 +11,7 @@ from ansys.pre_commit_hooks import quality_rules import ansys.pre_commit_hooks.pyansys_quality_report as hook -from ansys.pre_commit_hooks.quality_rules import project_metadata +from ansys.pre_commit_hooks.quality_rules import project_metadata, security from ansys.pre_commit_hooks.quality_rules.common import workflow_map from ansys.pre_commit_hooks.quality_rules.project_metadata import ( PM013, @@ -187,6 +187,32 @@ def class_names(path: Path) -> set[str]: assert expected - family_names == actual +def test_sec004_requires_all_uses_lines_to_be_pinned(tmp_path): + """Mixed pinned and unpinned GitHub Actions should fail instead of passing on a single SHA.""" + repo_path = tmp_path / "security-project" + repo_path.mkdir() + (repo_path / ".github").mkdir() + (repo_path / ".github" / "workflows").mkdir() + (repo_path / ".github" / "workflows" / "pr.yml").write_text( + """ +name: PR +on: [pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567 + - uses: actions/cache@v4 +""".strip(), + encoding="utf-8", + ) + + assert security.SEC004.check(repo_path, {"pr": {"path": ".github/workflows/pr.yml"}}) == ( + "⚠️ Some GitHub Actions in the PR workflow are not pinned to full commit SHAs. " + "Use full commit SHAs for all actions." + ) + + def test_pm015_accepts_apache_license(tmp_path): """Project metadata should accept Apache 2.0 as a valid license text.""" repo_path = tmp_path / "apache-license-project" From ed1e8c1972367ce5fe23cd83401c73e09132a7da Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Fri, 4 Sep 2026 14:04:08 +0530 Subject: [PATCH 46/49] fix: suggestions by @jorgepiloto --- .pre-commit-config.yaml | 10 +++++----- doc/source/conf.py | 3 ++- src/ansys/pre_commit_hooks/tech_review.py | 8 ++++++++ tests/test_pyansys_quality_report.py | 12 ++++++------ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 29e1e64d..18a397e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -64,8 +64,8 @@ repos: - --product=pre_commit_hooks - --non_compliant_name -- repo: https://github.com/ansys/pre-commit-hooks - rev: f911449 - hooks: - - id: pyansys-quality-report - args: [--repo-root, ., --all, --fix-missing] \ No newline at end of file +# - repo: https://github.com/ansys/pre-commit-hooks +# rev: f911449 +# hooks: +# - id: pyansys-quality-report +# args: [--repo-root, ., --all, --fix-missing] \ No newline at end of file diff --git a/doc/source/conf.py b/doc/source/conf.py index ab42f2f8..1b62117f 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -44,7 +44,8 @@ }, } -# Exclude legacy modules that still share public API names with the canonical hook. +# Keep the legacy compatibility module out of the canonical API docs. +# It remains intentionally excluded while the newer quality-report entry point is preferred. autoapi_ignore = ["**/tech_review.py"] # Sphinx extensions diff --git a/src/ansys/pre_commit_hooks/tech_review.py b/src/ansys/pre_commit_hooks/tech_review.py index 7f159198..6a46d652 100644 --- a/src/ansys/pre_commit_hooks/tech_review.py +++ b/src/ansys/pre_commit_hooks/tech_review.py @@ -30,6 +30,14 @@ import pathlib import re from tempfile import NamedTemporaryFile +import warnings + +warnings.warn( + "ansys.pre_commit_hooks.tech_review is deprecated and kept only for backward compatibility; " + "use ansys.pre_commit_hooks.pyansys_quality_report instead.", + DeprecationWarning, + stacklevel=2, +) HOOK_PATH = pathlib.Path(__file__).parent.resolve() """Location of the pre-commit hook on your system.""" diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 9a2607d5..e0fb7271 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -65,11 +65,11 @@ def test_pm010_accepts_poetry_readme_reference(tmp_path): assert project_metadata.PM010.check(repo_path, "README.rst") is True -def test_main_reports_quality_summary(tmp_path, capsys): +def test_main_reports_quality_summary(tmp_path, capsys, monkeypatch): """The quality report hook should run and print a summary for the repo.""" repo_path = tmp_path / "quality-demo" repo_path.mkdir() - os.chdir(repo_path) + monkeypatch.chdir(repo_path) git.Repo.init(repo_path) (repo_path / ".pre-commit-config.yaml").write_text("repos: []\n", encoding="utf-8") @@ -93,11 +93,11 @@ def test_main_reports_quality_summary(tmp_path, capsys): assert "Score" in output or "Summary" in output -def test_fix_missing_runs_quality_report_after_bootstrap(tmp_path, capsys): +def test_fix_missing_runs_quality_report_after_bootstrap(tmp_path, capsys, monkeypatch): """--fix-missing should bootstrap the repo and then continue to the quality report.""" repo_path = tmp_path / "quality-demo" repo_path.mkdir() - os.chdir(repo_path) + monkeypatch.chdir(repo_path) git.Repo.init(repo_path) repo = git.Repo(repo_path) repo.index.commit("initial") @@ -126,11 +126,11 @@ def test_fix_missing_runs_quality_report_after_bootstrap(tmp_path, capsys): assert "Score" in output or "Summary" in output -def test_main_colors_status_labels(tmp_path, capsys): +def test_main_colors_status_labels(tmp_path, capsys, monkeypatch): """The console report should colorize pass, warn, and fail states.""" repo_path = tmp_path / "quality-demo" repo_path.mkdir() - os.chdir(repo_path) + monkeypatch.chdir(repo_path) git.Repo.init(repo_path) (repo_path / ".pre-commit-config.yaml").write_text("repos: []\n", encoding="utf-8") From 9c5d29bc38ff08938cee453ffe4b054b4edc0eec Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Fri, 4 Sep 2026 16:08:16 +0530 Subject: [PATCH 47/49] fix: revert tech-review precommit --- .pre-commit-hooks.yaml | 7 +++++++ setup.py | 1 + 2 files changed, 8 insertions(+) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 4b2fd3be..d2d267de 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -10,4 +10,11 @@ description: "Generate a PyAnsys repository quality summary" entry: pyansys-quality-report language: python + pass_filenames: false + +- id: "tech-review" + name: "PyAnsys Quality Report (deprecated alias)" + description: "Deprecated compatibility alias for pyansys-quality-report" + entry: pyansys-quality-report + language: python pass_filenames: false \ No newline at end of file diff --git a/setup.py b/setup.py index 992d5cc3..78a6b029 100644 --- a/setup.py +++ b/setup.py @@ -74,6 +74,7 @@ "console_scripts": [ "add-license-headers=ansys.pre_commit_hooks.add_license_headers:main", "pyansys-quality-report=ansys.pre_commit_hooks.pyansys_quality_report:main", + "tech-review=ansys.pre_commit_hooks.pyansys_quality_report:main", ], }, ) From 3603b2843522305d34ba973ee27917258df8d2db Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Tue, 8 Sep 2026 14:51:12 +0530 Subject: [PATCH 48/49] fix: add metadata flag --- .../pyansys_quality_report.py | 52 ++++++++++++++++++- tests/test_pyansys_quality_report.py | 35 +++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index a6fb0a03..37849c21 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -687,17 +687,20 @@ def _run_checks( files: dict[str, str | None], is_mcp_flag: bool, ignored_codes: set[str] | None = None, + selected_codes: set[str] | None = None, ) -> dict[str, Any]: """Run the package-based repo review checks against an in-memory file set.""" root = MemoryTraversable(files) fixture_values = _build_fixture_values(root, is_mcp_flag) checks = repo_review_checks() + if selected_codes: + checks = {code: check for code, check in checks.items() if code.upper() in selected_codes} families = repo_review_families() ignored = _normalize_ignore_codes(ignored_codes or set()) results = [ _execute_check(check_obj, code=code, fixture_values=fixture_values, families=families) for code, check_obj in checks.items() - if code not in ignored + if code.upper() not in ignored ] tally = _tally_results(results) @@ -772,6 +775,29 @@ def _style_status(status: str, text: str) -> str: return f"{color}{text}{reset}" if color else text +def _metadata_report(selected_codes: set[str] | None = None) -> list[dict[str, str]]: + """Return the available rule metadata keyed by code and family.""" + checks = repo_review_checks() + if selected_codes: + checks = {code: check for code, check in checks.items() if code.upper() in selected_codes} + + items: list[dict[str, str]] = [] + for code, check_obj in sorted(checks.items()): + klass = check_obj.__class__ + items.append( + { + "id": code, + "family": getattr(klass, "family", "unknown"), + "name": _first_doc_line(check_obj), + "description": ( + (klass.__doc__ or "").strip().splitlines()[0] if klass.__doc__ else "" + ), + } + ) + + return items + + def _print_report(review: dict[str, Any], *, show_passes: bool = False) -> None: """Print the repo quality summary to stdout.""" results = review["results"] @@ -806,6 +832,18 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--json", action="store_true", help="Emit a JSON report instead of a text summary." ) + parser.add_argument( + "--metadata", + action="store_true", + help="Print metadata for all available repository quality checks.", + ) + parser.add_argument( + "--check", + action="append", + default=[], + help="Limit the run to specific quality-check codes, such as PM010 or PM014. " + "May be passed multiple times or as a comma-separated list.", + ) parser.add_argument( "--all", action="store_true", help="Show all checks, including passing ones." ) @@ -851,6 +889,11 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--non_compliant_name", action="store_true") args = parser.parse_args(argv) + selected_codes = _normalize_ignore_codes(args.check) + if args.metadata: + print(json.dumps(_metadata_report(selected_codes), indent=2)) + return 0 + repo_root = Path(args.repo_root).resolve() if not repo_root.exists(): raise FileNotFoundError(f"Repo root not found: {repo_root}") @@ -880,7 +923,12 @@ def main(argv: list[str] | None = None) -> int: files = _load_files(repo_root) ignored = _normalize_ignore_codes(args.ignore) ignored |= _read_pyproject_ignore(repo_root) - review = _run_checks(files, is_mcp_flag=is_mcp(MemoryTraversable(files)), ignored_codes=ignored) + review = _run_checks( + files, + is_mcp_flag=is_mcp(MemoryTraversable(files)), + ignored_codes=ignored, + selected_codes=selected_codes, + ) if args.json: print(json.dumps(review, indent=2)) diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index e0fb7271..06019007 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -37,6 +37,41 @@ def test_workflow_map_classifies_ci_cd_roles(tmp_path): assert result["pr"]["name"] == "ci_cd_pr.yml" +def test_main_lists_quality_check_metadata(capsys): + """The CLI should list the available quality checks through a metadata flag.""" + exit_code = hook.main(["--metadata"]) + output = capsys.readouterr().out + + assert exit_code == 0 + assert '"id": "PM001"' in output + assert '"id": "PC001"' in output + assert '"family": "project_metadata"' in output + + +def test_main_can_limit_checks_to_selected_codes(tmp_path, capsys): + """The CLI should allow a subset of checks to be evaluated using a selection flag.""" + repo_path = tmp_path / "quality-demo" + repo_path.mkdir() + (repo_path / "README.rst").write_text("Demo\n=====\n", encoding="utf-8") + (repo_path / "pyproject.toml").write_text( + """ +[project] +name = "ansys-demo-library" +version = "0.1.0" +authors = [{name = "Example", email = "example@example.com"}] +maintainers = [{name = "Example", email = "example@example.com"}] +""".strip(), + encoding="utf-8", + ) + + exit_code = hook.main(["--repo-root", str(repo_path), "--check", "PM010,PM014"]) + output = capsys.readouterr().out + + assert exit_code in (0, 1) + assert "PM010" in output or "PM014" in output + assert "PM001" not in output + + def test_pm013_accepts_supported_version_formats(tmp_path): """Development versions in Python packaging should be accepted alongside SemVer.""" for version in ["1.2.3", "1.2.3-rc.1", "1.2.3.dev0", "1.2.3.dev1"]: From 52f99ca60c4d75f0a94aecec007ed9eb8f67c2d0 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Tue, 8 Sep 2026 15:01:33 +0530 Subject: [PATCH 49/49] fix: add metadata family --- .../pyansys_quality_report.py | 45 ++++++++++++++++--- tests/test_pyansys_quality_report.py | 24 ++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 37849c21..76827565 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -775,11 +775,36 @@ def _style_status(status: str, text: str) -> str: return f"{color}{text}{reset}" if color else text -def _metadata_report(selected_codes: set[str] | None = None) -> list[dict[str, str]]: +def _normalize_selection_codes( + selected_codes: set[str] | None = None, + selected_families: set[str] | None = None, +) -> set[str]: + """Return the final set of rule codes after expanding any family selectors.""" + normalized = set() + if selected_codes: + normalized |= {code.upper() for code in selected_codes} + + if selected_families: + checks = repo_review_checks() + expanded = { + code.upper() + for code, check_obj in checks.items() + if getattr(check_obj.__class__, "family", "").lower() in selected_families + } + normalized |= expanded + + return normalized + + +def _metadata_report( + selected_codes: set[str] | None = None, + selected_families: set[str] | None = None, +) -> list[dict[str, str]]: """Return the available rule metadata keyed by code and family.""" checks = repo_review_checks() - if selected_codes: - checks = {code: check for code, check in checks.items() if code.upper() in selected_codes} + selected = _normalize_selection_codes(selected_codes, selected_families) + if selected: + checks = {code: check for code, check in checks.items() if code.upper() in selected} items: list[dict[str, str]] = [] for code, check_obj in sorted(checks.items()): @@ -844,6 +869,13 @@ def main(argv: list[str] | None = None) -> int: help="Limit the run to specific quality-check codes, such as PM010 or PM014. " "May be passed multiple times or as a comma-separated list.", ) + parser.add_argument( + "--family", + action="append", + default=[], + help="Limit the run to a rule family, such as documentation or security. " + "May be passed multiple times or as a comma-separated list.", + ) parser.add_argument( "--all", action="store_true", help="Show all checks, including passing ones." ) @@ -890,8 +922,11 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) selected_codes = _normalize_ignore_codes(args.check) + selected_families = { + family.strip().lower() for family in ",".join(args.family).split(",") if family.strip() + } if args.metadata: - print(json.dumps(_metadata_report(selected_codes), indent=2)) + print(json.dumps(_metadata_report(selected_codes, selected_families), indent=2)) return 0 repo_root = Path(args.repo_root).resolve() @@ -927,7 +962,7 @@ def main(argv: list[str] | None = None) -> int: files, is_mcp_flag=is_mcp(MemoryTraversable(files)), ignored_codes=ignored, - selected_codes=selected_codes, + selected_codes=_normalize_selection_codes(selected_codes, selected_families), ) if args.json: diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 06019007..e133856f 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -72,6 +72,30 @@ def test_main_can_limit_checks_to_selected_codes(tmp_path, capsys): assert "PM001" not in output +def test_main_can_limit_checks_to_selected_family(tmp_path, capsys): + """The CLI should allow a repository family to be selected instead of individual rule IDs.""" + repo_path = tmp_path / "quality-demo" + repo_path.mkdir() + (repo_path / "README.rst").write_text("Demo\n=====\n", encoding="utf-8") + (repo_path / "pyproject.toml").write_text( + """ +[project] +name = "ansys-demo-library" +version = "0.1.0" +authors = [{name = "Example", email = "example@example.com"}] +maintainers = [{name = "Example", email = "example@example.com"}] +""".strip(), + encoding="utf-8", + ) + + exit_code = hook.main(["--repo-root", str(repo_path), "--family", "documentation", "--all"]) + output = capsys.readouterr().out + + assert exit_code in (0, 1) + assert "DOC001" in output + assert "PM001" not in output + + def test_pm013_accepts_supported_version_formats(tmp_path): """Development versions in Python packaging should be accepted alongside SemVer.""" for version in ["1.2.3", "1.2.3-rc.1", "1.2.3.dev0", "1.2.3.dev1"]: