shipwright-cli 2.3.1 → 3.0.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 (162) hide show
  1. package/README.md +95 -28
  2. package/completions/_shipwright +1 -1
  3. package/completions/shipwright.bash +3 -8
  4. package/completions/shipwright.fish +1 -1
  5. package/config/defaults.json +111 -0
  6. package/config/event-schema.json +81 -0
  7. package/config/policy.json +155 -2
  8. package/config/policy.schema.json +162 -1
  9. package/dashboard/coverage/coverage-summary.json +14 -0
  10. package/dashboard/public/index.html +1 -1
  11. package/dashboard/server.ts +306 -17
  12. package/dashboard/src/components/charts/bar.test.ts +79 -0
  13. package/dashboard/src/components/charts/donut.test.ts +68 -0
  14. package/dashboard/src/components/charts/pipeline-rail.test.ts +117 -0
  15. package/dashboard/src/components/charts/sparkline.test.ts +125 -0
  16. package/dashboard/src/core/api.test.ts +309 -0
  17. package/dashboard/src/core/helpers.test.ts +301 -0
  18. package/dashboard/src/core/router.test.ts +307 -0
  19. package/dashboard/src/core/router.ts +7 -0
  20. package/dashboard/src/core/sse.test.ts +144 -0
  21. package/dashboard/src/views/metrics.test.ts +186 -0
  22. package/dashboard/src/views/overview.test.ts +173 -0
  23. package/dashboard/src/views/pipelines.test.ts +183 -0
  24. package/dashboard/src/views/team.test.ts +253 -0
  25. package/dashboard/vitest.config.ts +14 -5
  26. package/docs/TIPS.md +1 -1
  27. package/docs/patterns/README.md +1 -1
  28. package/package.json +15 -5
  29. package/scripts/adapters/docker-deploy.sh +1 -1
  30. package/scripts/adapters/tmux-adapter.sh +11 -1
  31. package/scripts/adapters/wezterm-adapter.sh +1 -1
  32. package/scripts/check-version-consistency.sh +1 -1
  33. package/scripts/lib/architecture.sh +126 -0
  34. package/scripts/lib/bootstrap.sh +75 -0
  35. package/scripts/lib/compat.sh +89 -6
  36. package/scripts/lib/config.sh +91 -0
  37. package/scripts/lib/daemon-adaptive.sh +3 -3
  38. package/scripts/lib/daemon-dispatch.sh +39 -16
  39. package/scripts/lib/daemon-health.sh +1 -1
  40. package/scripts/lib/daemon-patrol.sh +24 -12
  41. package/scripts/lib/daemon-poll.sh +37 -25
  42. package/scripts/lib/daemon-state.sh +115 -23
  43. package/scripts/lib/daemon-triage.sh +30 -8
  44. package/scripts/lib/fleet-failover.sh +63 -0
  45. package/scripts/lib/helpers.sh +30 -6
  46. package/scripts/lib/pipeline-detection.sh +2 -2
  47. package/scripts/lib/pipeline-github.sh +9 -9
  48. package/scripts/lib/pipeline-intelligence.sh +85 -35
  49. package/scripts/lib/pipeline-quality-checks.sh +16 -16
  50. package/scripts/lib/pipeline-quality.sh +1 -1
  51. package/scripts/lib/pipeline-stages.sh +242 -28
  52. package/scripts/lib/pipeline-state.sh +40 -4
  53. package/scripts/lib/test-helpers.sh +247 -0
  54. package/scripts/postinstall.mjs +3 -11
  55. package/scripts/sw +10 -4
  56. package/scripts/sw-activity.sh +1 -11
  57. package/scripts/sw-adaptive.sh +109 -85
  58. package/scripts/sw-adversarial.sh +4 -14
  59. package/scripts/sw-architecture-enforcer.sh +1 -11
  60. package/scripts/sw-auth.sh +8 -17
  61. package/scripts/sw-autonomous.sh +111 -49
  62. package/scripts/sw-changelog.sh +1 -11
  63. package/scripts/sw-checkpoint.sh +144 -20
  64. package/scripts/sw-ci.sh +2 -12
  65. package/scripts/sw-cleanup.sh +13 -17
  66. package/scripts/sw-code-review.sh +16 -36
  67. package/scripts/sw-connect.sh +5 -12
  68. package/scripts/sw-context.sh +9 -26
  69. package/scripts/sw-cost.sh +6 -16
  70. package/scripts/sw-daemon.sh +75 -70
  71. package/scripts/sw-dashboard.sh +57 -17
  72. package/scripts/sw-db.sh +506 -15
  73. package/scripts/sw-decompose.sh +1 -11
  74. package/scripts/sw-deps.sh +15 -25
  75. package/scripts/sw-developer-simulation.sh +1 -11
  76. package/scripts/sw-discovery.sh +112 -30
  77. package/scripts/sw-doc-fleet.sh +7 -17
  78. package/scripts/sw-docs-agent.sh +6 -16
  79. package/scripts/sw-docs.sh +4 -12
  80. package/scripts/sw-doctor.sh +134 -43
  81. package/scripts/sw-dora.sh +11 -19
  82. package/scripts/sw-durable.sh +35 -52
  83. package/scripts/sw-e2e-orchestrator.sh +11 -27
  84. package/scripts/sw-eventbus.sh +115 -115
  85. package/scripts/sw-evidence.sh +748 -0
  86. package/scripts/sw-feedback.sh +3 -13
  87. package/scripts/sw-fix.sh +2 -20
  88. package/scripts/sw-fleet-discover.sh +1 -11
  89. package/scripts/sw-fleet-viz.sh +10 -18
  90. package/scripts/sw-fleet.sh +13 -17
  91. package/scripts/sw-github-app.sh +6 -16
  92. package/scripts/sw-github-checks.sh +1 -11
  93. package/scripts/sw-github-deploy.sh +1 -11
  94. package/scripts/sw-github-graphql.sh +2 -12
  95. package/scripts/sw-guild.sh +1 -11
  96. package/scripts/sw-heartbeat.sh +49 -12
  97. package/scripts/sw-hygiene.sh +45 -43
  98. package/scripts/sw-incident.sh +284 -67
  99. package/scripts/sw-init.sh +35 -37
  100. package/scripts/sw-instrument.sh +1 -11
  101. package/scripts/sw-intelligence.sh +362 -51
  102. package/scripts/sw-jira.sh +5 -14
  103. package/scripts/sw-launchd.sh +2 -12
  104. package/scripts/sw-linear.sh +8 -17
  105. package/scripts/sw-logs.sh +4 -12
  106. package/scripts/sw-loop.sh +641 -90
  107. package/scripts/sw-memory.sh +243 -17
  108. package/scripts/sw-mission-control.sh +2 -12
  109. package/scripts/sw-model-router.sh +73 -34
  110. package/scripts/sw-otel.sh +11 -21
  111. package/scripts/sw-oversight.sh +1 -11
  112. package/scripts/sw-patrol-meta.sh +5 -11
  113. package/scripts/sw-pipeline-composer.sh +7 -17
  114. package/scripts/sw-pipeline-vitals.sh +1 -11
  115. package/scripts/sw-pipeline.sh +478 -122
  116. package/scripts/sw-pm.sh +2 -12
  117. package/scripts/sw-pr-lifecycle.sh +203 -29
  118. package/scripts/sw-predictive.sh +16 -22
  119. package/scripts/sw-prep.sh +6 -16
  120. package/scripts/sw-ps.sh +1 -11
  121. package/scripts/sw-public-dashboard.sh +2 -12
  122. package/scripts/sw-quality.sh +77 -10
  123. package/scripts/sw-reaper.sh +1 -11
  124. package/scripts/sw-recruit.sh +15 -25
  125. package/scripts/sw-regression.sh +11 -21
  126. package/scripts/sw-release-manager.sh +19 -28
  127. package/scripts/sw-release.sh +8 -16
  128. package/scripts/sw-remote.sh +1 -11
  129. package/scripts/sw-replay.sh +48 -44
  130. package/scripts/sw-retro.sh +70 -92
  131. package/scripts/sw-review-rerun.sh +220 -0
  132. package/scripts/sw-scale.sh +109 -32
  133. package/scripts/sw-security-audit.sh +12 -22
  134. package/scripts/sw-self-optimize.sh +239 -23
  135. package/scripts/sw-session.sh +3 -13
  136. package/scripts/sw-setup.sh +8 -18
  137. package/scripts/sw-standup.sh +5 -15
  138. package/scripts/sw-status.sh +32 -23
  139. package/scripts/sw-strategic.sh +129 -13
  140. package/scripts/sw-stream.sh +1 -11
  141. package/scripts/sw-swarm.sh +76 -36
  142. package/scripts/sw-team-stages.sh +10 -20
  143. package/scripts/sw-templates.sh +4 -14
  144. package/scripts/sw-testgen.sh +3 -13
  145. package/scripts/sw-tmux-pipeline.sh +1 -19
  146. package/scripts/sw-tmux-role-color.sh +0 -10
  147. package/scripts/sw-tmux-status.sh +3 -11
  148. package/scripts/sw-tmux.sh +2 -20
  149. package/scripts/sw-trace.sh +1 -19
  150. package/scripts/sw-tracker-github.sh +0 -10
  151. package/scripts/sw-tracker-jira.sh +1 -11
  152. package/scripts/sw-tracker-linear.sh +1 -11
  153. package/scripts/sw-tracker.sh +7 -24
  154. package/scripts/sw-triage.sh +24 -34
  155. package/scripts/sw-upgrade.sh +5 -23
  156. package/scripts/sw-ux.sh +1 -19
  157. package/scripts/sw-webhook.sh +18 -32
  158. package/scripts/sw-widgets.sh +3 -21
  159. package/scripts/sw-worktree.sh +11 -27
  160. package/scripts/update-homebrew-sha.sh +67 -0
  161. package/templates/pipelines/tdd.json +72 -0
  162. package/scripts/sw-pipeline.sh.mock +0 -7
