204 lines
7.6 KiB
Python
204 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
|
|
ALLOWED_STATUS = {"experimental", "active", "deprecated"}
|
|
ALLOWED_MATURITY = {"scaffold", "production", "library", "governed"}
|
|
ALLOWED_REVIEW_CADENCE = {"monthly", "quarterly", "semiannual", "annual", "per-release"}
|
|
DECLARED_MATURITY_MIN_SCORE = {
|
|
"scaffold": 0,
|
|
"production": 80,
|
|
"library": 85,
|
|
"governed": 90,
|
|
}
|
|
|
|
|
|
def load_json(path: Path) -> dict:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def read_frontmatter(skill_md: Path) -> dict:
|
|
if not skill_md.exists():
|
|
return {}
|
|
text = skill_md.read_text(encoding="utf-8")
|
|
if not text.startswith("---"):
|
|
return {}
|
|
parts = text.split("---", 2)
|
|
if len(parts) < 3:
|
|
return {}
|
|
payload = {}
|
|
for line in parts[1].splitlines():
|
|
if ":" not in line:
|
|
continue
|
|
key, value = line.split(":", 1)
|
|
payload[key.strip()] = value.strip().strip("'\"")
|
|
return payload
|
|
|
|
|
|
def has_files(path: Path) -> bool:
|
|
return path.exists() and any(child.is_file() for child in path.rglob("*"))
|
|
|
|
|
|
def score_label(score: int) -> str:
|
|
if score >= 90:
|
|
return "governed"
|
|
if score >= 80:
|
|
return "production"
|
|
if score >= 65:
|
|
return "reusable"
|
|
if score >= 45:
|
|
return "emerging"
|
|
return "draft"
|
|
|
|
|
|
def compute_score(root: Path, manifest: dict, frontmatter: dict, skill_text: str, manifest_valid: bool) -> tuple[int, dict]:
|
|
breakdown = {
|
|
"metadata_integrity": 0,
|
|
"ownership_and_review": 0,
|
|
"boundary_and_eval": 0,
|
|
"operational_assets": 0,
|
|
"maintenance_evidence": 0,
|
|
}
|
|
|
|
if manifest_valid and manifest:
|
|
breakdown["metadata_integrity"] += 4
|
|
if manifest.get("name") and frontmatter.get("name") and manifest["name"] == frontmatter["name"]:
|
|
breakdown["metadata_integrity"] += 4
|
|
if manifest.get("version"):
|
|
breakdown["metadata_integrity"] += 3
|
|
if manifest.get("updated_at"):
|
|
breakdown["metadata_integrity"] += 3
|
|
if manifest.get("status") in ALLOWED_STATUS:
|
|
breakdown["metadata_integrity"] += 3
|
|
if manifest.get("maturity_tier") in ALLOWED_MATURITY:
|
|
breakdown["metadata_integrity"] += 3
|
|
|
|
if manifest.get("owner"):
|
|
breakdown["ownership_and_review"] += 10
|
|
if manifest.get("review_cadence") in ALLOWED_REVIEW_CADENCE:
|
|
breakdown["ownership_and_review"] += 10
|
|
|
|
if frontmatter.get("description"):
|
|
breakdown["boundary_and_eval"] += 5
|
|
if any(marker in skill_text.lower() for marker in ("do not use", "out of scope", "should not trigger", "do not")):
|
|
breakdown["boundary_and_eval"] += 10
|
|
if has_files(root / "evals"):
|
|
breakdown["boundary_and_eval"] += 10
|
|
|
|
if (root / "agents" / "interface.yaml").exists():
|
|
breakdown["operational_assets"] += 5
|
|
if has_files(root / "references"):
|
|
breakdown["operational_assets"] += 5
|
|
if has_files(root / "scripts"):
|
|
breakdown["operational_assets"] += 5
|
|
if manifest.get("factory_components") or manifest.get("target_platforms"):
|
|
breakdown["operational_assets"] += 5
|
|
|
|
if has_files(root / "reports"):
|
|
breakdown["maintenance_evidence"] += 5
|
|
if has_files(root / "evals" / "history") or has_files(root / "failures"):
|
|
breakdown["maintenance_evidence"] += 5
|
|
if (
|
|
has_files(root / "outputs")
|
|
or has_files(root / "assets")
|
|
or has_files(root / "examples")
|
|
or has_files(root / "tests")
|
|
):
|
|
breakdown["maintenance_evidence"] += 5
|
|
|
|
total = sum(breakdown.values())
|
|
return total, breakdown
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Check skill governance metadata and lifecycle readiness.")
|
|
parser.add_argument("skill_dir")
|
|
parser.add_argument("--require-manifest", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
root = Path(args.skill_dir).resolve()
|
|
manifest_path = root / "manifest.json"
|
|
skill_md = root / "SKILL.md"
|
|
failures = []
|
|
warnings = []
|
|
details = {"skill_dir": str(root), "manifest_present": manifest_path.exists()}
|
|
|
|
frontmatter = read_frontmatter(skill_md)
|
|
skill_text = skill_md.read_text(encoding="utf-8") if skill_md.exists() else ""
|
|
manifest = {}
|
|
manifest_valid = False
|
|
if manifest_path.exists():
|
|
try:
|
|
manifest = load_json(manifest_path)
|
|
manifest_valid = True
|
|
except json.JSONDecodeError as exc:
|
|
failures.append(f"Invalid manifest.json: {exc}")
|
|
elif args.require_manifest:
|
|
failures.append("Missing manifest.json")
|
|
else:
|
|
warnings.append("No manifest.json; governance metadata is unavailable.")
|
|
|
|
if manifest:
|
|
required = ["name", "version", "owner", "updated_at", "review_cadence", "status", "maturity_tier", "lifecycle_stage"]
|
|
missing = [field for field in required if not manifest.get(field)]
|
|
if missing:
|
|
failures.append(f"Missing manifest fields: {', '.join(missing)}")
|
|
|
|
if manifest.get("status") and manifest["status"] not in ALLOWED_STATUS:
|
|
failures.append(f"Invalid status: {manifest['status']}")
|
|
if manifest.get("maturity_tier") and manifest["maturity_tier"] not in ALLOWED_MATURITY:
|
|
failures.append(f"Invalid maturity_tier: {manifest['maturity_tier']}")
|
|
if manifest.get("lifecycle_stage") and manifest["lifecycle_stage"] not in ALLOWED_MATURITY:
|
|
failures.append(f"Invalid lifecycle_stage: {manifest['lifecycle_stage']}")
|
|
if manifest.get("review_cadence") and manifest["review_cadence"] not in ALLOWED_REVIEW_CADENCE:
|
|
failures.append(f"Invalid review_cadence: {manifest['review_cadence']}")
|
|
if manifest.get("updated_at"):
|
|
try:
|
|
datetime.strptime(manifest["updated_at"], "%Y-%m-%d")
|
|
except ValueError:
|
|
failures.append("updated_at must use YYYY-MM-DD")
|
|
|
|
if frontmatter.get("name") and manifest.get("name") and frontmatter["name"] != manifest["name"]:
|
|
failures.append("manifest name does not match SKILL.md frontmatter name")
|
|
|
|
if manifest.get("status") == "deprecated" and not manifest.get("deprecation_note"):
|
|
warnings.append("Deprecated skill should include deprecation_note in manifest.json.")
|
|
|
|
score, breakdown = compute_score(root, manifest, frontmatter, skill_text, manifest_valid)
|
|
computed_label = score_label(score)
|
|
declared_tier = manifest.get("maturity_tier")
|
|
required_minimum = DECLARED_MATURITY_MIN_SCORE.get(declared_tier)
|
|
if declared_tier in ALLOWED_MATURITY and required_minimum is not None and score < required_minimum:
|
|
warnings.append(
|
|
f"Maturity tier says {declared_tier} but governance score is {score}/100, below the recommended minimum {required_minimum}."
|
|
)
|
|
|
|
report = {
|
|
"ok": not failures,
|
|
"failures": failures,
|
|
"warnings": warnings,
|
|
"details": {
|
|
**details,
|
|
"frontmatter_name": frontmatter.get("name"),
|
|
"manifest_name": manifest.get("name"),
|
|
"status": manifest.get("status"),
|
|
"maturity_tier": manifest.get("maturity_tier"),
|
|
"review_cadence": manifest.get("review_cadence"),
|
|
"governance_score": score,
|
|
"declared_maturity_minimum": required_minimum,
|
|
"computed_governance_band": computed_label,
|
|
"computed_maturity_tier": computed_label,
|
|
"score_breakdown": breakdown,
|
|
},
|
|
}
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
if failures:
|
|
raise SystemExit(2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|