Skip to content

Commit 58b24cf

Browse files
committed
Add production quality checks
1 parent 20fdf8d commit 58b24cf

4 files changed

Lines changed: 239 additions & 0 deletions

File tree

.github/workflows/quality.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Quality
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
validate:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-python@v5
13+
with:
14+
python-version: "3.12"
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version: "22"
18+
- name: Install Python quality dependencies
19+
run: |
20+
python -m pip install --upgrade pip
21+
python -m pip install -r requirements.txt ruff pyyaml
22+
- name: Install diagram and type-check tools
23+
run: npm install -g @mermaid-js/mermaid-cli pyright
24+
- name: Run quality validation
25+
run: python scripts/validate_quality.py

QUALITY.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Quality Checks
2+
3+
Run the production-readiness checks from this repository root:
4+
5+
```bash
6+
python3 scripts/validate_quality.py
7+
```
8+
9+
The validator compiles Python files, runs Ruff, parses YAML/JSON/INI files, renders Mermaid sources when Mermaid tooling is available, runs generator `--help` smoke checks where generator scripts exist, runs catalog `--list` smoke checks where renderer catalog scripts exist, and runs Pyright when a `pyrightconfig.json` file is present.
10+
11+
GitHub Actions runs the same validator on push and pull request.

pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[tool.ruff]
2+
line-length = 120
3+
target-version = "py312"
4+
exclude = [
5+
".git",
6+
".venv",
7+
"venv",
8+
"env",
9+
"ENV",
10+
"output",
11+
"preview",
12+
]
13+
14+
[tool.ruff.lint]
15+
select = ["E4", "E7", "E9", "F"]

