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,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()
@@ -2,17 +2,43 @@
2
2
  """Coarse traceability check for a change folder."""
3
3
  from pathlib import Path
4
4
  import argparse, sys
5
- REQUIRED=['classification.md','test-plan.md','ci-gates.md','tasks.md']
6
- def main():
7
- ap=argparse.ArgumentParser(); ap.add_argument('change_dir')
8
- args=ap.parse_args(); d=Path(args.change_dir)
9
- if not d.exists(): print(f'{d} not found'); sys.exit(1)
5
+ REQUIRED=['change-classification.md','test-plan.md','ci-gates.md','tasks.md']
6
+ def check_change_dir(d):
7
+ """Check one change directory. Returns list of error strings (empty = pass)."""
8
+ errors=[]
10
9
  missing=[f for f in REQUIRED if not (d/f).exists()]
11
- if missing: print('Missing required change artifacts: '+', '.join(missing)); sys.exit(1)
10
+ if missing:
11
+ errors.append(f'{d.name}: missing required artifacts: '+', '.join(missing))
12
+ return errors
12
13
  text='\n'.join((d/f).read_text(encoding='utf-8', errors='ignore') for f in REQUIRED)
13
14
  warnings=[]
14
15
  for term in ['contract','test','ci','gate']:
15
16
  if term not in text.lower(): warnings.append(term)
16
- if warnings: print('Warning: weak traceability terms: '+', '.join(warnings))
17
- print('Change traceability basic validation passed.')
17
+ if warnings: print(f'Warning: {d.name}: weak traceability terms: '+', '.join(warnings))
18
+ print(f'Change traceability basic validation passed: {d.name}')
19
+ return errors
20
+ def main():
21
+ ap=argparse.ArgumentParser(); ap.add_argument('change_dir', nargs='?', default=None)
22
+ args=ap.parse_args()
23
+ if args.change_dir is not None:
24
+ d=Path(args.change_dir)
25
+ if not d.exists(): print(f'{d} not found'); sys.exit(1)
26
+ errors=check_change_dir(d)
27
+ if errors: [print(e) for e in errors]; sys.exit(1)
28
+ sys.exit(0)
29
+ # No argument: scan specs/changes/*/
30
+ changes_root=Path('specs/changes')
31
+ if not changes_root.exists() or not any(True for _ in changes_root.iterdir() if changes_root.exists()):
32
+ print('Warning: specs/changes/ not found or empty -- skipping spec traceability validation.')
33
+ sys.exit(0)
34
+ subdirs=[p for p in changes_root.iterdir() if p.is_dir()]
35
+ if not subdirs:
36
+ print('Warning: specs/changes/ is empty -- skipping spec traceability validation.')
37
+ sys.exit(0)
38
+ all_errors=[]
39
+ for d in sorted(subdirs):
40
+ all_errors.extend(check_change_dir(d))
41
+ if all_errors:
42
+ [print(e) for e in all_errors]; sys.exit(1)
43
+ sys.exit(0)
18
44
  if __name__=='__main__': main()
@@ -0,0 +1,19 @@
1
+ import http from 'k6/http';
2
+ import { check, sleep } from 'k6';
3
+
4
+ export const options = {
5
+ vus: 5, // low constant load
6
+ duration: '4h', // long-running
7
+ thresholds: {
8
+ http_req_duration: ['p(95)<800'], // looser threshold for soak
9
+ http_req_failed: ['rate<0.005'], // tighter error rate over long run
10
+ },
11
+ };
12
+
13
+ const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
14
+
15
+ export default function () {
16
+ const res = http.get(`${BASE_URL}/api/health`);
17
+ check(res, { 'status is 200': (r) => r.status === 200 });
18
+ sleep(2); // slower cadence; we are looking for leaks, not throughput
19
+ }
@@ -0,0 +1,21 @@
1
+ """Locust example: soak profile (long-running, low load).
2
+
3
+ Run: locust -f tests/templates/soak/locust-example.py \
4
+ --headless -u 5 -r 1 --run-time 4h \
5
+ --host http://localhost:3000
6
+
7
+ Soak focuses on stability over time, not peak throughput.
8
+ Watch for: memory growth, connection exhaustion, queue backlog,
9
+ latency drift, error rate creep.
10
+ """
11
+ from locust import HttpUser, task, between
12
+
13
+
14
+ class SoakUser(HttpUser):
15
+ wait_time = between(2.0, 5.0)
16
+
17
+ @task
18
+ def health_check(self):
19
+ with self.client.get("/api/health", catch_response=True) as r:
20
+ if r.status_code != 200:
21
+ r.failure(f"unexpected status {r.status_code}")
@@ -13,3 +13,19 @@
13
13
  ## Failure Thresholds
