diff --git a/scripts/epic-execute-lib/contract-harness.sh b/scripts/epic-execute-lib/contract-harness.sh new file mode 100644 index 000000000..79e4ecfa0 --- /dev/null +++ b/scripts/epic-execute-lib/contract-harness.sh @@ -0,0 +1,380 @@ +#!/bin/bash +# +# BMAD Epic Execute - Contract Harness Preflight Module +# +# A project may declare a contract-validation harness (contract-harness.yaml) +# that describes how to bring up a SAMPLE/TEST environment and verify that the +# API and database contracts hold (the proper API is called and data lands in +# the right place). +# +# This module does NOT execute the per-story contract checks. It validates at +# STARTUP that the system has everything it needs to run them - credentials, +# commands, and files - so a misconfigured harness fails fast (or, in a dry +# run, produces an exit-code-honest readiness report) instead of blowing up +# mid-epic. +# +# The user never hand-maintains a checklist: prerequisites are inferred from the +# harness commands themselves (env var references, executables, file paths), +# with an optional `requires:` block for anything inference cannot see. +# +# Usage: sourced by epic-execute.sh +# + +# Set true by contract_preflight when a required prerequisite is missing. +# epic-execute uses this to fail the run / dry-run exit code. +PREFLIGHT_FAILED=false + +# ============================================================================= +# Harness Discovery +# ============================================================================= + +# Locate the harness file (project root, then docs/). Echoes the path or "". +find_contract_harness() { + local candidate + for candidate in \ + "$PROJECT_ROOT/contract-harness.yaml" \ + "$PROJECT_ROOT/contract-harness.yml" \ + "$PROJECT_ROOT/docs/contract-harness.yaml" \ + "$PROJECT_ROOT/docs/contract-harness.yml"; do + if [ -f "$candidate" ]; then + echo "$candidate" + return 0 + fi + done + echo "" + return 0 +} + +# ============================================================================= +# Prerequisite Inference +# ============================================================================= + +# Emit every command string declared in the harness (setup, start, teardown, +# and the datastore verify command), one per line. +_harness_commands() { + local h="$1" + yq '(.environment.setup // [])[]' "$h" 2>/dev/null + yq '.environment.start.command // ""' "$h" 2>/dev/null + yq '(.environment.teardown // [])[]' "$h" 2>/dev/null + yq '.datastore.verify_command // ""' "$h" 2>/dev/null +} + +# Derive required environment variables: references inside command strings +# ($VAR / ${VAR}), the datastore url_env value (itself a var name), and any +# explicit requires.env entries. +_derive_env_vars() { + local h="$1" + { + _harness_commands "$h" | grep -oE '\$\{?[A-Za-z_][A-Za-z0-9_]*\}?' | tr -d '${}' + yq '.datastore.url_env // ""' "$h" 2>/dev/null + yq '(.requires.env // [])[]' "$h" 2>/dev/null + } | sed '/^$/d' | sort -u +} + +# Derive required executables: the first non-assignment token of each command, +# plus any explicit requires.commands entries. +_derive_commands() { + local h="$1" + { + _harness_commands "$h" | while IFS= read -r cmd; do + [ -z "$cmd" ] && continue + # shellcheck disable=SC2086 + set -- $cmd + while [ $# -gt 0 ]; do + case "$1" in + *=*) shift ;; # skip leading VAR=value assignments + *) echo "$1"; break ;; + esac + done + done + yq '(.requires.commands // [])[]' "$h" 2>/dev/null + } | sed '/^$/d' | sort -u +} + +# Derive referenced files: path-like tokens in command strings (best effort), +# plus any explicit requires.files entries. +_derive_files() { + local h="$1" + { + _harness_commands "$h" | tr ' ' '\n' | grep -E '/|\.(ya?ml|json|toml|sh|env)$' 2>/dev/null + yq '(.requires.files // [])[]' "$h" 2>/dev/null + } | sed '/^$/d' | sort -u +} + +# ============================================================================= +# Safety Guard +# ============================================================================= + +# Warn if the declared datastore connection looks like a real/production target. +# Contract validation writes data, so it must point at a throwaway/test store. +_check_datastore_safety() { + local h="$1" + local url_env + url_env=$(yq '.datastore.url_env // ""' "$h" 2>/dev/null) + [ -z "$url_env" ] && return 0 + + case "$url_env" in + DATABASE_URL|*PROD*|*PRODUCTION*) + log_warn " ! datastore.url_env='$url_env' looks production-scoped - use a TEST-only variable" ;; + esac + + local val="${!url_env:-}" + if [ -n "$val" ]; then + case "$val" in + *localhost*|*127.0.0.1*|*test*) : ;; + *) log_warn " ! $url_env does not look local/test-scoped - contract validation must never run against a real database" ;; + esac + fi + return 0 +} + +# ============================================================================= +# Preflight (presence checks + readiness report) +# ============================================================================= + +# Validate that everything needed to run the harness is present. +# Arguments: +# $1 - path to the harness file +# Returns: 0 if ready, 1 if a required prerequisite is missing. +contract_preflight() { + local h="$1" + [ -z "$h" ] && return 0 + + log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log "Contract Harness Preflight" + log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log "Harness: $h" + + local check="✓" cross="✗" + local missing=0 + + # yq is required to parse the harness + if ! command -v yq >/dev/null 2>&1; then + log_error " $cross yq is required to parse the contract harness (install yq)" + PREFLIGHT_FAILED=true + return 1 + fi + + # Basic schema sanity (advisory) + local start_cmd + start_cmd=$(yq '.environment.start.command // ""' "$h" 2>/dev/null) + [ -z "$start_cmd" ] && log_warn " ! harness declares no environment.start.command" + + # 1. Credentials / environment variables + local envs + envs=$(_derive_env_vars "$h") + if [ -n "$envs" ]; then + log "Credentials / environment variables:" + while IFS= read -r v; do + [ -z "$v" ] && continue + if [ -n "${!v:-}" ]; then + echo " $check $v" + else + echo " $cross $v (not set)" + missing=$((missing + 1)) + fi + done <<< "$envs" + fi + + # 2. Required executables + local cmds + cmds=$(_derive_commands "$h") + if [ -n "$cmds" ]; then + log "Required commands:" + while IFS= read -r c; do + [ -z "$c" ] && continue + if command -v "$c" >/dev/null 2>&1; then + echo " $check $c" + else + echo " $cross $c (not on PATH)" + missing=$((missing + 1)) + fi + done <<< "$cmds" + fi + + # 3. Referenced files + local files + files=$(_derive_files "$h") + if [ -n "$files" ]; then + log "Referenced files:" + while IFS= read -r f; do + [ -z "$f" ] && continue + local path="$f" + case "$f" in /*) path="$f" ;; *) path="$PROJECT_ROOT/$f" ;; esac + if [ -e "$path" ]; then + echo " $check $f" + else + echo " $cross $f (not found)" + missing=$((missing + 1)) + fi + done <<< "$files" + fi + + # 4. Safety guard on datastore connection + _check_datastore_safety "$h" + + # 5. Optional deep connectivity smoke (boots the sample environment) + if [ "${PREFLIGHT_DEEP:-false}" = true ]; then + if [ "$missing" -gt 0 ]; then + log_warn "Skipping deep connectivity smoke - presence checks failed first" + elif ! contract_preflight_deep "$h"; then + missing=$((missing + 1)) + fi + fi + + if [ "$missing" -gt 0 ]; then + log_error "Contract preflight: $missing required prerequisite(s) missing" + PREFLIGHT_FAILED=true + return 1 + fi + + log_success "Contract preflight passed - ready to validate contracts" + return 0 +} + +# ============================================================================= +# Deep Connectivity Smoke (opt-in: --preflight-deep) +# ============================================================================= + +# Run teardown commands (best effort, always safe to call). +_harness_teardown() { + local h="$1" + while IFS= read -r cmd; do + [ -z "$cmd" ] && continue + log " teardown: $cmd" + ( cd "$PROJECT_ROOT" && eval "$cmd" ) >>"$LOG_FILE" 2>&1 || true + done < <(yq '(.environment.teardown // [])[]' "$h" 2>/dev/null) +} + +# Actually bring the sample environment up, check readiness, then tear it down. +# Executes the project's own commands (same trust level as package.json scripts). +# Arguments: +# $1 - path to the harness file +# Returns: 0 on success, 1 on any failure. +contract_preflight_deep() { + local h="$1" + log "Deep connectivity smoke (booting the sample environment)..." + + # Run setup commands + local rc=0 cmd + while IFS= read -r cmd; do + [ -z "$cmd" ] && continue + log " setup: $cmd" + if ! ( cd "$PROJECT_ROOT" && eval "$cmd" ) >>"$LOG_FILE" 2>&1; then + log_error " setup failed: $cmd" + rc=1 + break + fi + done < <(yq '(.environment.setup // [])[]' "$h" 2>/dev/null) + + if [ "$rc" -ne 0 ]; then + _harness_teardown "$h" + return 1 + fi + + # Start the app in the background (if a start command is declared) + local start_cmd start_pid="" + start_cmd=$(yq '.environment.start.command // ""' "$h" 2>/dev/null) + if [ -n "$start_cmd" ]; then + log " start: $start_cmd" + ( cd "$PROJECT_ROOT" && eval "$start_cmd" ) >>"$LOG_FILE" 2>&1 & + start_pid=$! + fi + + # Poll the readiness URL (if declared) + local ready_url timeout ok=1 + ready_url=$(yq '.environment.start.ready.url // ""' "$h" 2>/dev/null) + timeout=$(yq '.environment.start.ready.timeout_seconds // 30' "$h" 2>/dev/null) + if [ -n "$ready_url" ]; then + ok=0 + local waited=0 + while [ "$waited" -lt "$timeout" ]; do + if curl -sf -o /dev/null "$ready_url" 2>/dev/null; then + ok=1 + break + fi + sleep 2 + waited=$((waited + 2)) + done + if [ "$ok" -eq 1 ]; then + log_success " ready: $ready_url" + else + log_error " not ready after ${timeout}s: $ready_url" + fi + fi + + # Stop the app and tear down + [ -n "$start_pid" ] && kill "$start_pid" 2>/dev/null || true + _harness_teardown "$h" + + [ "$ok" -eq 1 ] && return 0 || return 1 +} + +# ============================================================================= +# Scaffolder (--init-harness) +# ============================================================================= + +# Write a commented contract-harness.yaml template to the project root. +init_contract_harness() { + local target="$PROJECT_ROOT/contract-harness.yaml" + if [ -e "$target" ]; then + log_warn "Harness already exists: $target (not overwriting)" + return 0 + fi + + cat > "$target" <<'YAML' +# Contract validation harness for epic-execute. +# +# Declares how to bring up a SAMPLE/TEST environment and verify that the API and +# database contracts hold. epic-execute validates at startup that it has +# everything needed to run this - run `epic-execute --dry-run` to get an +# exit-code-honest readiness report (great as a CI gate). +version: 1 + +environment: + # Commands to provision the sample environment (DB, migrations, seed data). + setup: + - docker compose -f docker-compose.test.yml up -d db + # - npm run migrate:test + # - npm run seed:test + # How to start the app under test. + start: + command: npm run start:test + ready: + url: http://localhost:3000/health + timeout_seconds: 60 + # Always run to clean up. + teardown: + - docker compose -f docker-compose.test.yml down -v + +api: + base_url: http://localhost:3000 + +# How to verify data landed "in the right place". Prefer a command (no DB +# credentials needed); the system passes the table/where as arguments. +datastore: + verify_command: "npm run db:assert --" + # OR direct query via a TEST-scoped env var (never a real DATABASE_URL): + # url_env: TEST_DATABASE_URL + +# Optional explicit prerequisites. The system also INFERS these from the +# commands above (env var references, executables, and file paths), so you only +# need to list things inference cannot see (e.g. an API token used at call time). +requires: + env: [] + commands: [] + files: [] + +# Contract cases: call the API and assert the response + persistence. +# (Validated by preflight now; executed by the per-story contract gate.) +cases: + - name: "example: create persists a row" + request: { method: POST, path: /api/example, body: { name: "x" } } + expect: { status: 201, body_contains: { name: "x" } } + verify_persistence: { table: example, where: { name: "x" }, exists: true } +YAML + + log_success "Created harness template: $target" + log "Edit it, then run a dry run to validate readiness." + return 0 +} diff --git a/scripts/epic-execute-lib/design-phase.sh b/scripts/epic-execute-lib/design-phase.sh index c01a8b1fe..cabc7ed0f 100644 --- a/scripts/epic-execute-lib/design-phase.sh +++ b/scripts/epic-execute-lib/design-phase.sh @@ -78,6 +78,117 @@ build_repo_map() { "$lang_label" "${top:-(none)}" "${sources:-(none detected)}" } +# ============================================================================= +# Feature Domain Classification (frontend / backend / fullstack) +# ============================================================================= + +# Auto-detect the feature domain for a story so the design phase can apply the +# right planning lens. Resolution order (all automatic): +# 1. An explicit Type:/Domain:/Feature-Type: field in the story file +# 2. Heuristic keyword scoring of the story content +# 3. Default to "fullstack" (the superset) when ambiguous - fail safe so we +# never under-plan a story. +# Returns one of: frontend | backend | fullstack +classify_feature_domain() { + local story_file="$1" + + # 1. Explicit metadata field in the story (highest confidence, still auto) + local meta + meta=$(grep -iE '^(Type|Domain|Feature[ _-]?Type)[[:space:]]*:' "$story_file" 2>/dev/null | head -1 | tr '[:upper:]' '[:lower:]') + case "$meta" in + *fullstack*|*full-stack*|*full\ stack*) echo "fullstack"; return ;; + *frontend*|*front-end*|*ui*|*ux*) echo "frontend"; return ;; + *backend*|*back-end*|*api*|*server*) echo "backend"; return ;; + esac + + # 2. Heuristic keyword scoring on story content + local content + content=$(cat "$story_file" 2>/dev/null) + + local fe be + fe=$(printf '%s' "$content" | grep -ioE '\b(component|components|page|pages|screen|view|button|form|modal|dialog|css|tailwind|stylesheet|layout|responsive|render|UI|UX|accessibility|a11y|frontend|front-end|click|hover|route|router|navigation|nav)\b' 2>/dev/null | wc -l | tr -d ' ') + be=$(printf '%s' "$content" | grep -ioE '\b(endpoint|endpoints|API|REST|GraphQL|controller|service|repository|schema|migration|migrations|database|query|queries|SQL|model|models|auth|authentication|authorization|token|queue|job|cron|webhook|backend|back-end|server)\b' 2>/dev/null | wc -l | tr -d ' ') + + fe=${fe:-0}; be=${be:-0} + local threshold=2 + + # One side clearly dominant -> that domain; otherwise fail safe to fullstack + if [ "$fe" -ge "$threshold" ] && [ "$be" -lt "$threshold" ]; then + echo "frontend" + elif [ "$be" -ge "$threshold" ] && [ "$fe" -lt "$threshold" ]; then + echo "backend" + else + echo "fullstack" + fi +} + +# Build the planning-lens prompt block for a domain. The lens tells the planner +# which domain-specific questions it MUST answer (states, a11y, API contract, +# error handling, etc). Fullstack injects both lenses. +# Arguments: +# $1 - domain (frontend | backend | fullstack) +build_lens_block() { + local domain="$1" + + local fe_lens="## Frontend Planning Lens + +This story involves UI. Your plan MUST address these in the \"frontend\" object: +- Component breakdown: which components are new vs reused from the design system +- Every interactive component's states: loading, empty, error, success, disabled +- Accessibility: keyboard navigation, ARIA, focus management, color contrast +- Responsive behavior across breakpoints +- Which existing design-system components/tokens to reuse (do not reinvent)" + + local be_lens="## Backend Planning Lens + +This story involves backend logic. Your plan MUST address these in the \"backend\" object: +- API contract: method, path, request/response shape, and status codes +- Data model and any migrations (and whether they are reversible) +- Error handling and failure modes for each state-changing operation +- Concurrency / idempotency / transactions where state changes +- Observability: what to log and which metrics to emit +- Backward compatibility / versioning" + + case "$domain" in + frontend) printf '%s\n' "$fe_lens" ;; + backend) printf '%s\n' "$be_lens" ;; + *) printf '%s\n\n%s\n\n%s\n' \ + "This story spans BOTH the UI and backend tiers - address both lenses." \ + "$fe_lens" "$be_lens" ;; + esac +} + +# Build the domain-specific JSON schema fragment to inject into the plan schema. +# Arguments: +# $1 - domain (frontend | backend | fullstack) +build_domain_schema() { + local domain="$1" + + local fe_schema=" \"frontend\": { + \"components\": [{\"name\": \"...\", \"new_or_existing\": \"new|existing\", \"states\": [\"loading\",\"empty\",\"error\",\"success\",\"disabled\"]}], + \"user_flows\": [\"...\"], + \"accessibility\": [\"...\"], + \"responsive\": [\"...\"], + \"design_system_usage\": [\"...\"] + }," + + local be_schema=" \"backend\": { + \"api_contract\": [{\"method\": \"...\", \"path\": \"...\", \"request\": \"...\", \"response\": \"...\", \"status_codes\": [\"...\"]}], + \"data_model\": [\"...\"], + \"migrations\": [\"...\"], + \"error_handling\": [\"...\"], + \"concurrency\": [\"...\"], + \"observability\": [\"...\"], + \"backward_compatibility\": [\"...\"] + }," + + case "$domain" in + frontend) printf '%s\n' "$fe_schema" ;; + backend) printf '%s\n' "$be_schema" ;; + *) printf '%s\n%s\n' "$fe_schema" "$be_schema" ;; + esac +} + # ============================================================================= # Design Phase Functions # ============================================================================= @@ -127,8 +238,18 @@ execute_design_phase() { repo_map=$(build_repo_map) fi + # Auto-detect the feature domain and build the matching planning lens + + # schema fragment (frontend / backend / fullstack). Fails safe to fullstack. + local domain + domain=$(classify_feature_domain "$story_file") + log "Design domain for $story_id: $domain" + local lens_block + lens_block=$(build_lens_block "$domain") + local domain_schema + domain_schema=$(build_domain_schema "$domain") + if [ "$DRY_RUN" = true ]; then - echo "[DRY RUN] Would execute design phase for $story_id" + echo "[DRY RUN] Would execute design phase for $story_id (domain: $domain)" return 0 fi @@ -198,17 +319,24 @@ Follow existing patterns rather than introducing new ones. $repo_map +## Feature Domain: $domain + +$lens_block + ${revision_block}## Required Output Output your implementation plan as a single JSON result block. Map EVERY acceptance criterion in the story to the files/functions that will implement it - the \"ac\" field must use the exact AC identifier from the story (e.g. -\"AC1\", \"AC2\"). +\"AC1\", \"AC2\"). Set \"feature_type\" to \"$domain\" (correct it only if the +story clearly belongs to a different domain) and fill the matching domain +object(s). \`\`\`json { \"status\": \"COMPLETE\", \"story_id\": \"$story_id\", + \"feature_type\": \"$domain\", \"summary\": \"\", \"files_to_modify\": [ {\"path\": \"\", \"action\": \"create|modify\", \"purpose\": \"\"} @@ -222,6 +350,7 @@ it - the \"ac\" field must use the exact AC identifier from the story (e.g. \"acceptance_criteria_mapping\": [ {\"ac\": \"AC1\", \"covered_by\": \"\"} ], +$domain_schema \"risks\": [ {\"risk\": \"\", \"mitigation\": \"\"} ], @@ -278,13 +407,24 @@ DESIGN COMPLETE: $story_id" return 1 fi + # Prefer the model's emitted feature_type (it has seen the code) over + # the heuristic; fall back to the heuristic domain. + local effective_domain="$domain" + if [ -n "$json" ] && type get_result_feature_type >/dev/null 2>&1; then + local model_ft + model_ft=$(get_result_feature_type "$json" | tr '[:upper:]' '[:lower:]') + case "$model_ft" in + frontend|backend|fullstack) effective_domain="$model_ft" ;; + esac + fi + # Critic disabled or no attempts budgeted - accept the first plan if [ "${SKIP_DESIGN_CRITIC:-false}" = true ] || [ "$max_attempts" -le 0 ]; then break fi - # Run the critic against the plan - run_design_critic "$story_file" "$story_id" "$arch_file" "$LAST_DESIGN" + # Run the critic against the plan (domain-aware) + run_design_critic "$story_file" "$story_id" "$arch_file" "$LAST_DESIGN" "$effective_domain" local verdict=$? if [ "$verdict" -ne 1 ]; then @@ -323,6 +463,9 @@ DESIGN COMPLETE: $story_id" # Validate that every acceptance criterion is mapped (advisory warning). validate_design_coverage "$story_file" "$story_id" "$json" + # Validate domain-specific completeness (advisory; the critic enforces). + validate_domain_completeness "$story_id" "$effective_domain" "$json" + # Save to decision log if type append_to_decision_log >/dev/null 2>&1; then append_to_decision_log "DESIGN" "$story_id" "$LAST_DESIGN" @@ -333,36 +476,52 @@ DESIGN COMPLETE: $story_id" } # Run a fresh-context critic pass over a proposed design plan (#4). -# The critic checks two things: (a) does the plan map every acceptance -# criterion, and (b) does it conform to the architecture. Gaps are stored in -# DESIGN_CRITIC_GAPS for feedback into a regeneration pass. +# The critic checks: (a) does the plan map every acceptance criterion, (b) does +# it conform to the architecture, and (c) is it complete for its feature domain. +# Gaps are stored in DESIGN_CRITIC_GAPS for feedback into a regeneration pass. # Arguments: # $1 - story_file path # $2 - story_id # $3 - architecture file path (may be empty) # $4 - the proposed plan (JSON or text) +# $5 - feature domain (frontend | backend | fullstack) # Returns: 0 approved, 1 needs revision, 2 unclear run_design_critic() { local story_file="$1" local story_id="$2" local arch_file="$3" local plan="$4" + local domain="${5:-fullstack}" DESIGN_CRITIC_GAPS="" local story_contents story_contents=$(cat "$story_file") + # Domain-specific completeness checks the critic must enforce + local domain_checks="" + case "$domain" in + frontend) domain_checks="- Every interactive component enumerates ALL of its states (loading, empty, error, success, disabled) +- Accessibility is addressed (keyboard navigation, ARIA, focus management, contrast) +- Responsive behavior is specified" ;; + backend) domain_checks="- Every state-changing operation has an explicit error path AND defined status codes +- Data-model / migration impact is covered (and migration reversibility noted) +- Concurrency / idempotency is addressed where state changes" ;; + *) domain_checks="- (Frontend) every interactive component enumerates ALL states (loading/empty/error/success/disabled); accessibility and responsive behavior are addressed +- (Backend) every state-changing operation has an explicit error path and defined status codes; data-model/migration impact is covered" ;; + esac + local critic_prompt="You are a skeptical senior engineer reviewing an implementation PLAN before any code is written. ## Your Task -Critique the proposed plan for story: $story_id +Critique the proposed plan for story: $story_id (feature domain: $domain) You are reviewing a PLAN, not code. Be rigorous. Decide whether the plan: 1. Maps EVERY acceptance criterion in the story to concrete files/functions 2. Conforms to the project's architecture 3. Is concrete and actionable (no vague hand-waving) +4. Is COMPLETE for its feature domain (see Domain Completeness below) ## Story @@ -380,6 +539,11 @@ $story_contents $plan +## Domain Completeness (feature domain: $domain) + +Treat any of the following that is missing as a NEEDS_REVISION gap: +$domain_checks + ## Required Output Output a single JSON result block: @@ -394,8 +558,9 @@ Output a single JSON result block: } \`\`\` -Use APPROVED only if the plan covers every acceptance criterion and conforms to -the architecture. Otherwise use NEEDS_REVISION and list specific, actionable gaps. +Use APPROVED only if the plan covers every acceptance criterion, conforms to the +architecture, AND is complete for its feature domain. Otherwise use +NEEDS_REVISION and list specific, actionable gaps. ## Completion Signal @@ -480,6 +645,54 @@ validate_design_coverage() { fi } +# Validate domain-specific completeness of the plan (advisory; the critic is the +# enforcing gate). Warns + records a metric for the most common omissions: +# frontend components missing their states, and backend APIs without an error +# path. Skips cleanly without a JSON plan or jq. +# Arguments: +# $1 - story_id +# $2 - feature domain (frontend | backend | fullstack) +# $3 - JSON plan (may be empty) +validate_domain_completeness() { + local story_id="$1" + local domain="$2" + local json="$3" + + if [ -z "$json" ] || ! command -v jq >/dev/null 2>&1; then + return 0 + fi + + # Frontend: every interactive component should enumerate its states + if [ "$domain" = "frontend" ] || [ "$domain" = "fullstack" ]; then + local comp_count states_missing + comp_count=$(echo "$json" | jq '[.frontend.components[]?] | length' 2>/dev/null || echo 0) + comp_count=$(echo "$comp_count" | tr -d '[:space:]'); [ -z "$comp_count" ] && comp_count=0 + if [ "$comp_count" -gt 0 ]; then + states_missing=$(echo "$json" | jq '[.frontend.components[]? | select((.states | length) == 0)] | length' 2>/dev/null || echo 0) + states_missing=$(echo "$states_missing" | tr -d '[:space:]'); [ -z "$states_missing" ] && states_missing=0 + if [ "$states_missing" -gt 0 ]; then + log_warn "Design: $states_missing frontend component(s) missing states for $story_id" + type add_metrics_issue >/dev/null 2>&1 && add_metrics_issue "$story_id" "design_domain_incomplete" "$states_missing FE component(s) missing states" + fi + fi + fi + + # Backend: an API contract without any error handling is a red flag + if [ "$domain" = "backend" ] || [ "$domain" = "fullstack" ]; then + local api_count err_count + api_count=$(echo "$json" | jq '[.backend.api_contract[]?] | length' 2>/dev/null || echo 0) + api_count=$(echo "$api_count" | tr -d '[:space:]'); [ -z "$api_count" ] && api_count=0 + err_count=$(echo "$json" | jq '[.backend.error_handling[]?] | length' 2>/dev/null || echo 0) + err_count=$(echo "$err_count" | tr -d '[:space:]'); [ -z "$err_count" ] && err_count=0 + if [ "$api_count" -gt 0 ] && [ "$err_count" -eq 0 ]; then + log_warn "Design: backend API planned without error handling for $story_id" + type add_metrics_issue >/dev/null 2>&1 && add_metrics_issue "$story_id" "design_domain_incomplete" "Backend API without error_handling" + fi + fi + + return 0 +} + # Persist a design plan to a per-story file under DESIGN_DIR. # Arguments: # $1 - story_id @@ -535,6 +748,15 @@ build_planned_test_files_context() { return fi + # Domain-aware hint on which kinds of tests to emphasize (#7 + domain) + local feature_type test_hint="" + feature_type=$(echo "$design" | jq -r '.feature_type // empty' 2>/dev/null || echo "") + case "$feature_type" in + frontend) test_hint="This is a frontend feature: emphasize component, interaction, and accessibility tests (plus visual regression where applicable)." ;; + backend) test_hint="This is a backend feature: emphasize unit, integration, contract, and migration tests." ;; + fullstack) test_hint="This is a fullstack feature: cover both UI (component/interaction/a11y) and backend (unit/integration/contract) tests." ;; + esac + cat << EOF ## Planned Test Files (from design phase) @@ -542,6 +764,8 @@ build_planned_test_files_context() { The design phase already identified the intended test files below. Align your specifications with these paths and reuse them; only introduce a new test file when a scenario genuinely isn't covered here, and call out any deviation. +${test_hint:+ +$test_hint} $files diff --git a/scripts/epic-execute-lib/json-output.sh b/scripts/epic-execute-lib/json-output.sh index 6fe666936..52b5dd6ec 100644 --- a/scripts/epic-execute-lib/json-output.sh +++ b/scripts/epic-execute-lib/json-output.sh @@ -133,6 +133,25 @@ get_result_story_id() { fi } +# Get the feature_type field from a JSON result (design phase) +# Arguments: +# $1 - JSON string (optional, uses LAST_JSON_RESULT if not provided) +# Returns: frontend | backend | fullstack (or empty if not present) +get_result_feature_type() { + local json="${1:-$LAST_JSON_RESULT}" + + if [ -z "$json" ]; then + echo "" + return 1 + fi + + if command -v jq >/dev/null 2>&1; then + echo "$json" | jq -r '.feature_type // empty' + else + echo "$json" | grep -oE '"feature_type":\s*"[^"]+"' | sed 's/.*"\([^"]*\)"$/\1/' + fi +} + # Get the summary field from a JSON result # Arguments: # $1 - JSON string (optional, uses LAST_JSON_RESULT if not provided) diff --git a/scripts/epic-execute.sh b/scripts/epic-execute.sh index 892db3cca..7ebc0076a 100755 --- a/scripts/epic-execute.sh +++ b/scripts/epic-execute.sh @@ -132,6 +132,7 @@ LIB_DIR="$SCRIPT_DIR/epic-execute-lib" [ -f "$LIB_DIR/design-phase.sh" ] && source "$LIB_DIR/design-phase.sh" [ -f "$LIB_DIR/json-output.sh" ] && source "$LIB_DIR/json-output.sh" [ -f "$LIB_DIR/tdd-flow.sh" ] && source "$LIB_DIR/tdd-flow.sh" +[ -f "$LIB_DIR/contract-harness.sh" ] && source "$LIB_DIR/contract-harness.sh" STORIES_DIR="$PROJECT_ROOT/docs/stories" SPRINT_ARTIFACTS_DIR="$PROJECT_ROOT/docs/sprint-artifacts" @@ -941,6 +942,11 @@ OPTIONS: --skip-test-spec Skip test specification phase only --skip-test-impl Skip test implementation phase only + Contract Validation: + --init-harness Scaffold a contract-harness.yaml template and exit + --preflight-deep Also run a connectivity smoke (boots the sample env) + --skip-contract-validation Skip the contract harness preflight + Commit Control: --no-commit Stage changes but don't commit --skip-done Skip stories with Status: Done @@ -983,6 +989,16 @@ FILES: Logs: docs/sprint-artifacts/logs/epic--.log Metrics: docs/sprint-artifacts/metrics/epic--metrics.yaml Checkpoint: docs/sprint-artifacts/.epic--checkpoint + Harness: contract-harness.yaml (project root or docs/) - optional + +CONTRACT VALIDATION: + If a contract-harness.yaml is present, startup runs a preflight that checks + every credential, command, and file the harness needs (inferred from the + harness itself). A dry run prints a readiness report and exits non-zero when + anything required is missing, so it works as a CI readiness gate: + ./epic-execute.sh --dry-run # presence checks only + ./epic-execute.sh --dry-run --preflight-deep # + connectivity smoke + ./epic-execute.sh --init-harness # scaffold a starter harness For more information, see: docs/bmad_improvements_v2_fixes.md EOF @@ -1009,6 +1025,9 @@ SKIP_STATIC_ANALYSIS=false SKIP_DESIGN=false SKIP_DESIGN_CRITIC=false SKIP_REGRESSION=false +SKIP_CONTRACT_VALIDATION=false +PREFLIGHT_DEEP=false +INIT_HARNESS=false SKIP_TDD=false SKIP_TEST_SPEC=false SKIP_TEST_IMPL=false @@ -1084,6 +1103,18 @@ while [[ $# -gt 0 ]]; do SKIP_REGRESSION=true shift ;; + --skip-contract-validation) + SKIP_CONTRACT_VALIDATION=true + shift + ;; + --preflight-deep) + PREFLIGHT_DEEP=true + shift + ;; + --init-harness) + INIT_HARNESS=true + shift + ;; --skip-tdd) SKIP_TDD=true shift @@ -1111,6 +1142,17 @@ while [[ $# -gt 0 ]]; do esac done +# --init-harness: scaffold a contract-harness.yaml template and exit (no epic needed) +if [ "$INIT_HARNESS" = true ]; then + if type init_contract_harness >/dev/null 2>&1; then + init_contract_harness + exit $? + else + echo "Contract harness module not available (scripts/epic-execute-lib/contract-harness.sh)" + exit 1 + fi +fi + if [ -z "$EPIC_ID" ]; then echo "Usage: $0 [options]" echo "" @@ -1210,6 +1252,26 @@ if [ "$NO_COMMIT" != true ] && type check_branch_protection >/dev/null 2>&1; the fi fi +# Contract harness preflight - validate readiness to run contract validation. +# Opt-in by presence of a contract-harness.yaml. In a real run this is a +# fail-fast gate (abort before story 1 if prerequisites are missing). In a dry +# run it reports readiness and makes the run exit non-zero (a CI readiness gate). +if [ "$SKIP_CONTRACT_VALIDATION" != true ] && type contract_preflight >/dev/null 2>&1; then + CONTRACT_HARNESS_FILE=$(find_contract_harness) + if [ -n "$CONTRACT_HARNESS_FILE" ]; then + if ! contract_preflight "$CONTRACT_HARNESS_FILE"; then + if [ "$DRY_RUN" = true ]; then + log_warn "Preflight found missing prerequisites - dry run will exit non-zero" + else + log_error "Contract harness preflight failed - aborting before execution" + exit 1 + fi + fi + elif [ "$VERBOSE" = true ]; then + log "No contract-harness.yaml found - contract validation not configured" + fi +fi + # Ensure directories exist mkdir -p "$UAT_DIR" mkdir -p "$SPRINTS_DIR" @@ -3216,6 +3278,14 @@ echo " - Metrics: $METRICS_FILE" echo " - Log: $LOGS_DIR/epic-${EPIC_ID}-.log (saved on exit)" echo "" +# Contract preflight is an exit-code-honest gate: if a declared harness was +# missing prerequisites, fail the run (this is what makes --dry-run usable as a +# CI readiness check). +if [ "${PREFLIGHT_FAILED:-false}" = true ]; then + log_warn "Contract preflight reported missing prerequisites - see the readiness report above" + exit 1 +fi + if [ $FAILED -gt 0 ]; then log_warn "$FAILED stories failed - check log for details" log "Checkpoint preserved for resume capability"