loop-plan-file-validator.sh 7.89 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
#!/usr/bin/env bash
#
# UserPromptSubmit hook for plan file validation during RLCR loop
#
# Validates:
# - State schema version (plan_tracked, start_branch fields required)
# - Branch consistency (no switching during loop)
# - Plan file tracking status consistency
#

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"

# Source shared loop functions and template loader
source "$SCRIPT_DIR/lib/loop-common.sh"

PROJECT_ROOT="$(resolve_project_root)" || exit 0

# Source portable timeout wrapper for git operations
PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$PLUGIN_ROOT/scripts/portable-timeout.sh"

# Default timeout for git operations (30 seconds)
GIT_TIMEOUT=30

# Read hook input (required for UserPromptSubmit hooks)
INPUT=$(cat)

# Extract session_id from hook input for session-aware loop filtering
HOOK_SESSION_ID=$(extract_session_id "$INPUT")

# Find active loop using shared function (filtered by session_id)
LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr"
LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")

# If no active loop, allow exit
if [[ -z "$LOOP_DIR" ]]; then
    exit 0
fi

# Detect if we're in Finalize Phase (finalize-state.md exists)
STATE_FILE=$(resolve_active_state_file "$LOOP_DIR")

# Parse state file using strict validation (fail closed on malformed state)
if ! parse_state_file_strict "$STATE_FILE" 2>/dev/null; then
    echo "Error: Malformed state file, blocking operation for safety" >&2
    exit 1
fi

# Map STATE_* variables to local names for backward compatibility
PLAN_TRACKED="$STATE_PLAN_TRACKED"
PLAN_FILE="$STATE_PLAN_FILE"
START_BRANCH="$STATE_START_BRANCH"

# ========================================
# Schema Validation (v1.1.2+ required fields)
# ========================================

# Helper function to output schema validation error
schema_validation_error() {
    local field_name="$1"
    local fallback="RLCR loop state file is missing required field: \`${field_name}\`\n\nThis indicates the loop was started with an older version of humanize.\n\n**Options:**\n1. Cancel the loop: \`/humanize:cancel-rlcr-loop\`\n2. Update humanize plugin to version 1.1.2+\n3. Restart the RLCR loop with the updated plugin"

    local reason
    reason=$(load_and_render_safe "$TEMPLATE_DIR" "block/schema-outdated.md" "$fallback" "FIELD_NAME=$field_name")

    # Escape newlines for JSON
    local escaped_reason
    escaped_reason=$(echo "$reason" | jq -Rs '.')

    cat << EOF
{
  "decision": "block",
  "reason": $escaped_reason
}
EOF
}

# Check required fields (using FIELD_* constants from loop-common.sh)
REQUIRED_FIELDS=("${FIELD_PLAN_TRACKED}:$PLAN_TRACKED" "${FIELD_START_BRANCH}:$START_BRANCH")
for field_entry in "${REQUIRED_FIELDS[@]}"; do
    field_name="${field_entry%%:*}"
    field_value="${field_entry#*:}"

    if [[ -z "$field_value" ]]; then
        schema_validation_error "$field_name"
        exit 0
    fi
done

# ========================================
# Branch Consistency Check
# ========================================

# Use || GIT_EXIT_CODE=$? to prevent set -e from aborting on non-zero exit
CURRENT_BRANCH=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null) || GIT_EXIT_CODE=$?
GIT_EXIT_CODE=${GIT_EXIT_CODE:-0}
if [[ $GIT_EXIT_CODE -ne 0 || -z "$CURRENT_BRANCH" ]]; then
    cat << EOF
{
  "decision": "block",
  "reason": "Git operation failed or timed out.\\n\\nCannot verify branch consistency. Please check git status and try again."
}
EOF
    exit 0
fi
if [[ -n "$START_BRANCH" && "$CURRENT_BRANCH" != "$START_BRANCH" ]]; then
    cat << EOF
{
  "decision": "block",
  "reason": "Git branch has changed during RLCR loop.\\n\\nStarted on: $START_BRANCH\\nCurrent: $CURRENT_BRANCH\\n\\nBranch switching is not allowed during an active RLCR loop. Please switch back to the original branch or cancel the loop with /humanize:cancel-rlcr-loop"
}
EOF
    exit 0
fi

# ========================================
# Plan File Tracking Status Check
# ========================================

FULL_PLAN_PATH="$PROJECT_ROOT/$PLAN_FILE"