14
14
 
15
15
  ## Artifact Retention
16
+
17
+ ## Runner Config (REQUIRED — fill before running)
18
+
19
+ - runner: k6 | locust | artillery
20
+ - config file: tests/soak/<scenario>.<ext>
21
+ - target environment:
22
+ - constant load (VUs or arrival rate):
23
+ - duration: <!-- soak runs are typically 2h–24h+ -->
24
+ - pass criteria (must include leak/drift signals):
25
+ - memory growth: <!-- e.g., RSS < 1.2× baseline after 4h -->
26
+ - latency drift: <!-- e.g., p95 within ±10% of hour-1 baseline -->
27
+ - error rate: <!-- e.g., < 0.5% sustained -->
28
+ - artifacts:
29
+
30
+ ### Reference templates
31
+ See `tests/templates/soak/k6-example.js` or `locust-example.py`.
@@ -0,0 +1,27 @@
1
+ # Run: npx artillery run tests/templates/stress/artillery-example.yml
2
+ config:
3
+ target: "http://localhost:3000"
4
+ phases:
5
+ - duration: 30
6
+ arrivalRate: 5
7
+ name: warm-up
8
+ - duration: 120
9
+ arrivalRate: 20
10
+ name: sustain
11
+ - duration: 30
12
+ arrivalRate: 5
13
+ name: cool-down
14
+ ensure:
15
+ p95: 500 # 95% under 500ms
16
+ maxErrorRate: 1 # < 1% errors
17
+
18
+ scenarios:
19
+ - name: health-then-list
20
+ flow:
21
+ - get:
22
+ url: "/api/health"
23
+ expect:
24
+ - statusCode: 200
25
+ - think: 1
26
+ - get:
27
+ url: "/api/items"
@@ -0,0 +1,22 @@
1
+ import http from 'k6/http';
2
+ import { check, sleep } from 'k6';
3
+
4
+ export const options = {
5
+ stages: [
6
+ { duration: '30s', target: 20 }, // ramp up to 20 VUs
7
+ { duration: '2m', target: 20 }, // hold 20 VUs
8
+ { duration: '30s', target: 0 }, // ramp down
9
+ ],
10
+ thresholds: {
11
+ http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
12
+ http_req_failed: ['rate<0.01'], // <1% errors
13
+ },
14
+ };
15
+
16
+ const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
17
+
18
+ export default function () {
19
+ const res = http.get(`${BASE_URL}/api/health`);
20
+ check(res, { 'status is 200': (r) => r.status === 200 });
21
+ sleep(1);
22
+ }
@@ -13,3 +13,17 @@
13
13
  ## Metrics
14
14
 
15
15
  ## Thresholds
16
+
17
+ ## Runner Config (REQUIRED — fill before running)
18
+
19
+ - runner: k6 | locust | artillery <!-- pick one -->
20
+ - config file: tests/stress/<scenario>.<ext> <!-- e.g., tests/stress/checkout-load.js -->
21
+ - target environment: <!-- staging | preprod | local -->
22
+ - VUs / arrival rate: <!-- e.g., 50 VUs, or 20 req/s -->
23
+ - duration: <!-- e.g., 3m -->
24
+ - pass criteria: <!-- must reference an SLO, e.g., p95 < 500ms, error rate < 1% -->
25
+ - artifacts: <!-- where stdout/HTML report is stored, e.g., ci/artifacts/stress/<run-id>/ -->
26
+
27
+ ### Reference templates
28
+ See `tests/templates/stress/k6-example.js`, `locust-example.py`, or
29
+ `artillery-example.yml` for runner-specific starting points.
@@ -0,0 +1,21 @@
1
+ """Locust example: stress profile.
2
+
3
+ Run: locust -f tests/templates/stress/locust-example.py \
4
+ --headless -u 50 -r 5 --run-time 3m \
5
+ --host http://localhost:3000
6
+ """
7
+ from locust import HttpUser, task, between
8
+
9
+
10
+ class StressUser(HttpUser):
11
+ wait_time = between(0.5, 2.0)
12
+
13
+ @task(3)
14
+ def health_check(self):
15
+ with self.client.get("/api/health", catch_response=True) as r:
16
+ if r.status_code != 200:
17
+ r.failure(f"unexpected status {r.status_code}")
18
+
19
+ @task(1)
20
+ def list_items(self):
21
+ self.client.get("/api/items")