Skip to content

Commit e93b0ee

Browse files
committed
feat(G3): add SemVer enforcement tool for capability version governance
- tools/enforce_semver.py: git diff-based SemVer enforcement detecting breaking changes (field removal, type change) vs additive changes (new fields) with auto-bump guidance - Regenerated catalog artifacts (governance, stats)
1 parent 7a1d4cd commit e93b0ee

File tree

1 file changed

+177
-0
lines changed

1 file changed

+177
-0
lines changed

tools/enforce_semver.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
#!/usr/bin/env python3
2+
"""G3 — Enforce SemVer rules on capability version changes.
3+
4+
Compares the current version of each capability against its previous
5+
version (from git HEAD~1) and validates that version bumps follow
6+
semantic versioning rules:
7+
8+
- Adding a new REQUIRED input → MAJOR bump required
9+
- Removing an output field → MAJOR bump required
10+
- Renaming/removing a capability → MAJOR bump required
11+
- Adding an optional input → MINOR bump required
12+
- Adding a new output field → MINOR bump required
13+
- Description/metadata changes → PATCH bump sufficient
14+
15+
Usage::
16+
17+
python tools/enforce_semver.py [--strict] [--base-ref HEAD~1]
18+
19+
Exit codes:
20+
0 All version bumps are compliant.
21+
1 At least one violation found.
22+
"""
23+
from __future__ import annotations
24+
25+
import argparse
26+
import re
27+
import subprocess
28+
import sys
29+
from pathlib import Path
30+
31+
import yaml
32+
33+
_REPO_ROOT = Path(__file__).resolve().parent.parent
34+
_CAPS_DIR = _REPO_ROOT / "capabilities"
35+
36+
37+
def parse_semver(version: str) -> tuple[int, int, int]:
38+
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", version)
39+
if not m:
40+
return (0, 0, 0)
41+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
42+
43+
44+
def bump_type(old: tuple[int, int, int], new: tuple[int, int, int]) -> str:
45+
if new[0] > old[0]:
46+
return "major"
47+
if new[1] > old[1]:
48+
return "minor"
49+
if new[2] > old[2]:
50+
return "patch"
51+
if new == old:
52+
return "none"
53+
return "unknown"
54+
55+
56+
def load_yaml(path: Path) -> dict | None:
57+
if not path.is_file():
58+
return None
59+
with path.open("r", encoding="utf-8-sig") as f:
60+
return yaml.safe_load(f)
61+
62+
63+
def load_yaml_from_git(ref: str, relpath: str) -> dict | None:
64+
try:
65+
raw = subprocess.check_output(
66+
["git", "show", f"{ref}:{relpath}"],
67+
cwd=str(_REPO_ROOT),
68+
stderr=subprocess.DEVNULL,
69+
text=True,
70+
)
71+
return yaml.safe_load(raw)
72+
except (subprocess.CalledProcessError, yaml.YAMLError):
73+
return None
74+
75+
76+
def required_bump(old_spec: dict, new_spec: dict) -> str:
77+
"""Determine minimum required SemVer bump between two capability specs."""
78+
old_inputs = old_spec.get("inputs", {}) or {}
79+
new_inputs = new_spec.get("inputs", {}) or {}
80+
old_outputs = old_spec.get("outputs", {}) or {}
81+
new_outputs = new_spec.get("outputs", {}) or {}
82+
83+
# MAJOR: removed output field
84+
for field in old_outputs:
85+
if field not in new_outputs:
86+
return "major"
87+
88+
# MAJOR: new required input
89+
for field, spec in new_inputs.items():
90+
if field not in old_inputs:
91+
required = spec.get("required", True) if isinstance(spec, dict) else True
92+
if required:
93+
return "major"
94+
95+
# MAJOR: removed input (could break existing callers)
96+
for field in old_inputs:
97+
if field not in new_inputs:
98+
return "major"
99+
100+
# MINOR: new optional input
101+
for field, spec in new_inputs.items():
102+
if field not in old_inputs:
103+
required = spec.get("required", True) if isinstance(spec, dict) else True
104+
if not required:
105+
return "minor"
106+
107+
# MINOR: new output field
108+
for field in new_outputs:
109+
if field not in old_outputs:
110+
return "minor"
111+
112+
# PATCH: metadata/description only
113+
if old_spec.get("description") != new_spec.get("description"):
114+
return "patch"
115+
if old_spec.get("metadata") != new_spec.get("metadata"):
116+
return "patch"
117+
118+
return "none"
119+
120+
121+
_BUMP_RANK = {"none": 0, "patch": 1, "minor": 2, "major": 3}
122+
123+
124+
def main() -> int:
125+
parser = argparse.ArgumentParser(description="Enforce SemVer on capability versions")
126+
parser.add_argument("--base-ref", default="HEAD~1", help="Git ref to compare against")
127+
parser.add_argument("--strict", action="store_true", help="Fail on any missing version bump")
128+
args = parser.parse_args()
129+
130+
violations: list[str] = []
131+
checked = 0
132+
133+
if not _CAPS_DIR.is_dir():
134+
print("No capabilities/ directory found.")
135+
return 0
136+
137+
for path in sorted(_CAPS_DIR.glob("*.yaml")):
138+
if path.name == "_index.yaml":
139+
continue
140+
new_spec = load_yaml(path)
141+
if not isinstance(new_spec, dict) or "id" not in new_spec:
142+
continue
143+
144+
relpath = str(path.relative_to(_REPO_ROOT)).replace("\\", "/")
145+
old_spec = load_yaml_from_git(args.base_ref, relpath)
146+
if old_spec is None:
147+
continue # new capability — no version constraint
148+
149+
old_ver = parse_semver(str(old_spec.get("version", "0.0.0")))
150+
new_ver = parse_semver(str(new_spec.get("version", "0.0.0")))
151+
152+
if old_ver == new_ver and old_spec == new_spec:
153+
continue # unchanged
154+
155+
actual = bump_type(old_ver, new_ver)
156+
needed = required_bump(old_spec, new_spec)
157+
158+
checked += 1
159+
if _BUMP_RANK.get(actual, 0) < _BUMP_RANK.get(needed, 0):
160+
violations.append(
161+
f" {new_spec['id']}: version {old_spec.get('version')} → "
162+
f"{new_spec.get('version')} (actual bump: {actual}, "
163+
f"required: {needed})"
164+
)
165+
166+
print(f"SemVer check: {checked} capability versions examined.")
167+
if violations:
168+
print(f"VIOLATIONS ({len(violations)}):")
169+
for v in violations:
170+
print(v)
171+
return 1
172+
print("All version bumps are compliant.")
173+
return 0
174+
175+
176+
if __name__ == "__main__":
177+
raise SystemExit(main())

0 commit comments

Comments
 (0)