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.
Files changed (58) hide show
  1. package/README.md +113 -18
  2. package/assets/CLAUDE.template.md +59 -3
  3. package/assets/agents/backend-engineer.md +43 -0
  4. package/assets/agents/change-classifier.md +40 -0
  5. package/assets/agents/ci-cd-gatekeeper.md +53 -4
  6. package/assets/agents/contract-reviewer.md +49 -3
  7. package/assets/agents/dependency-security-reviewer.md +95 -0
  8. package/assets/agents/e2e-resilience-engineer.md +42 -1
  9. package/assets/agents/frontend-engineer.md +44 -1
  10. package/assets/agents/monkey-test-engineer.md +40 -1
  11. package/assets/agents/qa-reviewer.md +52 -0
  12. package/assets/agents/repo-context-scanner.md +40 -0
  13. package/assets/agents/spec-architect.md +77 -3
  14. package/assets/agents/spec-drift-auditor.md +40 -0
  15. package/assets/agents/stress-soak-engineer.md +42 -0
  16. package/assets/agents/test-strategist.md +44 -1
  17. package/assets/agents/ui-ux-reviewer.md +41 -1
  18. package/assets/agents/visual-reviewer.md +41 -1
  19. package/assets/ci/github-actions/contract-driven-gates.yml +50 -5
  20. package/assets/ci-templates/bun.yml +5 -0
  21. package/assets/ci-templates/conda.yml +11 -0
  22. package/assets/ci-templates/go.yml +12 -0
  23. package/assets/ci-templates/npm.yml +6 -0
  24. package/assets/ci-templates/pip.yml +10 -0
  25. package/assets/ci-templates/pnpm.yml +9 -0
  26. package/assets/ci-templates/poetry.yml +12 -0
  27. package/assets/ci-templates/rust.yml +12 -0
  28. package/assets/ci-templates/unknown.yml +4 -0
  29. package/assets/ci-templates/uv.yml +12 -0
  30. package/assets/ci-templates/yarn.yml +6 -0
  31. package/assets/contracts/CHANGELOG.md +27 -0
  32. package/assets/contracts/api/api-contract.md +7 -0
  33. package/assets/contracts/business/business-rules.md +7 -0
  34. package/assets/contracts/ci/ci-gate-contract.md +7 -0
  35. package/assets/contracts/css/css-contract.md +7 -0
  36. package/assets/contracts/data/data-shape-contract.md +7 -0
  37. package/assets/contracts/env/env-contract.md +7 -0
  38. package/assets/hooks/pre-commit +23 -0
  39. package/assets/skill/SKILL.md +20 -4
  40. package/assets/skill/scripts/detect_project_profile.py +68 -1
  41. package/assets/skill/scripts/generate_change_scaffold.py +2 -2
  42. package/assets/skill/scripts/validate_api_semantic.py +162 -0
  43. package/assets/skill/scripts/validate_ci_gates.py +34 -6
  44. package/assets/skill/scripts/validate_contract_versions.py +385 -0
  45. package/assets/skill/scripts/validate_contracts.py +25 -1
  46. package/assets/skill/scripts/validate_env_contract.py +3 -1
  47. package/assets/skill/scripts/validate_env_semantic.py +182 -0
  48. package/assets/skill/scripts/validate_spec_traceability.py +34 -8
  49. package/assets/tests-templates/soak/k6-example.js +19 -0
  50. package/assets/tests-templates/soak/locust-example.py +21 -0
  51. package/assets/tests-templates/soak/soak-profile.md +16 -0
  52. package/assets/tests-templates/stress/artillery-example.yml +27 -0
  53. package/assets/tests-templates/stress/k6-example.js +22 -0
  54. package/assets/tests-templates/stress/load-profile.md +14 -0
  55. package/assets/tests-templates/stress/locust-example.py +21 -0
  56. package/dist/cli/index.js +593 -106
  57. package/package.json +7 -4
  58. 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 main():
7
- ap=argparse.ArgumentParser(); ap.add_argument('path')
8
- args=ap.parse_args(); p=Path(args.path)
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: print('ci-gates missing terms: '+', '.join(missing)); sys.exit(1)
13
- print('CI gates basic validation passed.')
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(): print(f'{p} not found'); sys.exit(1)
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)