contract-driven-delivery 1.0.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -18
- package/assets/CLAUDE.template.md +59 -3
- package/assets/agents/backend-engineer.md +43 -0
- package/assets/agents/change-classifier.md +40 -0
- package/assets/agents/ci-cd-gatekeeper.md +53 -4
- package/assets/agents/contract-reviewer.md +49 -3
- package/assets/agents/dependency-security-reviewer.md +95 -0
- package/assets/agents/e2e-resilience-engineer.md +42 -1
- package/assets/agents/frontend-engineer.md +44 -1
- package/assets/agents/monkey-test-engineer.md +40 -1
- package/assets/agents/qa-reviewer.md +52 -0
- package/assets/agents/repo-context-scanner.md +40 -0
- package/assets/agents/spec-architect.md +77 -3
- package/assets/agents/spec-drift-auditor.md +40 -0
- package/assets/agents/stress-soak-engineer.md +42 -0
- package/assets/agents/test-strategist.md +44 -1
- package/assets/agents/ui-ux-reviewer.md +41 -1
- package/assets/agents/visual-reviewer.md +41 -1
- package/assets/ci/github-actions/contract-driven-gates.yml +50 -5
- package/assets/ci-templates/bun.yml +5 -0
- package/assets/ci-templates/conda.yml +11 -0
- package/assets/ci-templates/go.yml +12 -0
- package/assets/ci-templates/npm.yml +6 -0
- package/assets/ci-templates/pip.yml +10 -0
- package/assets/ci-templates/pnpm.yml +9 -0
- package/assets/ci-templates/poetry.yml +12 -0
- package/assets/ci-templates/rust.yml +12 -0
- package/assets/ci-templates/unknown.yml +4 -0
- package/assets/ci-templates/uv.yml +12 -0
- package/assets/ci-templates/yarn.yml +6 -0
- package/assets/contracts/CHANGELOG.md +27 -0
- package/assets/contracts/api/api-contract.md +7 -0
- package/assets/contracts/business/business-rules.md +7 -0
- package/assets/contracts/ci/ci-gate-contract.md +7 -0
- package/assets/contracts/css/css-contract.md +7 -0
- package/assets/contracts/data/data-shape-contract.md +7 -0
- package/assets/contracts/env/env-contract.md +7 -0
- package/assets/hooks/pre-commit +23 -0
- package/assets/skill/SKILL.md +20 -4
- package/assets/skill/scripts/detect_project_profile.py +68 -1
- package/assets/skill/scripts/generate_change_scaffold.py +2 -2
- package/assets/skill/scripts/validate_api_semantic.py +162 -0
- package/assets/skill/scripts/validate_ci_gates.py +34 -6
- package/assets/skill/scripts/validate_contract_versions.py +385 -0
- package/assets/skill/scripts/validate_contracts.py +25 -1
- package/assets/skill/scripts/validate_env_contract.py +3 -1
- package/assets/skill/scripts/validate_env_semantic.py +182 -0
- package/assets/skill/scripts/validate_spec_traceability.py +34 -8
- package/assets/tests-templates/soak/k6-example.js +19 -0
- package/assets/tests-templates/soak/locust-example.py +21 -0
- package/assets/tests-templates/soak/soak-profile.md +16 -0
- package/assets/tests-templates/stress/artillery-example.yml +27 -0
- package/assets/tests-templates/stress/k6-example.js +22 -0
- package/assets/tests-templates/stress/load-profile.md +14 -0
- package/assets/tests-templates/stress/locust-example.py +21 -0
- package/dist/cli/index.js +593 -106
- package/package.json +7 -4
- package/assets/skill/agents/openai.yaml +0 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Semantic validation of the API contract endpoint table.
|
|
3
|
+
|
|
4
|
+
Reads contracts/api/api-contract.md (relative to cwd), skips YAML frontmatter,
|
|
5
|
+
finds the endpoint table (first markdown table whose header starts with '| method |'),
|
|
6
|
+
and validates each data row for:
|
|
7
|
+
- method ∈ VALID_METHODS
|
|
8
|
+
- path starts with '/'
|
|
9
|
+
- auth ∈ VALID_AUTH
|
|
10
|
+
- at least 5 columns present
|
|
11
|
+
"""
|
|
12
|
+
import sys
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
VALID_METHODS = {'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'}
|
|
17
|
+
VALID_AUTH = {'required', 'optional', 'admin', 'none', 'public'}
|
|
18
|
+
|
|
19
|
+
CONTRACT_PATH = Path('contracts/api/api-contract.md')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def strip_frontmatter(text: str) -> str:
|
|
23
|
+
"""Remove YAML frontmatter delimited by --- ... ---."""
|
|
24
|
+
if text.startswith('---'):
|
|
25
|
+
end = text.find('\n---', 3)
|
|
26
|
+
if end != -1:
|
|
27
|
+
return text[end + 4:].lstrip('\n')
|
|
28
|
+
return text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_table_row(line: str) -> list[str]:
|
|
32
|
+
"""Split a markdown table row into stripped cell values."""
|
|
33
|
+
# Remove leading/trailing pipes and whitespace, then split on '|'
|
|
34
|
+
row = line.strip().strip('|')
|
|
35
|
+
return [cell.strip() for cell in row.split('|')]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_separator_row(cells: list[str]) -> bool:
|
|
39
|
+
"""Return True if this is a markdown table separator (---|---|...)."""
|
|
40
|
+
return all(re.match(r'^:?-+:?$', c) for c in cells if c)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_endpoint_table(lines: list[str]) -> list[tuple[int, str]]:
|
|
44
|
+
"""
|
|
45
|
+
Find all data rows across ALL '| method |' tables in the document.
|
|
46
|
+
Blank lines and prose between rows do not end collection, making this
|
|
47
|
+
robust to files where content is appended after the original table block.
|
|
48
|
+
"""
|
|
49
|
+
in_table = False
|
|
50
|
+
sep_seen = False
|
|
51
|
+
data_rows: list[tuple[int, str]] = []
|
|
52
|
+
|
|
53
|
+
for i, line in enumerate(lines):
|
|
54
|
+
stripped = line.strip()
|
|
55
|
+
|
|
56
|
+
if not stripped:
|
|
57
|
+
continue # blank lines never end table mode
|
|
58
|
+
|
|
59
|
+
if not stripped.startswith('|'):
|
|
60
|
+
# Non-pipe line: keep searching (don't break)
|
|
61
|
+
# A new `## heading` may precede a duplicate table header
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
cells = parse_table_row(stripped)
|
|
65
|
+
if not cells:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# A header row for an endpoint table
|
|
69
|
+
if cells[0].lower() == 'method':
|
|
70
|
+
in_table = True
|
|
71
|
+
sep_seen = False
|
|
72
|
+
continue # skip header row
|
|
73
|
+
|
|
74
|
+
if not in_table:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# Separator row
|
|
78
|
+
if not sep_seen and is_separator_row(cells):
|
|
79
|
+
sep_seen = True
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# Data row
|
|
83
|
+
data_rows.append((i + 1, line)) # 1-based line numbers
|
|
84
|
+
|
|
85
|
+
return data_rows
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main() -> None:
|
|
89
|
+
cwd = Path('.')
|
|
90
|
+
contract = cwd / CONTRACT_PATH
|
|
91
|
+
|
|
92
|
+
if not contract.exists():
|
|
93
|
+
print(f'API contract not found: {CONTRACT_PATH}')
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
raw = contract.read_text(encoding='utf-8', errors='ignore')
|
|
98
|
+
except OSError as e:
|
|
99
|
+
print(f'Cannot read {CONTRACT_PATH}: {e}')
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
if not raw.strip():
|
|
103
|
+
print('API contract: file is empty.')
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
body = strip_frontmatter(raw)
|
|
107
|
+
lines = body.splitlines()
|
|
108
|
+
|
|
109
|
+
data_rows = find_endpoint_table(lines)
|
|
110
|
+
|
|
111
|
+
if not data_rows:
|
|
112
|
+
print('API contract: no endpoint table found')
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
errors: list[str] = []
|
|
116
|
+
|
|
117
|
+
for lineno, raw_line in data_rows:
|
|
118
|
+
cells = parse_table_row(raw_line)
|
|
119
|
+
|
|
120
|
+
# Skip entirely empty rows (blank table filler lines)
|
|
121
|
+
if not any(cells):
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Must have at least 5 columns: method, path, auth, request, response
|
|
125
|
+
if len(cells) < 5:
|
|
126
|
+
errors.append(
|
|
127
|
+
f'Line {lineno}: row has only {len(cells)} column(s), need at least 5: {raw_line.strip()}'
|
|
128
|
+
)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
method = cells[0].upper()
|
|
132
|
+
path = cells[1]
|
|
133
|
+
auth = cells[2].lower()
|
|
134
|
+
|
|
135
|
+
if method not in VALID_METHODS:
|
|
136
|
+
errors.append(
|
|
137
|
+
f'Line {lineno}: invalid method "{cells[0]}" '
|
|
138
|
+
f'(valid: {", ".join(sorted(VALID_METHODS))})'
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if not path.startswith('/'):
|
|
142
|
+
errors.append(
|
|
143
|
+
f'Line {lineno}: path "{path}" does not start with "/"'
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if auth not in VALID_AUTH:
|
|
147
|
+
errors.append(
|
|
148
|
+
f'Line {lineno}: invalid auth "{cells[2]}" '
|
|
149
|
+
f'(valid: {", ".join(sorted(VALID_AUTH))})'
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if errors:
|
|
153
|
+
print('API semantic validation failed:')
|
|
154
|
+
for err in errors:
|
|
155
|
+
print(f' {err}')
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
|
|
158
|
+
print(f'API semantic validation passed ({len(data_rows)} endpoint(s) checked).')
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == '__main__':
|
|
162
|
+
main()
|
|
@@ -3,12 +3,40 @@
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
import argparse, sys
|
|
5
5
|
REQUIRED_TERMS=['required gates','tier','trigger','workflow','promotion policy','rollback policy']
|
|
6
|
-
def
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if not p.exists(): print(f'{p} not found'); sys.exit(1)
|
|
6
|
+
def check_file(p):
|
|
7
|
+
"""Check one ci-gates.md file. Returns list of error strings."""
|
|
8
|
+
errors=[]
|
|
10
9
|
text=p.read_text(encoding='utf-8', errors='ignore').lower()
|
|
11
10
|
missing=[t for t in REQUIRED_TERMS if t not in text]
|
|
12
|
-
if missing:
|
|
13
|
-
|
|
11
|
+
if missing:
|
|
12
|
+
errors.append(f'{p}: ci-gates missing terms: '+', '.join(missing))
|
|
13
|
+
else:
|
|
14
|
+
print(f'CI gates basic validation passed: {p}')
|
|
15
|
+
return errors
|
|
16
|
+
def main():
|
|
17
|
+
ap=argparse.ArgumentParser(); ap.add_argument('path', nargs='?', default=None)
|
|
18
|
+
args=ap.parse_args()
|
|
19
|
+
if args.path is not None:
|
|
20
|
+
p=Path(args.path)
|
|
21
|
+
if not p.exists():
|
|
22
|
+
print(f'Warning: {p} not found -- skipping CI gates validation (file not yet created).')
|
|
23
|
+
sys.exit(0)
|
|
24
|
+
errors=check_file(p)
|
|
25
|
+
if errors: [print(e) for e in errors]; sys.exit(1)
|
|
26
|
+
sys.exit(0)
|
|
27
|
+
# No argument: scan specs/changes/*/ci-gates.md
|
|
28
|
+
changes_root=Path('specs/changes')
|
|
29
|
+
if not changes_root.exists():
|
|
30
|
+
print('Warning: specs/changes/ not found -- skipping CI gates validation.')
|
|
31
|
+
sys.exit(0)
|
|
32
|
+
gates_files=sorted(changes_root.glob('*/ci-gates.md'))
|
|
33
|
+
if not gates_files:
|
|
34
|
+
print('Warning: no ci-gates.md found in specs/changes/ -- skipping CI gates validation.')
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
all_errors=[]
|
|
37
|
+
for p in gates_files:
|
|
38
|
+
all_errors.extend(check_file(p))
|
|
39
|
+
if all_errors:
|
|
40
|
+
[print(e) for e in all_errors]; sys.exit(1)
|
|
41
|
+
sys.exit(0)
|
|
14
42
|
if __name__=='__main__': main()
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
validate_contract_versions.py — Contract Version Control Validator (v1.2.0)
|
|
4
|
+
|
|
5
|
+
For every contract file, validates:
|
|
6
|
+
- frontmatter presence and schema correctness (always)
|
|
7
|
+
- content-change ↔ version-bump coherence (1.0+ only)
|
|
8
|
+
- no version skips or downgrades (1.0+ only)
|
|
9
|
+
- CHANGELOG.md entry exists for every bump (1.0+ only)
|
|
10
|
+
- major bump CHANGELOG entry includes ### Removed or ### Changed (breaking)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import hashlib
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
CONTRACT_FILES = [
|
|
23
|
+
'contracts/api/api-contract.md',
|
|
24
|
+
'contracts/css/css-contract.md',
|
|
25
|
+
'contracts/env/env-contract.md',
|
|
26
|
+
'contracts/data/data-shape-contract.md',
|
|
27
|
+
'contracts/business/business-rules.md',
|
|
28
|
+
'contracts/ci/ci-gate-contract.md',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
VALID_CONTRACT_TYPES = {'api', 'css', 'env', 'data', 'business', 'ci'}
|
|
32
|
+
VALID_BREAKING_POLICIES = {'deprecate-2-minors', 'fail-on-major', 'no-breaking'}
|
|
33
|
+
SEMVER_RE = re.compile(r'^\d+\.\d+\.\d+$')
|
|
34
|
+
DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
|
35
|
+
|
|
36
|
+
CHANGELOG_PATH = 'contracts/CHANGELOG.md'
|
|
37
|
+
|
|
38
|
+
# ── Frontmatter Parser (zero-dependency) ──────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def parse_frontmatter(text: str):
|
|
41
|
+
"""
|
|
42
|
+
Parse YAML-style frontmatter from a markdown file.
|
|
43
|
+
Returns (fields_dict, body_text) or (None, text) if no frontmatter.
|
|
44
|
+
Handles the pattern: ^---\n...\n---\n
|
|
45
|
+
"""
|
|
46
|
+
if not text.startswith('---\n'):
|
|
47
|
+
return None, text
|
|
48
|
+
|
|
49
|
+
end = text.find('\n---\n', 4)
|
|
50
|
+
if end == -1:
|
|
51
|
+
return None, text
|
|
52
|
+
|
|
53
|
+
fm_block = text[4:end]
|
|
54
|
+
body = text[end + 5:] # skip '\n---\n'
|
|
55
|
+
|
|
56
|
+
fields = {}
|
|
57
|
+
for line in fm_block.splitlines():
|
|
58
|
+
if ':' in line:
|
|
59
|
+
key, _, value = line.partition(':')
|
|
60
|
+
fields[key.strip()] = value.strip()
|
|
61
|
+
|
|
62
|
+
return fields, body
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def strip_frontmatter(text: str) -> str:
|
|
66
|
+
"""Return the body text with frontmatter removed."""
|
|
67
|
+
_, body = parse_frontmatter(text)
|
|
68
|
+
return body
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sha256_of(text: str) -> str:
|
|
72
|
+
return hashlib.sha256(text.encode('utf-8')).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Semver helpers ────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def parse_semver(s: str):
|
|
78
|
+
"""Return (major, minor, patch) tuple or None."""
|
|
79
|
+
m = SEMVER_RE.match(s)
|
|
80
|
+
if not m:
|
|
81
|
+
return None
|
|
82
|
+
parts = s.split('.')
|
|
83
|
+
return int(parts[0]), int(parts[1]), int(parts[2])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_pre_1_0(ver_tuple) -> bool:
|
|
87
|
+
return ver_tuple[0] == 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── CHANGELOG parser ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def parse_changelog(root: Path):
|
|
93
|
+
"""
|
|
94
|
+
Parse contracts/CHANGELOG.md and return a dict:
|
|
95
|
+
{ (contract_type, version_str): set_of_section_types }
|
|
96
|
+
section types are the ### headings: 'Added', 'Changed (non-breaking)', etc.
|
|
97
|
+
"""
|
|
98
|
+
cl_path = root / CHANGELOG_PATH
|
|
99
|
+
if not cl_path.exists():
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
text = cl_path.read_text(encoding='utf-8', errors='ignore')
|
|
103
|
+
entries = {}
|
|
104
|
+
|
|
105
|
+
# Match headings like: ## [api 1.0.0] — 2026-01-10
|
|
106
|
+
heading_re = re.compile(
|
|
107
|
+
r'^## \[([a-z]+) (\d+\.\d+\.\d+)\]',
|
|
108
|
+
re.MULTILINE
|
|
109
|
+
)
|
|
110
|
+
section_re = re.compile(r'^### (.+)$', re.MULTILINE)
|
|
111
|
+
|
|
112
|
+
matches = list(heading_re.finditer(text))
|
|
113
|
+
for i, m in enumerate(matches):
|
|
114
|
+
contract_type = m.group(1)
|
|
115
|
+
version = m.group(2)
|
|
116
|
+
start = m.end()
|
|
117
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
118
|
+
block = text[start:end]
|
|
119
|
+
|
|
120
|
+
sections = set(s.strip() for s in section_re.findall(block))
|
|
121
|
+
entries[(contract_type, version)] = sections
|
|
122
|
+
|
|
123
|
+
return entries
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── Git helpers ───────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def git_available(root: Path) -> bool:
|
|
129
|
+
"""Return True if git is available and root is inside a git repo."""
|
|
130
|
+
try:
|
|
131
|
+
r = subprocess.run(
|
|
132
|
+
['git', 'rev-parse', '--git-dir'],
|
|
133
|
+
capture_output=True,
|
|
134
|
+
text=True,
|
|
135
|
+
cwd=str(root),
|
|
136
|
+
)
|
|
137
|
+
return r.returncode == 0
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def git_show_head(root: Path, rel_path: str) -> str | None:
|
|
143
|
+
"""
|
|
144
|
+
Return file content at HEAD, or None if:
|
|
145
|
+
- git unavailable
|
|
146
|
+
- file not tracked / no HEAD yet
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
r = subprocess.run(
|
|
150
|
+
['git', 'show', f'HEAD:{rel_path}'],
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
cwd=str(root),
|
|
154
|
+
)
|
|
155
|
+
if r.returncode == 0:
|
|
156
|
+
return r.stdout
|
|
157
|
+
return None
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Per-file validation ───────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
def validate_format(rel_path: str, fields, errors: list) -> bool:
|
|
165
|
+
"""
|
|
166
|
+
Validate frontmatter fields. Returns True if all required fields pass.
|
|
167
|
+
Appends error messages to `errors`.
|
|
168
|
+
"""
|
|
169
|
+
ok = True
|
|
170
|
+
|
|
171
|
+
if fields is None:
|
|
172
|
+
errors.append(f'{rel_path}: missing frontmatter (expected ---...--- block)')
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
# contract type
|
|
176
|
+
contract = fields.get('contract', '')
|
|
177
|
+
if not contract:
|
|
178
|
+
errors.append(f'{rel_path}: frontmatter missing field "contract"')
|
|
179
|
+
ok = False
|
|
180
|
+
elif contract not in VALID_CONTRACT_TYPES:
|
|
181
|
+
errors.append(
|
|
182
|
+
f'{rel_path}: invalid contract type "{contract}" '
|
|
183
|
+
f'(allowed: {", ".join(sorted(VALID_CONTRACT_TYPES))})'
|
|
184
|
+
)
|
|
185
|
+
ok = False
|
|
186
|
+
|
|
187
|
+
# schema-version
|
|
188
|
+
schema_version = fields.get('schema-version', '')
|
|
189
|
+
if not schema_version:
|
|
190
|
+
errors.append(f'{rel_path}: frontmatter missing field "schema-version"')
|
|
191
|
+
ok = False
|
|
192
|
+
elif not SEMVER_RE.match(schema_version):
|
|
193
|
+
errors.append(
|
|
194
|
+
f'{rel_path}: invalid schema-version "{schema_version}" '
|
|
195
|
+
f'(must match X.Y.Z)'
|
|
196
|
+
)
|
|
197
|
+
ok = False
|
|
198
|
+
|
|
199
|
+
# last-changed
|
|
200
|
+
last_changed = fields.get('last-changed', '')
|
|
201
|
+
if not last_changed:
|
|
202
|
+
errors.append(f'{rel_path}: frontmatter missing field "last-changed"')
|
|
203
|
+
ok = False
|
|
204
|
+
elif not DATE_RE.match(last_changed):
|
|
205
|
+
errors.append(
|
|
206
|
+
f'{rel_path}: invalid last-changed "{last_changed}" '
|
|
207
|
+
f'(must be YYYY-MM-DD)'
|
|
208
|
+
)
|
|
209
|
+
ok = False
|
|
210
|
+
|
|
211
|
+
# breaking-change-policy
|
|
212
|
+
policy = fields.get('breaking-change-policy', '')
|
|
213
|
+
if not policy:
|
|
214
|
+
errors.append(f'{rel_path}: frontmatter missing field "breaking-change-policy"')
|
|
215
|
+
ok = False
|
|
216
|
+
elif policy not in VALID_BREAKING_POLICIES:
|
|
217
|
+
errors.append(
|
|
218
|
+
f'{rel_path}: invalid breaking-change-policy "{policy}" '
|
|
219
|
+
f'(allowed: {", ".join(sorted(VALID_BREAKING_POLICIES))})'
|
|
220
|
+
)
|
|
221
|
+
ok = False
|
|
222
|
+
|
|
223
|
+
return ok
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def validate_file(root: Path, rel_path: str, changelog_entries: dict, errors: list):
|
|
227
|
+
abs_path = root / rel_path
|
|
228
|
+
|
|
229
|
+
if not abs_path.exists():
|
|
230
|
+
errors.append(f'{rel_path}: file not found')
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
current_text = abs_path.read_text(encoding='utf-8', errors='ignore')
|
|
234
|
+
current_fields, current_body = parse_frontmatter(current_text)
|
|
235
|
+
|
|
236
|
+
# ── Always validate format ────────────────────────────────────────────────
|
|
237
|
+
format_ok = validate_format(rel_path, current_fields, errors)
|
|
238
|
+
if not format_ok:
|
|
239
|
+
return # cannot proceed without valid frontmatter
|
|
240
|
+
|
|
241
|
+
schema_version_str = current_fields.get('schema-version', '')
|
|
242
|
+
current_ver = parse_semver(schema_version_str)
|
|
243
|
+
if current_ver is None:
|
|
244
|
+
return # already caught above
|
|
245
|
+
|
|
246
|
+
contract_type = current_fields.get('contract', '')
|
|
247
|
+
|
|
248
|
+
# ── Try to get HEAD version ───────────────────────────────────────────────
|
|
249
|
+
head_text = git_show_head(root, rel_path)
|
|
250
|
+
|
|
251
|
+
if head_text is None:
|
|
252
|
+
# New/untracked file or no HEAD — baseline, format already validated
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
head_fields, head_body = parse_frontmatter(head_text)
|
|
256
|
+
|
|
257
|
+
if head_fields is None:
|
|
258
|
+
# HEAD had no frontmatter — treat as baseline (format already validated)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
head_version_str = head_fields.get('schema-version', '')
|
|
262
|
+
head_ver = parse_semver(head_version_str)
|
|
263
|
+
if head_ver is None:
|
|
264
|
+
# HEAD had invalid version — can't compare sensibly, skip extra rules
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
# ── Pre-1.0: only format validation ──────────────────────────────────────
|
|
268
|
+
if is_pre_1_0(current_ver):
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
# ── Post-1.0 rules ────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
current_body_hash = sha256_of(current_body)
|
|
274
|
+
head_body_hash = sha256_of(head_body)
|
|
275
|
+
content_changed = current_body_hash != head_body_hash
|
|
276
|
+
version_changed = current_ver != head_ver
|
|
277
|
+
|
|
278
|
+
# a) Content ↔ version coherence
|
|
279
|
+
if content_changed and not version_changed:
|
|
280
|
+
errors.append(
|
|
281
|
+
f'{rel_path}: content changed but schema-version not bumped '
|
|
282
|
+
f'(still {schema_version_str})'
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
if version_changed and not content_changed:
|
|
287
|
+
errors.append(
|
|
288
|
+
f'{rel_path}: version changed without content change '
|
|
289
|
+
f'({head_version_str} → {schema_version_str})'
|
|
290
|
+
)
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
if not version_changed:
|
|
294
|
+
# No change at all — fine
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
# b) Version bump rules (version_changed == True here)
|
|
298
|
+
new_maj, new_min, new_pat = current_ver
|
|
299
|
+
old_maj, old_min, old_pat = head_ver
|
|
300
|
+
|
|
301
|
+
# No downgrade
|
|
302
|
+
if current_ver < head_ver:
|
|
303
|
+
errors.append(
|
|
304
|
+
f'{rel_path}: schema-version downgrade not allowed '
|
|
305
|
+
f'({head_version_str} → {schema_version_str})'
|
|
306
|
+
)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
# Major bump: must be exactly +1, minor and patch reset to 0
|
|
310
|
+
if new_maj > old_maj:
|
|
311
|
+
if new_maj != old_maj + 1:
|
|
312
|
+
errors.append(
|
|
313
|
+
f'{rel_path}: major version skip not allowed '
|
|
314
|
+
f'({head_version_str} → {schema_version_str}); '
|
|
315
|
+
f'must increment by 1'
|
|
316
|
+
)
|
|
317
|
+
return
|
|
318
|
+
# major bump is valid version increment; fall through to CHANGELOG check
|
|
319
|
+
|
|
320
|
+
# Minor bump (same major): must be exactly +1, patch reset to 0
|
|
321
|
+
elif new_min > old_min:
|
|
322
|
+
if new_min != old_min + 1:
|
|
323
|
+
errors.append(
|
|
324
|
+
f'{rel_path}: minor version skip not allowed '
|
|
325
|
+
f'({head_version_str} → {schema_version_str}); '
|
|
326
|
+
f'must increment by 1'
|
|
327
|
+
)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
# Patch bump (same major.minor): any positive increment is ok
|
|
331
|
+
|
|
332
|
+
# c) CHANGELOG entry required
|
|
333
|
+
key = (contract_type, schema_version_str)
|
|
334
|
+
if key not in changelog_entries:
|
|
335
|
+
errors.append(
|
|
336
|
+
f'{rel_path}: schema-version bumped to {schema_version_str} '
|
|
337
|
+
f'but no CHANGELOG entry "## [{contract_type} {schema_version_str}]" found'
|
|
338
|
+
)
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
# d) Major bump requires ### Removed or ### Changed (breaking)
|
|
342
|
+
if new_maj > old_maj:
|
|
343
|
+
sections = changelog_entries[key]
|
|
344
|
+
if 'Removed' not in sections and 'Changed (breaking)' not in sections:
|
|
345
|
+
errors.append(
|
|
346
|
+
f'{rel_path}: major version bump to {schema_version_str} '
|
|
347
|
+
f'requires "### Removed" or "### Changed (breaking)" '
|
|
348
|
+
f'in CHANGELOG entry (found sections: {sections or "none"})'
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
def main():
|
|
355
|
+
ap = argparse.ArgumentParser(
|
|
356
|
+
description='Validate contract file versions against frontmatter and CHANGELOG.'
|
|
357
|
+
)
|
|
358
|
+
ap.add_argument('root', nargs='?', default='.', help='Project root directory')
|
|
359
|
+
args = ap.parse_args()
|
|
360
|
+
root = Path(args.root).resolve()
|
|
361
|
+
|
|
362
|
+
if not git_available(root):
|
|
363
|
+
print(
|
|
364
|
+
'Warning: git not available or not a git repo — '
|
|
365
|
+
'skipping version comparison checks, validating format only.'
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
changelog_entries = parse_changelog(root)
|
|
369
|
+
errors: list[str] = []
|
|
370
|
+
|
|
371
|
+
for rel_path in CONTRACT_FILES:
|
|
372
|
+
validate_file(root, rel_path, changelog_entries, errors)
|
|
373
|
+
|
|
374
|
+
if errors:
|
|
375
|
+
print('Contract version validation FAILED:')
|
|
376
|
+
for e in errors:
|
|
377
|
+
print(f' FAIL: {e}')
|
|
378
|
+
sys.exit(1)
|
|
379
|
+
else:
|
|
380
|
+
print('Contract version validation passed.')
|
|
381
|
+
sys.exit(0)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
if __name__ == '__main__':
|
|
385
|
+
main()
|
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Check for required contract surfaces."""
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
import argparse, sys
|
|
4
|
+
import argparse, sys, re
|
|
5
5
|
REQUIRED=['contracts/api/api-contract.md','contracts/css/css-contract.md','contracts/env/env-contract.md','contracts/data/data-shape-contract.md','contracts/business/business-rules.md','contracts/ci/ci-gate-contract.md']
|
|
6
|
+
PLACEHOLDER_THRESHOLD=470
|
|
7
|
+
|
|
8
|
+
def meaningful_chars(text):
|
|
9
|
+
"""Return text stripped of markdown headings, blank lines, table borders, and comments."""
|
|
10
|
+
lines=text.splitlines()
|
|
11
|
+
filtered=[]
|
|
12
|
+
for line in lines:
|
|
13
|
+
s=line.strip()
|
|
14
|
+
if not s: continue
|
|
15
|
+
if s.startswith('#'): continue
|
|
16
|
+
if re.match(r'^[|\s\-:]+$', s): continue
|
|
17
|
+
if s.startswith('<!--'): continue
|
|
18
|
+
filtered.append(s)
|
|
19
|
+
return ''.join(filtered)
|
|
20
|
+
|
|
6
21
|
def main():
|
|
7
22
|
ap=argparse.ArgumentParser(); ap.add_argument('root', nargs='?', default='.')
|
|
8
23
|
args=ap.parse_args(); root=Path(args.root)
|
|
9
24
|
missing=[p for p in REQUIRED if not (root/p).exists()]
|
|
10
25
|
if missing:
|
|
11
26
|
print('Missing contract files:'); [print(f'- {p}') for p in missing]; sys.exit(1)
|
|
27
|
+
placeholders=[]
|
|
28
|
+
for p in REQUIRED:
|
|
29
|
+
text=(root/p).read_text(encoding='utf-8', errors='ignore')
|
|
30
|
+
if len(meaningful_chars(text))<PLACEHOLDER_THRESHOLD:
|
|
31
|
+
placeholders.append(p)
|
|
32
|
+
if placeholders:
|
|
33
|
+
print('Error: contracts present but appear empty: '+', '.join(placeholders))
|
|
34
|
+
print('Fill them in before relying on the gate.')
|
|
35
|
+
sys.exit(1)
|
|
12
36
|
print('All required contract files are present.')
|
|
13
37
|
if __name__=='__main__': main()
|
|
@@ -6,7 +6,9 @@ REQUIRED_COLUMNS=['name','scope','required','secret','default','example','valida
|
|
|
6
6
|
def main():
|
|
7
7
|
ap=argparse.ArgumentParser(); ap.add_argument('path', nargs='?', default='contracts/env/env-contract.md')
|
|
8
8
|
args=ap.parse_args(); p=Path(args.path)
|
|
9
|
-
if not p.exists():
|
|
9
|
+
if not p.exists():
|
|
10
|
+
print(f'Warning: {p} not found -- skipping env contract validation (file not yet created).')
|
|
11
|
+
sys.exit(0)
|
|
10
12
|
text=p.read_text(encoding='utf-8', errors='ignore').lower()
|
|
11
13
|
missing=[c for c in REQUIRED_COLUMNS if c not in text]
|
|
12
14
|
if missing: print('Env contract missing columns/terms: '+', '.join(missing)); sys.exit(1)
|