contract-driven-delivery 1.0.1 → 1.7.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 +96 -1
- 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 +6 -3
- package/assets/skill/agents/openai.yaml +0 -2
|
@@ -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)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Semantic validation of the env contract variable table.
|
|
3
|
+
|
|
4
|
+
Reads contracts/env/env-contract.md (relative to cwd), skips YAML frontmatter,
|
|
5
|
+
finds the variable table (first markdown table whose header starts with '| name |'),
|
|
6
|
+
and validates each data row for:
|
|
7
|
+
- secret=true AND non-empty default → fail (secrets must not have defaults)
|
|
8
|
+
- required=true AND secret=false AND empty default → warn only (valid scenario)
|
|
9
|
+
|
|
10
|
+
Column order expected:
|
|
11
|
+
name | scope | environments | required | secret | default | example | owner |
|
|
12
|
+
validation | restart required | failure behavior
|
|
13
|
+
"""
|
|
14
|
+
import sys
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
CONTRACT_PATH = Path('contracts/env/env-contract.md')
|
|
19
|
+
|
|
20
|
+
# Column indices (0-based)
|
|
21
|
+
COL_NAME = 0
|
|
22
|
+
COL_REQUIRED = 3
|
|
23
|
+
COL_SECRET = 4
|
|
24
|
+
COL_DEFAULT = 5
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def strip_frontmatter(text: str) -> str:
|
|
28
|
+
"""Remove YAML frontmatter delimited by --- ... ---."""
|
|
29
|
+
if text.startswith('---'):
|
|
30
|
+
end = text.find('\n---', 3)
|
|
31
|
+
if end != -1:
|
|
32
|
+
return text[end + 4:].lstrip('\n')
|
|
33
|
+
return text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_table_row(line: str) -> list[str]:
|
|
37
|
+
"""Split a markdown table row into stripped cell values."""
|
|
38
|
+
row = line.strip().strip('|')
|
|
39
|
+
return [cell.strip() for cell in row.split('|')]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_separator_row(cells: list[str]) -> bool:
|
|
43
|
+
"""Return True if this is a markdown table separator (---|---|...)."""
|
|
44
|
+
return all(re.match(r'^:?-+:?$', c) for c in cells if c)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_truthy(val: str) -> bool:
|
|
48
|
+
"""Return True if the value represents a truthy boolean in the table."""
|
|
49
|
+
return val.lower() in {'true', 'yes', '1'}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_empty_default(val: str) -> bool:
|
|
53
|
+
"""Return True if the default column represents an absent/empty value."""
|
|
54
|
+
return val in {'', '-', 'n/a', 'none', '—'}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_variable_table(lines: list[str]) -> list[tuple[int, str]]:
|
|
58
|
+
"""
|
|
59
|
+
Find all rows belonging to any table with a '| name |' header in the document.
|
|
60
|
+
This is robust to blank lines within the table and to the table header appearing
|
|
61
|
+
multiple times (e.g., when content is appended to a file that already has a header).
|
|
62
|
+
Returns list of (line_number, raw_line) for all data rows across all matching tables.
|
|
63
|
+
"""
|
|
64
|
+
# Collect all (line_index, line) pairs that start with '|'
|
|
65
|
+
# Track which lines are headers, separators, or data rows.
|
|
66
|
+
in_name_table = False
|
|
67
|
+
sep_seen = False
|
|
68
|
+
data_rows: list[tuple[int, str]] = []
|
|
69
|
+
|
|
70
|
+
for i, line in enumerate(lines):
|
|
71
|
+
stripped = line.strip()
|
|
72
|
+
|
|
73
|
+
# Blank lines inside a table: keep scanning (don't break out of table mode)
|
|
74
|
+
if not stripped:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
if not stripped.startswith('|'):
|
|
78
|
+
# Non-pipe line: if we were collecting data, pause (but don't stop searching)
|
|
79
|
+
# A new section heading might precede another occurrence of the table header.
|
|
80
|
+
# We keep `in_name_table` True so we capture data rows even after headings.
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
cells = parse_table_row(stripped)
|
|
84
|
+
if not cells:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# A header row for a '| name |' table
|
|
88
|
+
if cells[0].lower() == 'name':
|
|
89
|
+
in_name_table = True
|
|
90
|
+
sep_seen = False
|
|
91
|
+
continue # skip header row itself
|
|
92
|
+
|
|
93
|
+
if not in_name_table:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
# Separator row
|
|
97
|
+
if not sep_seen and is_separator_row(cells):
|
|
98
|
+
sep_seen = True
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# Data row
|
|
102
|
+
data_rows.append((i + 1, line))
|
|
103
|
+
|
|
104
|
+
return data_rows
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
cwd = Path('.')
|
|
109
|
+
contract = cwd / CONTRACT_PATH
|
|
110
|
+
|
|
111
|
+
if not contract.exists():
|
|
112
|
+
print(f'Env contract not found: {CONTRACT_PATH}')
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
raw = contract.read_text(encoding='utf-8', errors='ignore')
|
|
117
|
+
except OSError as e:
|
|
118
|
+
print(f'Cannot read {CONTRACT_PATH}: {e}')
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
if not raw.strip():
|
|
122
|
+
print('Env contract: file is empty.')
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
body = strip_frontmatter(raw)
|
|
126
|
+
lines = body.splitlines()
|
|
127
|
+
|
|
128
|
+
data_rows = find_variable_table(lines)
|
|
129
|
+
|
|
130
|
+
if not data_rows:
|
|
131
|
+
print('Env contract: no variable table found')
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
errors: list[str] = []
|
|
135
|
+
warnings: list[str] = []
|
|
136
|
+
|
|
137
|
+
for lineno, raw_line in data_rows:
|
|
138
|
+
cells = parse_table_row(raw_line)
|
|
139
|
+
|
|
140
|
+
# Skip entirely empty rows
|
|
141
|
+
if not any(cells):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
# Need at least name, required (col 3), secret (col 4), default (col 5)
|
|
145
|
+
if len(cells) <= COL_DEFAULT:
|
|
146
|
+
# Not enough columns to validate — skip silently
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
name = cells[COL_NAME]
|
|
150
|
+
required = is_truthy(cells[COL_REQUIRED])
|
|
151
|
+
secret = is_truthy(cells[COL_SECRET])
|
|
152
|
+
default_ = cells[COL_DEFAULT]
|
|
153
|
+
|
|
154
|
+
# Rule: secret=true AND non-empty default → fail
|
|
155
|
+
if secret and not is_empty_default(default_):
|
|
156
|
+
errors.append(
|
|
157
|
+
f'Line {lineno}: variable "{name}" is secret=true but has '
|
|
158
|
+
f'a non-empty default value "{default_}" '
|
|
159
|
+
f'(secrets must not have defaults in the contract)'
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Rule: required=true AND secret=false AND empty default → warn only
|
|
163
|
+
if required and not secret and is_empty_default(default_):
|
|
164
|
+
warnings.append(
|
|
165
|
+
f'Line {lineno}: variable "{name}" is required=true, '
|
|
166
|
+
f'secret=false, and has no default — ensure it is always set.'
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
for w in warnings:
|
|
170
|
+
print(f'Warning: {w}')
|
|
171
|
+
|
|
172
|
+
if errors:
|
|
173
|
+
print('Env semantic validation failed:')
|
|
174
|
+
for err in errors:
|
|
175
|
+
print(f' {err}')
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
|
|
178
|
+
print(f'Env semantic validation passed ({len(data_rows)} variable(s) checked).')
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == '__main__':
|
|
182
|
+
main()
|