contract-driven-delivery 1.0.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -18
- package/assets/CLAUDE.template.md +59 -3
- package/assets/agents/backend-engineer.md +43 -0
- package/assets/agents/change-classifier.md +40 -0
- package/assets/agents/ci-cd-gatekeeper.md +53 -4
- package/assets/agents/contract-reviewer.md +49 -3
- package/assets/agents/dependency-security-reviewer.md +95 -0
- package/assets/agents/e2e-resilience-engineer.md +42 -1
- package/assets/agents/frontend-engineer.md +44 -1
- package/assets/agents/monkey-test-engineer.md +40 -1
- package/assets/agents/qa-reviewer.md +52 -0
- package/assets/agents/repo-context-scanner.md +40 -0
- package/assets/agents/spec-architect.md +77 -3
- package/assets/agents/spec-drift-auditor.md +40 -0
- package/assets/agents/stress-soak-engineer.md +42 -0
- package/assets/agents/test-strategist.md +44 -1
- package/assets/agents/ui-ux-reviewer.md +41 -1
- package/assets/agents/visual-reviewer.md +41 -1
- package/assets/ci/github-actions/contract-driven-gates.yml +50 -5
- package/assets/ci-templates/bun.yml +5 -0
- package/assets/ci-templates/conda.yml +11 -0
- package/assets/ci-templates/go.yml +12 -0
- package/assets/ci-templates/npm.yml +6 -0
- package/assets/ci-templates/pip.yml +10 -0
- package/assets/ci-templates/pnpm.yml +9 -0
- package/assets/ci-templates/poetry.yml +12 -0
- package/assets/ci-templates/rust.yml +12 -0
- package/assets/ci-templates/unknown.yml +4 -0
- package/assets/ci-templates/uv.yml +12 -0
- package/assets/ci-templates/yarn.yml +6 -0
- package/assets/contracts/CHANGELOG.md +27 -0
- package/assets/contracts/api/api-contract.md +7 -0
- package/assets/contracts/business/business-rules.md +7 -0
- package/assets/contracts/ci/ci-gate-contract.md +7 -0
- package/assets/contracts/css/css-contract.md +7 -0
- package/assets/contracts/data/data-shape-contract.md +7 -0
- package/assets/contracts/env/env-contract.md +7 -0
- package/assets/hooks/pre-commit +23 -0
- package/assets/skill/SKILL.md +20 -4
- package/assets/skill/scripts/detect_project_profile.py +68 -1
- package/assets/skill/scripts/generate_change_scaffold.py +2 -2
- package/assets/skill/scripts/validate_api_semantic.py +162 -0
- package/assets/skill/scripts/validate_ci_gates.py +34 -6
- package/assets/skill/scripts/validate_contract_versions.py +385 -0
- package/assets/skill/scripts/validate_contracts.py +25 -1
- package/assets/skill/scripts/validate_env_contract.py +3 -1
- package/assets/skill/scripts/validate_env_semantic.py +182 -0
- package/assets/skill/scripts/validate_spec_traceability.py +34 -8
- package/assets/tests-templates/soak/k6-example.js +19 -0
- package/assets/tests-templates/soak/locust-example.py +21 -0
- package/assets/tests-templates/soak/soak-profile.md +16 -0
- package/assets/tests-templates/stress/artillery-example.yml +27 -0
- package/assets/tests-templates/stress/k6-example.js +22 -0
- package/assets/tests-templates/stress/load-profile.md +14 -0
- package/assets/tests-templates/stress/locust-example.py +21 -0
- package/dist/cli/index.js +593 -106
- package/package.json +7 -4
- package/assets/skill/agents/openai.yaml +0 -2
|
@@ -0,0 +1,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
|
|
7
|
-
|
|
8
|
-
|
|
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:
|
|
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")
|