if [[ "$PLAN_TRACKED" == "true" ]]; then
    # Must be tracked and clean
    # Use || LS_FILES_EXIT=$? to prevent set -e from aborting on non-zero exit
    # ls-files --error-unmatch returns: 0 (tracked), 1 (not tracked), 124 (timeout), other (error)
    run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" ls-files --error-unmatch "$PLAN_FILE" &>/dev/null || LS_FILES_EXIT=$?
    LS_FILES_EXIT=${LS_FILES_EXIT:-0}
    if [[ $LS_FILES_EXIT -eq 124 ]]; then
        # Timeout - fail closed
        cat << EOF
{
  "decision": "block",
  "reason": "Git operation timed out while checking plan file tracking status.\\n\\nPlease check git status and try again."
}
EOF
        exit 0
    elif [[ $LS_FILES_EXIT -ne 0 && $LS_FILES_EXIT -ne 1 ]]; then
        # Unexpected git error - fail closed
        cat << EOF
{
  "decision": "block",
  "reason": "Git operation failed while checking plan file tracking status (exit code: $LS_FILES_EXIT).\\n\\nPlease check git status and try again."
}
EOF
        exit 0
    fi
    PLAN_IS_TRACKED=$([[ $LS_FILES_EXIT -eq 0 ]] && echo "true" || echo "false")

    # Use || STATUS_EXIT=$? to prevent set -e from aborting on non-zero exit
    # git status --porcelain returns: 0 (success), 124 (timeout), other (error)
    PLAN_GIT_STATUS=$(run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" status --porcelain "$PLAN_FILE" 2>/dev/null) || STATUS_EXIT=$?
    STATUS_EXIT=${STATUS_EXIT:-0}
    if [[ $STATUS_EXIT -eq 124 ]]; then
        # Timeout - fail closed
        cat << EOF
{
  "decision": "block",
  "reason": "Git operation timed out while checking plan file status.\\n\\nPlease check git status and try again."
}
EOF
        exit 0
    elif [[ $STATUS_EXIT -ne 0 ]]; then
        # Unexpected git error - fail closed
        cat << EOF
{
  "decision": "block",
  "reason": "Git operation failed while checking plan file status (exit code: $STATUS_EXIT).\\n\\nPlease check git status and try again."
}
EOF
        exit 0
    fi

    if [[ "$PLAN_IS_TRACKED" != "true" ]]; then
        cat << EOF
{
  "decision": "block",
  "reason": "Plan file is no longer tracked in git.\\n\\nFile: $PLAN_FILE\\n\\nThis RLCR loop was started with --track-plan-file, but the plan file has been removed from git tracking."
}
EOF
        exit 0
    fi

    if [[ -n "$PLAN_GIT_STATUS" ]]; then
        cat << EOF
{
  "decision": "block",
  "reason": "Plan file has uncommitted modifications.\\n\\nFile: $PLAN_FILE\\nStatus: $PLAN_GIT_STATUS\\n\\nThis RLCR loop was started with --track-plan-file. Plan file modifications are not allowed during the loop."
}
EOF
        exit 0
    fi
else
    # Must be gitignored (not tracked)
    # Check if git command succeeds - fail closed on timeout/error
    # ls-files --error-unmatch returns: 0 (tracked), 1 (not tracked), 124 (timeout), other (error)
    run_with_timeout "$GIT_TIMEOUT" git -C "$PROJECT_ROOT" ls-files --error-unmatch "$PLAN_FILE" &>/dev/null || LS_FILES_EXIT=$?
    LS_FILES_EXIT=${LS_FILES_EXIT:-0}
    if [[ $LS_FILES_EXIT -eq 124 ]]; then
        # Timeout - fail closed
        cat << EOF
{
  "decision": "block",
  "reason": "Git operation timed out while checking plan file tracking status.\\n\\nPlease check git status and try again."
}
EOF
        exit 0
    elif [[ $LS_FILES_EXIT -ne 0 && $LS_FILES_EXIT -ne 1 ]]; then
        # Unexpected git error - fail closed
        cat << EOF
{
  "decision": "block",
  "reason": "Git operation failed while checking plan file tracking status (exit code: $LS_FILES_EXIT).\\n\\nPlease check git status and try again."
}
EOF
        exit 0
    fi
    PLAN_IS_TRACKED=$([[ $LS_FILES_EXIT -eq 0 ]] && echo "true" || echo "false")

    if [[ "$PLAN_IS_TRACKED" == "true" ]]; then
        cat << EOF
{
  "decision": "block",
  "reason": "Plan file is now tracked in git but loop was started without --track-plan-file.\\n\\nFile: $PLAN_FILE\\n\\nThe plan file must remain gitignored during this RLCR loop."
}
EOF
        exit 0
    fi
fi

exit 0