diff --git a/.github/scripts/check-portability.py b/.github/scripts/check-portability.py index 984f355..2186b98 100644 --- a/.github/scripts/check-portability.py +++ b/.github/scripts/check-portability.py @@ -15,7 +15,7 @@ FORBIDDEN_PATTERNS = ( ("Hermes tool name", re.compile(r"\bskill_(?:view|manage)\b", re.IGNORECASE)), - ("Hermes config path", re.compile(r"~/\\.hermes(?:/|\b)", re.IGNORECASE)), + ("Hermes config path", re.compile(r"~/\.hermes(?:/|\b)", re.IGNORECASE)), ( "Hermes CLI command", re.compile( @@ -53,7 +53,54 @@ def scan_file(path: Path) -> list[tuple[Path, int, str, str]]: return violations +def run_self_tests() -> int: + """Run internal self-tests for the validation logic.""" + test_cases = [ + ("skill_view(name)", True, "Hermes tool name"), + ("SKILL_VIEW(name)", True, "Hermes tool name case insensitive"), + ("from hermes_tools import foo", True, "Hermes Python import"), + ("from HERMES_TOOLS import foo", True, "Hermes Python import case insensitive"), + ("~/.hermes/config", True, "Hermes config path"), + ("~/.hermes/", True, "Hermes config path with slash"), + ("hermes skills install", True, "Hermes CLI command"), + ("hermes config set", True, "Hermes CLI command"), + ("Claude()", True, "Claude Code agent reference"), + ("gemini skills", True, "Gemini CLI command"), + ("codex run", True, "Codex CLI command"), + (".claude/config", True, "Platform-specific path"), + (".cursor/config", True, "Platform-specific path"), + ("normal text", False, "Normal text"), + ("skill_manage(action='create')", True, "Hermes tool name"), + ] + + for text, should_match, label in test_cases: + for pattern_label, pattern in FORBIDDEN_PATTERNS: + if pattern.search(text): + if not should_match: + print(f"FAIL: {label} matched unexpectedly: {text!r}") + return 1 + break + else: + if should_match: + print(f"FAIL: {label} should have matched: {text!r}") + return 1 + + print("PASS: check-portability.py self-tests") + return 0 + + def main() -> int: + import argparse + + parser = argparse.ArgumentParser( + description="Check for agent-specific references in portable skill files." + ) + parser.add_argument("--self-test", action="store_true", help="Run internal self-tests") + args = parser.parse_args() + + if args.self_test: + return run_self_tests() + violations: list[tuple[Path, int, str, str]] = [] for path in sorted(SKILLS_DIR.rglob("*.md")): try: @@ -73,4 +120,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccd53eb..083feed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,9 @@ jobs: - name: Check cross-agent portability run: python3 .github/scripts/check-portability.py + - name: Check version consistency + run: python3 scripts/check-version-consistency.py + - name: Validate CI policy run: python3 scripts/validate-ci.py diff --git a/CITATION.cff b/CITATION.cff index f0c78ac..4ab2a58 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,8 +2,8 @@ cff-version: 1.2.0 message: "If you use this work in your research, please cite it as below." authors: - family-names: "CodeSigils" - email: "toolsoftrade.web@gmail.com" title: "Python Project Workflow" +version: "0.1.0" license: MIT type: software repository-code: https://github.com/CodeSigils/python-project-workflow-skill diff --git a/scripts/check-version-consistency.py b/scripts/check-version-consistency.py new file mode 100644 index 0000000..d3e997a --- /dev/null +++ b/scripts/check-version-consistency.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Check local release versions and git tags for consistency.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + +LOCAL_VERSION_SOURCES: dict[str, Path] = { + "SKILL.md": ROOT / "skills" / "python-project-workflow" / "SKILL.md", + "CITATION.cff": ROOT / "CITATION.cff", +} + + +def normalize_version(version: str) -> str: + return version.removeprefix("v") + + +def read_skill_version(path: Path) -> str | None: + """Extract metadata version from SKILL.md frontmatter.""" + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None + frontmatter = content.split("---", 2) + if len(frontmatter) < 3: + return None + match = re.search(r'(?m)^version:\s*["\']?([^"\'#\s]+)', frontmatter[1]) + return match.group(1) if match else None + + +def read_citation_version(path: Path) -> str | None: + """Extract the version field from CITATION.cff.""" + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None + match = re.search(r'(?m)^version:\s*["\']?([^"\'#\s]+)', content) + return match.group(1) if match else None + + +def get_latest_tag() -> tuple[str | None, str | None]: + """Return the latest v-prefixed tag and an optional query error.""" + try: + result = subprocess.run( + ["git", "tag", "--list", "v*", "--sort=-version:refname"], + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + return None, str(exc) + if result.returncode != 0: + return None, result.stderr.strip() or f"git exited {result.returncode}" + tags = result.stdout.splitlines() + return (tags[0], None) if tags else (None, None) + + +def validate_versions( + local_versions: dict[str, str | None], + latest_tag: str | None, + tag_error: str | None, +) -> tuple[list[str], list[str]]: + """Validate version consistency and return errors plus informational notes.""" + errors: list[str] = [] + notes: list[str] = [] + comparable: dict[str, str] = {} + + for source in LOCAL_VERSION_SOURCES: + version = local_versions.get(source) + if not version: + errors.append(f"Could not extract version from {source}") + else: + comparable[source] = normalize_version(version) + + if tag_error: + notes.append(f"SKIP: could not query git tags: {tag_error}") + elif latest_tag: + comparable["latest tag"] = normalize_version(latest_tag) + else: + notes.append("SKIP: no v-prefixed git tags found") + + if comparable and len(set(comparable.values())) > 1: + rendered = ", ".join( + f"{source}={version}" for source, version in comparable.items() + ) + errors.append(f"Version drift: {rendered}") + + return errors, notes + + +def run_self_tests() -> int: + """Cover aligned, drifted, and no-tag states.""" + aligned = {source: "0.1.0" for source in LOCAL_VERSION_SOURCES} + + errors, notes = validate_versions(aligned, "v0.1.0", None) + assert errors == [] and notes == [] + + drifted = {**aligned, "CITATION.cff": "0.2.0"} + errors, _ = validate_versions(drifted, "v0.1.0", None) + assert any(error.startswith("Version drift:") for error in errors) + + errors, notes = validate_versions(aligned, None, None) + assert errors == [] and notes == ["SKIP: no v-prefixed git tags found"] + + errors, notes = validate_versions(aligned, None, "not a git repository") + assert errors == [] and notes == [ + "SKIP: could not query git tags: not a git repository" + ] + + print("PASS: check-version-consistency.py self-tests") + return 0 + + +def main() -> int: + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + + if args.self_test: + return run_self_tests() + + local_versions = { + "SKILL.md": read_skill_version(LOCAL_VERSION_SOURCES["SKILL.md"]), + "CITATION.cff": read_citation_version(LOCAL_VERSION_SOURCES["CITATION.cff"]), + } + latest_tag, tag_error = get_latest_tag() + + for source, version in local_versions.items(): + print(f"{source} version: {version or 'unreadable'}") + print(f"Latest tag: {latest_tag or 'none'}") + + errors, notes = validate_versions(local_versions, latest_tag, tag_error) + for note in notes: + print(note) + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print("OK: all available version sources are consistent") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate-ci.py b/scripts/validate-ci.py index dee2c68..aca1ab1 100644 --- a/scripts/validate-ci.py +++ b/scripts/validate-ci.py @@ -13,6 +13,7 @@ SHA_PIN_RE = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") REQUIRED_VALIDATE_COMMANDS = ( "python3 .github/scripts/check-portability.py", + "python3 scripts/check-version-consistency.py", "python3 scripts/validate-ci.py", "python3 scripts/validate.py", "python3 scripts/test-validate-ci.py", diff --git a/scripts/validate.py b/scripts/validate.py index bbdfb9d..820d4ee 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -93,6 +93,8 @@ def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: fail(f"unsupported frontmatter fields: {sorted(extra)}") if data.get("name") != "python-project-workflow": fail("frontmatter name must be python-project-workflow") + if not data.get("version"): + fail("frontmatter must include a version field") if len(data.get("description", "").split()) < 18: fail("frontmatter description is too short to trigger reliably") return data, body diff --git a/skills/python-project-workflow/SKILL.md b/skills/python-project-workflow/SKILL.md index 5516010..57c78f1 100644 --- a/skills/python-project-workflow/SKILL.md +++ b/skills/python-project-workflow/SKILL.md @@ -1,5 +1,6 @@ --- name: python-project-workflow +version: "0.1.0" description: Set up, inspect, preserve, and verify Python projects across greenfield bootstrap, tooling configuration, CI, packaging, mature-repo preservation, and project-native verification. Use for Python project workflow tasks, not for pure code review; use py-review-skill or another dedicated review skill for review findings. ---