contract-driven-delivery 1.0.1 → 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 +96 -1
  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 +6 -3
  58. 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 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)
@@ -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()