scripts/validate_quality.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#!/usr/bin/env python3
2+
"""Validate lint, parser, diagram, and smoke-test gates for this skill repo."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import configparser
8+
import json
9+
import py_compile
10+
import shutil
11+
import subprocess
12+
import sys
13+
import tempfile
14+
from pathlib import Path
15+
from typing import Any, cast
16+
17+
try:
18+
import yaml
19+
except ImportError: # pragma: no cover - reported clearly at runtime
20+
yaml = None
21+
22+
ROOT = Path(__file__).resolve().parents[1]
23+
SKIP_DIRS = {".git", ".venv", "venv", "env", "ENV", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache"}
24+
25+
26+
def iter_files(*suffixes: str) -> list[Path]:
27+
files: list[Path] = []
28+
for path in ROOT.rglob("*"):
29+
if not path.is_file():
30+
continue
31+
if any(part in SKIP_DIRS for part in path.relative_to(ROOT).parts):
32+
continue
33+
if path.suffix in suffixes:
34+
files.append(path)
35+
return sorted(files)
36+
37+
38+
def run(cmd: list[str], cwd: Path = ROOT) -> None:
39+
print("$", " ".join(cmd))
40+
subprocess.run(cmd, cwd=cwd, check=True)
41+
42+
43+
def run_ruff() -> None:
44+
if shutil.which("ruff"):
45+
run(["ruff", "check", "."])
46+
return
47+
if shutil.which("uvx"):
48+
run(["uvx", "ruff", "check", "."])
49+
return
50+
run([sys.executable, "-m", "ruff", "check", "."])
51+
52+
53+
def compile_python() -> None:
54+
for path in iter_files(".py"):
55+
py_compile.compile(str(path), doraise=True)
56+
print("python compile ok")
57+
58+
59+
def parse_structured_files() -> None:
60+
yaml_files = iter_files(".yaml", ".yml")
61+
yaml_module = cast(Any, yaml)
62+
if yaml_files and yaml_module is None:
63+
raise RuntimeError("PyYAML is required to parse YAML files")
64+
for path in yaml_files:
65+
yaml_module.safe_load(path.read_text(encoding="utf-8"))
66+
for path in iter_files(".json"):
67+
json.loads(path.read_text(encoding="utf-8"))
68+
for path in iter_files(".ini"):
69+
parser = configparser.RawConfigParser()
70+
with path.open(encoding="utf-8") as handle:
71+
parser.read_file(handle)
72+
print("structured files ok")
73+
74+
75+
def mermaid_command() -> list[str] | None:
76+
if shutil.which("mmdc"):
77+
return ["mmdc"]
78+
if shutil.which("npx"):
79+
return ["npx", "--yes", "@mermaid-js/mermaid-cli"]
80+
return None
81+
82+
83+
def validate_mermaid(skip: bool) -> None:
84+
diagrams = iter_files(".mmd")
85+
if not diagrams:
86+
print("mermaid skipped: no .mmd files")
87+
return
88+
cmd = mermaid_command()
89+
if skip or cmd is None:
90+
reason = "requested" if skip else "mmdc/npx not available"
91+
print(f"mermaid skipped: {reason}")
92+
return
93+
with tempfile.TemporaryDirectory(prefix="skill-mermaid-") as tmp:
94+
out_dir = Path(tmp)
95+
for index, path in enumerate(diagrams, start=1):
96+
run([*cmd, "-i", str(path), "-o", str(out_dir / f"diagram-{index}.svg"), "-b", "white"])
97+
print("mermaid ok")
98+
99+
100+
def smoke_generators() -> None:
101+
generator_commands = {
102+
"generate_certificate.py": [
103+
"--config",
104+
"config.ini",
105+
"examples/northwind_workshop.ini",
106+
"--out",
107+
"{tmp}/certificate.pdf",
108+
],
109+
"generate_contract.py": [
110+
"--config",
111+
"config.ini",
112+
"examples/northwind_support_triage.ini",
113+
"--out",
114+
"{tmp}/contract.pdf",
115+
"--markdown-out",
116+
"{tmp}/contract.md",
117+
"--no-envelope",
118+
],
119+
"generate_envelope.py": [
120+
"--config",
121+
"config.ini",
122+
"examples/northwind_address.ini",
123+
"--out",
124+
"{tmp}/envelope.pdf",
125+
],
126+
}
127+
with tempfile.TemporaryDirectory(prefix="skill-generator-") as tmp:
128+
for path in sorted(ROOT.glob("generate_*.py")):
129+
run([sys.executable, str(path), "--help"])
130+
args = generator_commands.get(path.name)
131+
if args:
132+
resolved_args = [arg.format(tmp=tmp) for arg in args]
133+
run([sys.executable, str(path), *resolved_args])
134+
print("generator smoke ok")
135+
136+
137+
def smoke_catalog_renderers() -> None:
138+
scripts_dir = ROOT / "scripts"
139+
if not scripts_dir.exists():
140+
print("catalog smoke skipped: no scripts directory")
141+
return
142+
index_path = ROOT / "references" / "template-index.json"
143+
first_template = None
144+
if index_path.exists():
145+
index_data = json.loads(index_path.read_text(encoding="utf-8"))
146+
templates = index_data.get("templates", [])
147+
if templates:
148+
first_template = templates[0].get("id")
149+
for path in sorted(scripts_dir.glob("render_*.py")):
150+
if path.name == "render_pdf.py":
151+
continue
152+
run([sys.executable, str(path), "--list"])
153+
if first_template:
154+
run([sys.executable, str(path), "--template", first_template, "--var", "smoke=value", "--no-pdf"])
155+
print("catalog smoke ok")
156+
157+
158+
def run_pyright() -> None:
159+
if not (ROOT / "pyrightconfig.json").exists():
160+
print("pyright skipped: no pyrightconfig.json")
161+
return
162+
if shutil.which("pyright"):
163+
run(["pyright"])
164+
return
165+
if shutil.which("npx"):
166+
run(["npx", "--yes", "pyright"])
167+
return
168+
raise RuntimeError("pyrightconfig.json exists, but pyright/npx is unavailable")
169+
170+
171+
def main() -> int:
172+
parser = argparse.ArgumentParser(description=__doc__)
173+
parser.add_argument("--skip-mermaid", action="store_true", help="Skip Mermaid render validation when local tooling is unavailable.")
174+
args = parser.parse_args()
175+
176+
compile_python()
177+
run_ruff()
178+
parse_structured_files()
179+
validate_mermaid(args.skip_mermaid)
180+
smoke_generators()
181+
smoke_catalog_renderers()
182+
run_pyright()
183+
print("quality validation ok")
184+
return 0
185+
186+
187+
if __name__ == "__main__":
188+
raise SystemExit(main())

0 commit comments

Comments
 (0)