@@ -0,0 +1,748 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # ║ shipwright evidence — Machine-Verifiable Proof for Agent Deliveries ║
4
+ # ║ Browser · API · Database · CLI · Webhook · Custom collectors ║
5
+ # ║ Capture · Verify · Manifest assertions · Artifact freshness ║
6
+ # ║ Part of the Code Factory pattern for deterministic merge evidence ║
7
+ # ╚═══════════════════════════════════════════════════════════════════════════╝
8
+ set -euo pipefail
9
+ trap 'echo "ERROR: $BASH_SOURCE:$LINENO exited with status $?" >&2' ERR
10
+
11
+ VERSION="3.0.0"
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
14
+
15
+ # shellcheck source=lib/compat.sh
16
+ [[ -f "$SCRIPT_DIR/lib/compat.sh" ]] && source "$SCRIPT_DIR/lib/compat.sh"
17
+ # shellcheck source=lib/helpers.sh
18
+ [[ -f "$SCRIPT_DIR/lib/helpers.sh" ]] && source "$SCRIPT_DIR/lib/helpers.sh"
19
+ [[ -f "$SCRIPT_DIR/lib/config.sh" ]] && source "$SCRIPT_DIR/lib/config.sh"
20
+ [[ "$(type -t info 2>/dev/null)" == "function" ]] || info() { echo -e "\033[38;2;0;212;255m\033[1m▸\033[0m $*"; }
21
+ [[ "$(type -t success 2>/dev/null)" == "function" ]] || success() { echo -e "\033[38;2;74;222;128m\033[1m✓\033[0m $*"; }
22
+ [[ "$(type -t warn 2>/dev/null)" == "function" ]] || warn() { echo -e "\033[38;2;250;204;21m\033[1m⚠\033[0m $*"; }
23
+ [[ "$(type -t error 2>/dev/null)" == "function" ]] || error() { echo -e "\033[38;2;248;113;113m\033[1m✗\033[0m $*" >&2; }
24
+ if [[ "$(type -t now_iso 2>/dev/null)" != "function" ]]; then
25
+ now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
26
+ now_epoch() { date +%s; }
27
+ fi
28
+
29
+ # Cross-platform timeout: macOS lacks GNU timeout
30
+ _run_with_timeout() {
31
+ local secs="$1"; shift
32
+ if command -v gtimeout >/dev/null 2>&1; then
33
+ gtimeout "$secs" "$@"
34
+ elif command -v timeout >/dev/null 2>&1; then
35
+ timeout "$secs" "$@"
36
+ else
37
+ # Fallback: run without timeout
38
+ "$@"
39
+ fi
40
+ }
41
+
42
+ EVIDENCE_DIR="${REPO_DIR}/.claude/evidence"
43
+ MANIFEST_FILE="${EVIDENCE_DIR}/manifest.json"
44
+ POLICY_FILE="${REPO_DIR}/config/policy.json"
45
+
46
+ ensure_evidence_dir() {
47
+ mkdir -p "$EVIDENCE_DIR"
48
+ }
49
+
50
+ # ─── Policy Accessors ────────────────────────────────────────────────────────
51
+
52
+ get_collectors() {
53
+ local type_filter="${1:-}"
54
+ if [[ -f "$POLICY_FILE" ]]; then
55
+ if [[ -n "$type_filter" ]]; then
56
+ jq -c ".evidence.collectors[]? | select(.type == \"${type_filter}\")" "$POLICY_FILE" 2>/dev/null
57
+ else
58
+ jq -c '.evidence.collectors[]?' "$POLICY_FILE" 2>/dev/null
59
+ fi
60
+ fi
61
+ }
62
+
63
+ get_max_age_minutes() {
64
+ if [[ -f "$POLICY_FILE" ]]; then
65
+ jq -r '.evidence.artifactMaxAgeMinutes // 30' "$POLICY_FILE" 2>/dev/null
66
+ else
67
+ echo "30"
68
+ fi
69
+ }
70
+
71
+ get_require_fresh() {
72
+ if [[ -f "$POLICY_FILE" ]]; then
73
+ jq -r '.evidence.requireFreshArtifacts // true' "$POLICY_FILE" 2>/dev/null
74
+ else
75
+ echo "true"
76
+ fi
77
+ }
78
+
79
+ # ═════════════════════════════════════════════════════════════════════════════
80
+ # ASSERTION EVALUATION
81
+ # Checks response body against assertions defined in policy.json.
82
+ # Assertion names map to simple content checks (case-insensitive).
83
+ # Returns the count of failed assertions (0 = all passed).
84
+ # ═════════════════════════════════════════════════════════════════════════════
85
+
86
+ evaluate_assertions() {
87
+ local collector_json="$1"
88
+ local response_body="$2"
89
+ local failed=0
90
+
91
+ local assertions
92
+ assertions=$(echo "$collector_json" | jq -r '.assertions[]? // empty' 2>/dev/null)
93
+ [[ -z "$assertions" ]] && { echo "0"; return; }
94
+
95
+ while IFS= read -r assertion; do
96
+ [[ -z "$assertion" ]] && continue
97
+ local check_passed="false"
98
+
99
+ case "$assertion" in
100
+ # Common assertion patterns — map names to body content checks
101
+ page-title-visible)
102
+ echo "$response_body" | grep -qi '<title>' && check_passed="true" ;;
103
+ websocket-connected|websocket-active)
104
+ echo "$response_body" | grep -qi 'websocket\|ws://' && check_passed="true" ;;
105
+ status-ok)
106
+ echo "$response_body" | grep -qi '"status"' && check_passed="true" ;;
107
+ response-has-version)
108
+ echo "$response_body" | grep -qi '"version"' && check_passed="true" ;;
109
+ valid-json-output|valid-json)
110
+ echo "$response_body" | jq empty 2>/dev/null && check_passed="true" ;;
111
+ has-pipeline-state)
112
+ echo "$response_body" | grep -qi 'pipeline\|status\|stage' && check_passed="true" ;;
113
+ stage-list-rendered)
114
+ echo "$response_body" | grep -qi 'stage\|pipeline' && check_passed="true" ;;
115
+ progress-indicator-visible)
116
+ echo "$response_body" | grep -qi 'progress\|percent\|stage' && check_passed="true" ;;
117
+ schema-valid|db-accessible)
118
+ echo "$response_body" | grep -qi 'schema\|version\|ok\|healthy' && check_passed="true" ;;
119
+ *)
120
+ # Generic: check if the assertion name (with hyphens as spaces) appears in body
121
+ local search_term="${assertion//-/ }"
122
+ echo "$response_body" | grep -qi "$search_term" && check_passed="true" ;;
123
+ esac
124
+
125
+ if [[ "$check_passed" != "true" ]]; then
126
+ warn "[assertion] '${assertion}' not satisfied" >&2
127
+ failed=$((failed + 1))
128
+ fi
129
+ done <<< "$assertions"
130
+
131
+ echo "$failed"
132
+ }
133
+
134
+ # ═════════════════════════════════════════════════════════════════════════════
135
+ # TYPE-SPECIFIC COLLECTORS
136
+ # Each returns a JSON evidence record written to EVIDENCE_DIR/<name>.json
137
+ # ═════════════════════════════════════════════════════════════════════════════
138
+
139
+ # ─── Browser: HTTP page load against a URL path ──────────────────────────────
140
+
141
+ collect_browser() {
142
+ local name="$1"
143
+ local collector_json="$2"
144
+
145
+ local entrypoint base_url url
146
+ entrypoint=$(echo "$collector_json" | jq -r '.entrypoint // "/"')
147
+ base_url=$(echo "$collector_json" | jq -r '.baseUrl // ""')
148
+ [[ -z "$base_url" ]] && base_url="http://localhost:$(_config_get_int "dashboard.port" 8767)"
149
+ url="${base_url}${entrypoint}"
150
+
151
+ info "[browser] ${name}: ${url}"
152
+
153
+ local http_status="0"
154
+ local response_size="0"
155
+ local response_body=""
156
+
157
+ if command -v curl >/dev/null 2>&1; then
158
+ local tmpfile
159
+ tmpfile=$(mktemp "${TMPDIR:-/tmp}/sw-evidence-browser.XXXXXX")
160
+ http_status=$(curl -s -o "$tmpfile" -w "%{http_code}" --max-time 30 "$url" 2>/dev/null || echo "0")
161
+ if [[ -f "$tmpfile" ]]; then
162
+ response_size=$(wc -c < "$tmpfile" 2>/dev/null || echo "0")
163
+ response_body=$(cat "$tmpfile" 2>/dev/null || echo "")
164
+ rm -f "$tmpfile"
165
+ fi
166
+ fi
167
+
168
+ local passed="false"
169
+ [[ "$http_status" -ge 200 && "$http_status" -lt 400 ]] && passed="true"
170
+
171
+ # Evaluate assertions against response body (if status check passed)
172
+ local assertion_failures=0
173
+ if [[ "$passed" == "true" && -n "$response_body" ]]; then
174
+ assertion_failures=$(evaluate_assertions "$collector_json" "$response_body")
175
+ [[ "$assertion_failures" -gt 0 ]] && passed="false"
176
+ fi
177
+
178
+ write_evidence_record "$name" "browser" "$passed" \
179
+ "$(jq -n --arg url "$url" --argjson status "$http_status" --argjson size "$response_size" \
180
+ --argjson assertion_failures "$assertion_failures" \
181
+ '{url: $url, http_status: $status, response_size: $size, assertion_failures: $assertion_failures}')"
182
+ }
183
+
184
+ # ─── API: REST/GraphQL endpoint verification ─────────────────────────────────
185
+
186
+ collect_api() {
187
+ local name="$1"
188
+ local collector_json="$2"
189
+
190
+ local url method expected_status headers_json body timeout
191
+ url=$(echo "$collector_json" | jq -r '.url // ""')
192
+ method=$(echo "$collector_json" | jq -r '.method // "GET"')
193
+ expected_status=$(echo "$collector_json" | jq -r '.expectedStatus // 200')
194
+ body=$(echo "$collector_json" | jq -r '.body // ""')
195
+ timeout=$(echo "$collector_json" | jq -r '.timeout // 30')
196
+
197
+ if [[ -z "$url" ]]; then
198
+ local base_url entrypoint
199
+ base_url=$(echo "$collector_json" | jq -r '.baseUrl // ""')
200
+ [[ -z "$base_url" ]] && base_url="http://localhost:$(_config_get_int "dashboard.port" 8767)"
201
+ entrypoint=$(echo "$collector_json" | jq -r '.entrypoint // "/"')
202
+ url="${base_url}${entrypoint}"
203
+ fi
204
+
205
+ info "[api] ${name}: ${method} ${url}"
206
+
207
+ local http_status="0"
208
+ local response_size="0"
209
+ local response_body=""
210
+ local content_type=""
211
+
212
+ if command -v curl >/dev/null 2>&1; then
213
+ local tmpfile header_file
214
+ tmpfile=$(mktemp "${TMPDIR:-/tmp}/sw-evidence-api.XXXXXX")
215
+ header_file=$(mktemp "${TMPDIR:-/tmp}/sw-evidence-api-headers.XXXXXX")
216
+ local curl_args=(-s -o "$tmpfile" -D "$header_file" -w "%{http_code}" -X "$method" --max-time "$timeout")
217
+
218
+ # Add custom headers
219
+ local custom_headers
220
+ custom_headers=$(echo "$collector_json" | jq -r '.headers // {} | to_entries[] | "-H\n\(.key): \(.value)"' 2>/dev/null || true)
221
+ if [[ -n "$custom_headers" ]]; then
222
+ while IFS= read -r line; do
223
+ [[ "$line" == "-H" ]] && continue
224
+ curl_args+=(-H "$line")
225
+ done <<< "$custom_headers"
226
+ fi
227
+
228
+ # Add body for POST/PUT/PATCH
229
+ if [[ -n "$body" && "$method" != "GET" && "$method" != "HEAD" ]]; then
230
+ curl_args+=(-d "$body")
231
+ fi
232
+
233
+ http_status=$(curl "${curl_args[@]}" "$url" 2>/dev/null || echo "0")
234
+
235
+ if [[ -f "$tmpfile" ]]; then
236
+ response_size=$(wc -c < "$tmpfile" 2>/dev/null || echo "0")
237
+ response_body=$(cat "$tmpfile" 2>/dev/null || echo "")
238
+ rm -f "$tmpfile"
239
+ fi
240
+ if [[ -f "$header_file" ]]; then
241
+ content_type=$(grep -i "^content-type:" "$header_file" 2>/dev/null | head -1 | sed 's/^[^:]*: *//' | tr -d '\r' || echo "")
242
+ rm -f "$header_file"
243
+ fi
244
+ rm -f "$tmpfile"
245
+ fi
246
+
247
+ local passed="false"
248
+ [[ "$http_status" -eq "$expected_status" ]] && passed="true"
249
+
250
+ # Check if response is valid JSON when content-type suggests it
251
+ local valid_json="false"
252
+ if echo "$response_body" | jq empty 2>/dev/null; then
253
+ valid_json="true"
254
+ fi
255
+
256
+ # Evaluate assertions against response body (if status check passed)
257
+ local assertion_failures=0
258
+ if [[ "$passed" == "true" && -n "$response_body" ]]; then
259
+ assertion_failures=$(evaluate_assertions "$collector_json" "$response_body")
260
+ [[ "$assertion_failures" -gt 0 ]] && passed="false"
261
+ fi
262
+
263
+ write_evidence_record "$name" "api" "$passed" \
264
+ "$(jq -n --arg url "$url" --arg method "$method" \
265
+ --argjson status "$http_status" --argjson expected "$expected_status" \
266
+ --argjson size "$response_size" --arg content_type "$content_type" \
267
+ --arg valid_json "$valid_json" --argjson assertion_failures "$assertion_failures" \
268
+ '{url: $url, method: $method, http_status: $status, expected_status: $expected, response_size: $size, content_type: $content_type, valid_json: ($valid_json == "true"), assertion_failures: $assertion_failures}')"
269
+ }
270
+
271
+ # ─── CLI: Execute a command and check exit code ──────────────────────────────
272
+
273
+ collect_cli() {
274
+ local name="$1"
275
+ local collector_json="$2"
276
+
277
+ local command_str expected_exit timeout
278
+ command_str=$(echo "$collector_json" | jq -r '.command // ""')
279
+ expected_exit=$(echo "$collector_json" | jq -r '.expectedExitCode // 0')
280
+ timeout=$(echo "$collector_json" | jq -r '.timeout // 60')
281
+
282
+ if [[ -z "$command_str" ]]; then
283
+ error "[cli] ${name}: no command specified"
284
+ write_evidence_record "$name" "cli" "false" '{"error": "no command specified"}'
285
+ return
286
+ fi
287
+
288
+ info "[cli] ${name}: ${command_str}"
289
+
290
+ local exit_code=0
291
+ local output=""
292
+ local start_time
293
+ start_time=$(date +%s)
294
+
295
+ output=$(cd "$REPO_DIR" && _run_with_timeout "$timeout" bash -c "$command_str" 2>&1) || exit_code=$?
296
+
297
+ local elapsed=$(( $(date +%s) - start_time ))
298
+
299
+ local passed="false"
300
+ [[ "$exit_code" -eq "$expected_exit" ]] && passed="true"
301
+
302
+ local valid_json="false"
303
+ if echo "$output" | jq empty 2>/dev/null; then
304
+ valid_json="true"
305
+ fi
306
+
307
+ # Evaluate assertions against command output (if exit code check passed)
308
+ local assertion_failures=0
309
+ if [[ "$passed" == "true" && -n "$output" ]]; then
310
+ assertion_failures=$(evaluate_assertions "$collector_json" "$output")
311
+ [[ "$assertion_failures" -gt 0 ]] && passed="false"
312
+ fi
313
+
314
+ local output_size=${#output}
315
+ # Truncate output for the evidence record (keep first 2000 chars)
316
+ local output_preview="${output:0:2000}"
317
+
318
+ write_evidence_record "$name" "cli" "$passed" \
319
+ "$(jq -n --arg cmd "$command_str" --argjson exit_code "$exit_code" \
320
+ --argjson expected "$expected_exit" --argjson elapsed "$elapsed" \
321
+ --argjson output_size "$output_size" --arg valid_json "$valid_json" \
322
+ --arg output_preview "$output_preview" \
323
+ '{command: $cmd, exit_code: $exit_code, expected_exit_code: $expected, elapsed_seconds: $elapsed, output_size: $output_size, valid_json: ($valid_json == "true"), output_preview: $output_preview}')"
324
+ }
325
+
326
+ # ─── Database: Schema/migration check via command ─────────────────────────────
327
+
328
+ collect_database() {
329
+ local name="$1"
330
+ local collector_json="$2"
331
+
332
+ local command_str expected_exit timeout
333
+ command_str=$(echo "$collector_json" | jq -r '.command // ""')
334
+ expected_exit=$(echo "$collector_json" | jq -r '.expectedExitCode // 0')
335
+ timeout=$(echo "$collector_json" | jq -r '.timeout // 30')
336
+
337
+ if [[ -z "$command_str" ]]; then
338
+ error "[database] ${name}: no command specified"
339
+ write_evidence_record "$name" "database" "false" '{"error": "no command specified"}'
340
+ return
341
+ fi
342
+
343
+ info "[database] ${name}: ${command_str}"
344
+
345
+ local exit_code=0
346
+ local output=""
347
+ output=$(cd "$REPO_DIR" && _run_with_timeout "$timeout" bash -c "$command_str" 2>&1) || exit_code=$?
348
+
349
+ local passed="false"
350
+ [[ "$exit_code" -eq "$expected_exit" ]] && passed="true"
351
+
352
+ # Evaluate assertions against command output (if exit code check passed)
353
+ local assertion_failures=0
354
+ if [[ "$passed" == "true" && -n "$output" ]]; then
355
+ assertion_failures=$(evaluate_assertions "$collector_json" "$output")
356
+ [[ "$assertion_failures" -gt 0 ]] && passed="false"
357
+ fi
358
+
359
+ local output_preview="${output:0:2000}"
360
+
361
+ write_evidence_record "$name" "database" "$passed" \
362
+ "$(jq -n --arg cmd "$command_str" --argjson exit_code "$exit_code" \
363
+ --argjson expected "$expected_exit" --arg output_preview "$output_preview" \
364
+ --argjson assertion_failures "$assertion_failures" \
365
+ '{command: $cmd, exit_code: $exit_code, expected_exit_code: $expected, output_preview: $output_preview, assertion_failures: $assertion_failures}')"
366
+ }
367
+
368
+ # ─── Webhook: Issue a callback and verify response ───────────────────────────
369
+
370
+ collect_webhook() {
371
+ local name="$1"
372
+ local collector_json="$2"
373
+
374
+ local url method expected_status body timeout
375
+ url=$(echo "$collector_json" | jq -r '.url // ""')
376
+ method=$(echo "$collector_json" | jq -r '.method // "POST"')
377
+ expected_status=$(echo "$collector_json" | jq -r '.expectedStatus // 200')
378
+ body=$(echo "$collector_json" | jq -r '.body // "{}"')
379
+ timeout=$(echo "$collector_json" | jq -r '.timeout // 15')
380
+
381
+ if [[ -z "$url" ]]; then
382
+ error "[webhook] ${name}: no URL specified"
383
+ write_evidence_record "$name" "webhook" "false" '{"error": "no URL specified"}'
384
+ return
385
+ fi
386
+
387
+ info "[webhook] ${name}: ${method} ${url}"
388
+
389
+ local http_status="0"
390
+ local response_body=""
391
+
392
+ if command -v curl >/dev/null 2>&1; then
393
+ local tmpfile
394
+ tmpfile=$(mktemp "${TMPDIR:-/tmp}/sw-evidence-webhook.XXXXXX")
395
+ http_status=$(curl -s -o "$tmpfile" -w "%{http_code}" -X "$method" \
396
+ -H "Content-Type: application/json" -d "$body" \
397
+ --max-time "$timeout" "$url" 2>/dev/null || echo "0")
398
+ if [[ -f "$tmpfile" ]]; then
399
+ response_body=$(cat "$tmpfile" 2>/dev/null || echo "")
400
+ rm -f "$tmpfile"
401
+ fi
402
+ fi
403
+
404
+ local passed="false"
405
+ [[ "$http_status" -eq "$expected_status" ]] && passed="true"
406
+
407
+ write_evidence_record "$name" "webhook" "$passed" \
408
+ "$(jq -n --arg url "$url" --arg method "$method" \
409
+ --argjson status "$http_status" --argjson expected "$expected_status" \
410
+ '{url: $url, method: $method, http_status: $status, expected_status: $expected}')"
411
+ }
412
+
413
+ # ─── Custom: User-defined script execution ───────────────────────────────────
414
+
415
+ collect_custom() {
416
+ local name="$1"
417
+ local collector_json="$2"
418
+
419
+ local command_str expected_exit timeout
420
+ command_str=$(echo "$collector_json" | jq -r '.command // ""')
421
+ expected_exit=$(echo "$collector_json" | jq -r '.expectedExitCode // 0')
422
+ timeout=$(echo "$collector_json" | jq -r '.timeout // 60')
423
+
424
+ if [[ -z "$command_str" ]]; then
425
+ error "[custom] ${name}: no command specified"
426
+ write_evidence_record "$name" "custom" "false" '{"error": "no command specified"}'
427
+ return
428
+ fi
429
+
430
+ info "[custom] ${name}: ${command_str}"
431
+
432
+ local exit_code=0
433
+ local output=""
434
+ output=$(cd "$REPO_DIR" && _run_with_timeout "$timeout" bash -c "$command_str" 2>&1) || exit_code=$?
435
+
436
+ local passed="false"
437
+ [[ "$exit_code" -eq "$expected_exit" ]] && passed="true"
438
+
439
+ local output_preview="${output:0:2000}"
440
+
441
+ write_evidence_record "$name" "custom" "$passed" \
442
+ "$(jq -n --arg cmd "$command_str" --argjson exit_code "$exit_code" \
443
+ --argjson expected "$expected_exit" --arg output_preview "$output_preview" \
444
+ '{command: $cmd, exit_code: $exit_code, expected_exit_code: $expected, output_preview: $output_preview}')"
445
+ }
446
+
447
+ # ═════════════════════════════════════════════════════════════════════════════
448
+ # EVIDENCE RECORD WRITER
449
+ # ═════════════════════════════════════════════════════════════════════════════
450
+
451
+ write_evidence_record() {
452
+ local name="$1"
453
+ local type="$2"
454
+ local passed="$3"
455
+ local details="$4"
456
+
457
+ local evidence_file="${EVIDENCE_DIR}/${name}.json"
458
+ local captured_at
459
+ captured_at=$(now_iso)
460
+
461
+ jq -n --arg name "$name" --arg type "$type" --arg passed "$passed" \
462
+ --arg captured_at "$captured_at" --argjson captured_epoch "$(now_epoch)" \
463
+ --argjson details "$details" \
464
+ '{
465
+ name: $name,
466
+ type: $type,
467
+ passed: ($passed == "true"),
468
+ captured_at: $captured_at,
469
+ captured_epoch: $captured_epoch,
470
+ details: $details
471
+ }' > "$evidence_file"
472
+
473
+ if [[ "$passed" == "true" ]]; then
474
+ success "[${type}] ${name}: passed"
475
+ else
476
+ error "[${type}] ${name}: failed"
477
+ fi
478
+ }
479
+
480
+ # ═════════════════════════════════════════════════════════════════════════════
481
+ # COMMANDS
482
+ # ═════════════════════════════════════════════════════════════════════════════
483
+
484
+ cmd_capture() {
485
+ local type_filter="${1:-}"
486
+ ensure_evidence_dir
487
+
488
+ info "Capturing evidence${type_filter:+ (type: ${type_filter})}..."
489
+
490
+ local collectors
491
+ collectors=$(get_collectors "$type_filter")
492
+
493
+ if [[ -z "$collectors" ]]; then
494
+ warn "No evidence collectors defined in policy — nothing to capture"
495
+ return 0
496
+ fi
497
+
498
+ local total=0
499
+ local passed=0
500
+ local failed=0
501
+ local manifest_entries="[]"
502
+
503
+ while IFS= read -r collector; do
504
+ [[ -z "$collector" ]] && continue
505
+
506
+ local cname ctype
507
+ cname=$(echo "$collector" | jq -r '.name')
508
+ ctype=$(echo "$collector" | jq -r '.type')
509
+
510
+ case "$ctype" in
511
+ browser) collect_browser "$cname" "$collector" ;;
512
+ api) collect_api "$cname" "$collector" ;;
513
+ cli) collect_cli "$cname" "$collector" ;;
514
+ database) collect_database "$cname" "$collector" ;;
515
+ webhook) collect_webhook "$cname" "$collector" ;;
516
+ custom) collect_custom "$cname" "$collector" ;;
517
+ *) warn "Unknown collector type: ${ctype} (skipping ${cname})" ; continue ;;
518
+ esac
519
+
520
+ total=$((total + 1))
521
+
522
+ local evidence_file="${EVIDENCE_DIR}/${cname}.json"
523
+ local cpassed="false"
524
+ if [[ -f "$evidence_file" ]]; then
525
+ cpassed=$(jq -r '.passed' "$evidence_file" 2>/dev/null || echo "false")
526
+ fi
527
+
528
+ if [[ "$cpassed" == "true" ]]; then
529
+ passed=$((passed + 1))
530
+ else
531
+ failed=$((failed + 1))
532
+ fi
533
+
534
+ manifest_entries=$(echo "$manifest_entries" | jq \
535
+ --arg name "$cname" --arg type "$ctype" --arg file "$evidence_file" --arg passed "$cpassed" \
536
+ '. + [{"name": $name, "type": $type, "file": $file, "passed": ($passed == "true")}]')
537
+
538
+ done <<< "$collectors"
539
+
540
+ # Write manifest
541
+ jq -n --arg captured_at "$(now_iso)" --argjson captured_epoch "$(now_epoch)" \
542
+ --argjson total "$total" --argjson passed "$passed" --argjson failed "$failed" \
543
+ --argjson collectors "$manifest_entries" \
544
+ '{
545
+ captured_at: $captured_at,
546
+ captured_epoch: $captured_epoch,
547
+ collector_count: $total,
548
+ passed: $passed,
549
+ failed: $failed,
550
+ collectors: $collectors
551
+ }' > "$MANIFEST_FILE"
552
+
553
+ echo ""
554
+ if [[ "$failed" -eq 0 ]]; then
555
+ success "All ${total} collector(s) passed"
556
+ else
557
+ warn "${passed}/${total} passed, ${failed} failed"
558
+ fi
559
+
560
+ emit_event "evidence.captured" "total=${total}" "passed=${passed}" "failed=${failed}" "type=${type_filter:-all}"
561
+ }
562
+
563
+ cmd_verify() {
564
+ ensure_evidence_dir
565
+
566
+ if [[ ! -f "$MANIFEST_FILE" ]]; then
567
+ error "No evidence manifest found — run 'capture' first"
568
+ return 1
569
+ fi
570
+
571
+ info "Verifying evidence..."
572
+
573
+ local all_passed="true"
574
+ local checked=0
575
+ local failed=0
576
+
577
+ # Check freshness
578
+ local require_fresh
579
+ require_fresh=$(get_require_fresh)
580
+ local max_age_minutes
581
+ max_age_minutes=$(get_max_age_minutes)
582
+ local max_age_seconds=$((max_age_minutes * 60))
583
+
584
+ local captured_epoch
585
+ captured_epoch=$(jq -r '.captured_epoch' "$MANIFEST_FILE" 2>/dev/null || echo "0")
586
+ local current_epoch
587
+ current_epoch=$(now_epoch)
588
+ local age_seconds=$((current_epoch - captured_epoch))
589
+
590
+ if [[ "$require_fresh" == "true" && "$age_seconds" -gt "$max_age_seconds" ]]; then
591
+ error "Evidence is stale: captured ${age_seconds}s ago (max: ${max_age_seconds}s)"
592
+ all_passed="false"
593
+ failed=$((failed + 1))
594
+ else
595
+ local age_minutes=$((age_seconds / 60))
596
+ info "Evidence age: ${age_minutes}m (max: ${max_age_minutes}m)"
597
+ fi
598
+
599
+ # Check all collectors in manifest
600
+ local collector_count
601
+ collector_count=$(jq -r '.collector_count' "$MANIFEST_FILE" 2>/dev/null || echo "0")
602
+
603
+ local collectors_json
604
+ collectors_json=$(jq -c '.collectors[]?' "$MANIFEST_FILE" 2>/dev/null)
605
+
606
+ while IFS= read -r entry; do
607
+ [[ -z "$entry" ]] && continue
608
+ checked=$((checked + 1))
609
+
610
+ local cname ctype cpassed
611
+ cname=$(echo "$entry" | jq -r '.name')
612
+ ctype=$(echo "$entry" | jq -r '.type')
613
+ cpassed=$(echo "$entry" | jq -r '.passed')
614
+
615
+ if [[ "$cpassed" != "true" ]]; then
616
+ error "Collector '${cname}' (${ctype}) failed"
617
+ all_passed="false"
618
+ failed=$((failed + 1))
619
+ else
620
+ success "Collector '${cname}' (${ctype}) passed"
621
+ fi
622
+ done <<< "$collectors_json"
623
+
624
+ echo ""
625
+ if [[ "$all_passed" == "true" ]]; then
626
+ success "All ${checked} evidence check(s) passed"
627
+ emit_event "evidence.verified" "total=${checked}" "result=pass"
628
+ return 0
629
+ else
630
+ error "${failed} of ${checked} evidence check(s) failed"
631
+ emit_event "evidence.verified" "total=${checked}" "result=fail" "failed=${failed}"
632
+ return 1
633
+ fi
634
+ }
635
+
636
+ cmd_pre_pr() {
637
+ local type_filter="${1:-}"
638
+ info "Running pre-PR evidence check..."
639
+ cmd_capture "$type_filter"
640
+ cmd_verify
641
+ }
642
+
643
+ cmd_status() {
644
+ ensure_evidence_dir
645
+
646
+ if [[ ! -f "$MANIFEST_FILE" ]]; then
647
+ warn "No evidence manifest found"
648
+ return 0
649
+ fi
650
+
651
+ local captured_at collector_count passed_count failed_count
652
+ captured_at=$(jq -r '.captured_at' "$MANIFEST_FILE" 2>/dev/null || echo "unknown")
653
+ collector_count=$(jq -r '.collector_count' "$MANIFEST_FILE" 2>/dev/null || echo "0")
654
+ passed_count=$(jq -r '.passed' "$MANIFEST_FILE" 2>/dev/null || echo "0")
655
+ failed_count=$(jq -r '.failed' "$MANIFEST_FILE" 2>/dev/null || echo "0")
656
+
657
+ echo "Evidence Status"
658
+ echo "━━━━━━━━━━━━━━━"
659
+ echo "Manifest: ${MANIFEST_FILE}"
660
+ echo "Captured at: ${captured_at}"
661
+ echo "Collectors: ${collector_count} (${passed_count} passed, ${failed_count} failed)"
662
+ echo ""
663
+
664
+ # Group by type
665
+ local types
666
+ types=$(jq -r '.collectors[].type' "$MANIFEST_FILE" 2>/dev/null | sort -u)
667
+
668
+ while IFS= read -r type; do
669
+ [[ -z "$type" ]] && continue
670
+ echo " ${type}:"
671
+ jq -r ".collectors[] | select(.type == \"${type}\") | \" \\(if .passed then \"✓\" else \"✗\" end) \\(.name)\"" "$MANIFEST_FILE" 2>/dev/null || true
672
+ done <<< "$types"
673
+ }
674
+
675
+ cmd_list_types() {
676
+ echo "Supported evidence types:"
677
+ echo ""
678
+ echo " browser HTTP page load — verifies UI renders correctly"
679
+ echo " api REST/GraphQL endpoint — verifies response status, body, content-type"
680
+ echo " database Schema/migration check — verifies DB integrity via command"
681
+ echo " cli Command execution — verifies exit code and output"
682
+ echo " webhook Callback verification — verifies webhook endpoint responds"
683
+ echo " custom User-defined script — any verification logic"
684
+ echo ""
685
+ echo "Configure collectors in config/policy.json under the 'evidence' section."
686
+ }
687
+
688
+ show_help() {
689
+ cat << 'EOF'
690
+ Usage: shipwright evidence <command> [args]
691
+
692
+ Commands:
693
+ capture [type] Capture evidence (optionally filter by type)
694
+ verify Verify evidence manifest and freshness
695
+ pre-pr [type] Capture + verify (run before PR creation)
696
+ status Show current evidence state grouped by type
697
+ types List supported evidence types
698
+
699
+ Evidence Types:
700
+ browser HTTP page load verification
701
+ api REST/GraphQL endpoint checks
702
+ database Schema/migration integrity
703
+ cli Command execution and exit code
704
+ webhook Callback endpoint verification
705
+ custom User-defined verification scripts
706
+
707
+ Evidence collectors are defined in config/policy.json under the
708
+ 'evidence.collectors' array. Each collector specifies a type,
709
+ target, and assertions.
710
+
711
+ Part of the Code Factory pattern for machine-verifiable proof.
712
+ EOF
713
+ }
714
+
715
+ main() {
716
+ local subcommand="${1:-help}"
717
+ shift || true
718
+
719
+ case "$subcommand" in
720
+ capture)
721
+ cmd_capture "$@"
722
+ ;;
723
+ verify)
724
+ cmd_verify "$@"
725
+ ;;
726
+ pre-pr)
727
+ cmd_pre_pr "$@"
728
+ ;;
729
+ status)
730
+ cmd_status
731
+ ;;
732
+ types)
733
+ cmd_list_types
734
+ ;;
735
+ help|--help|-h)
736
+ show_help
737
+ ;;
738
+ *)
739
+ error "Unknown subcommand: $subcommand"
740
+ show_help
741
+ return 1
742
+ ;;
743
+ esac
744
+ }
745
+
746
+ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
747
+ main "$@"
748
+ fi