Skip to content

Commit 485329b

Browse files
authored
Merge pull request #6 from DojoCodingLabs/daniel/doj-2429-error-handling
feat: proper error handling replacing silent suppression (DOJ-2429)
2 parents 026302f + c5c57be commit 485329b

6 files changed

Lines changed: 555 additions & 232 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/bin/bash
2+
# CodeSensei β€” Shared Error Handling Library
3+
# Source this file in hook scripts for consistent error logging and JSON safety.
4+
#
5+
# Usage:
6+
# source "$(dirname "$0")/lib/error-handling.sh"
7+
8+
LOG_FILE="${HOME}/.code-sensei/error.log"
9+
MAX_LOG_LINES=1000
10+
11+
# Log an error with timestamp and script name.
12+
# Usage: log_error "script-name" "message"
13+
log_error() {
14+
local script_name="${1:-unknown}"
15+
local message="$2"
16+
local timestamp
17+
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d')
18+
mkdir -p "$(dirname "$LOG_FILE")"
19+
printf '[%s] [%s] %s\n' "$timestamp" "$script_name" "$message" >> "$LOG_FILE"
20+
# Cap log file to MAX_LOG_LINES
21+
if [ -f "$LOG_FILE" ]; then
22+
local line_count
23+
line_count=$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0)
24+
if [ "$line_count" -gt "$MAX_LOG_LINES" ]; then
25+
tail -n "$MAX_LOG_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
26+
fi
27+
fi
28+
}
29+
30+
# Safely escape a string for JSON interpolation.
31+
# Returns a JSON-encoded string including surrounding double quotes.
32+
# Usage: escaped=$(json_escape "$var")
33+
json_escape() {
34+
local str="$1"
35+
if command -v jq &>/dev/null; then
36+
printf '%s' "$str" | jq -Rs '.'
37+
else
38+
# Basic fallback: escape backslashes, double quotes, and common control chars
39+
printf '"%s"' "$(printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g')"
40+
fi
41+
}
42+
43+
# Check that jq is installed. Logs a one-time warning per session if missing.
44+
# Usage: check_jq "script-name" || exit 0
45+
check_jq() {
46+
if ! command -v jq &>/dev/null; then
47+
local warn_file="${HOME}/.code-sensei/.jq-warned"
48+
if [ ! -f "$warn_file" ]; then
49+
log_error "${1:-unknown}" "jq not installed β€” CodeSensei features limited. Install with: brew install jq"
50+
touch "$warn_file"
51+
fi
52+
return 1
53+
fi
54+
return 0
55+
}

β€Žscripts/quiz-selector.shβ€Ž

Lines changed: 140 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,34 @@
1414
# "quiz_format": "multiple_choice" | "free_response" | "code_prediction"
1515
# }
1616

17+
SCRIPT_NAME="quiz-selector"
1718
PROFILE_DIR="$HOME/.code-sensei"
1819
PROFILE_FILE="$PROFILE_DIR/profile.json"
1920
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}"
2021
QUIZ_BANK="$PLUGIN_ROOT/data/quiz-bank.json"
2122

23+
# Load shared error handling
24+
LIB_DIR="$(dirname "$0")/lib"
25+
if [ -f "$LIB_DIR/error-handling.sh" ]; then
26+
source "$LIB_DIR/error-handling.sh"
27+
else
28+
LOG_FILE="${PROFILE_DIR}/error.log"
29+
log_error() { printf '[%s] [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d')" "${1:-unknown}" "$2" >> "$LOG_FILE" 2>/dev/null; }
30+
json_escape() {
31+
local str="$1"
32+
if command -v jq &>/dev/null; then
33+
printf '%s' "$str" | jq -Rs '.'
34+
else
35+
printf '"%s"' "$(printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g')"
36+
fi
37+
}
38+
check_jq() { command -v jq &>/dev/null; }
39+
fi
40+
2241
# Default output if we can't determine anything
2342
DEFAULT_OUTPUT='{"mode":"dynamic","concept":null,"reason":"No profile data available","static_question":null,"belt":"white","quiz_format":"multiple_choice"}'
2443

25-
if ! command -v jq &> /dev/null; then
44+
if ! check_jq "$SCRIPT_NAME"; then
2645
echo "$DEFAULT_OUTPUT"
2746
exit 0
2847
fi
@@ -33,12 +52,42 @@ if [ ! -f "$PROFILE_FILE" ]; then
3352
fi
3453

3554
# Read profile data
36-
BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE")
37-
QUIZ_HISTORY=$(jq -c '.quiz_history // []' "$PROFILE_FILE")
38-
CONCEPTS_SEEN=$(jq -c '.concepts_seen // []' "$PROFILE_FILE")
39-
SESSION_CONCEPTS=$(jq -c '.session_concepts // []' "$PROFILE_FILE")
40-
TOTAL_QUIZZES=$(jq -r '.quizzes.total // 0' "$PROFILE_FILE")
41-
CORRECT_QUIZZES=$(jq -r '.quizzes.correct // 0' "$PROFILE_FILE")
55+
BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE" 2>&1)
56+
if [ $? -ne 0 ]; then
57+
log_error "$SCRIPT_NAME" "jq failed reading belt: $BELT"
58+
echo "$DEFAULT_OUTPUT"
59+
exit 0
60+
fi
61+
62+
QUIZ_HISTORY=$(jq -c '.quiz_history // []' "$PROFILE_FILE" 2>&1)
63+
if [ $? -ne 0 ]; then
64+
log_error "$SCRIPT_NAME" "jq failed reading quiz_history: $QUIZ_HISTORY"
65+
QUIZ_HISTORY="[]"
66+
fi
67+
68+
CONCEPTS_SEEN=$(jq -c '.concepts_seen // []' "$PROFILE_FILE" 2>&1)
69+
if [ $? -ne 0 ]; then
70+
log_error "$SCRIPT_NAME" "jq failed reading concepts_seen: $CONCEPTS_SEEN"
71+
CONCEPTS_SEEN="[]"
72+
fi
73+
74+
SESSION_CONCEPTS=$(jq -c '.session_concepts // []' "$PROFILE_FILE" 2>&1)
75+
if [ $? -ne 0 ]; then
76+
log_error "$SCRIPT_NAME" "jq failed reading session_concepts: $SESSION_CONCEPTS"
77+
SESSION_CONCEPTS="[]"
78+
fi
79+
80+
TOTAL_QUIZZES=$(jq -r '.quizzes.total // 0' "$PROFILE_FILE" 2>&1)
81+
if [ $? -ne 0 ]; then
82+
log_error "$SCRIPT_NAME" "jq failed reading quizzes.total: $TOTAL_QUIZZES"
83+
TOTAL_QUIZZES=0
84+
fi
85+
86+
CORRECT_QUIZZES=$(jq -r '.quizzes.correct // 0' "$PROFILE_FILE" 2>&1)
87+
if [ $? -ne 0 ]; then
88+
log_error "$SCRIPT_NAME" "jq failed reading quizzes.correct: $CORRECT_QUIZZES"
89+
CORRECT_QUIZZES=0
90+
fi
4291

4392
TODAY=$(date -u +%Y-%m-%d)
4493
NOW_EPOCH=$(date +%s)
@@ -65,7 +114,7 @@ SPACED_REP_REASON=""
65114

66115
if [ "$QUIZ_HISTORY" != "[]" ]; then
67116
# Get concepts that were answered incorrectly, with their last wrong date and wrong count
68-
WRONG_CONCEPTS=$(echo "$QUIZ_HISTORY" | jq -c '
117+
WRONG_CONCEPTS=$(printf '%s' "$QUIZ_HISTORY" | jq -c '
69118
[.[] | select(.result == "incorrect")] |
70119
group_by(.concept) |
71120
map({
@@ -74,20 +123,39 @@ if [ "$QUIZ_HISTORY" != "[]" ]; then
74123
last_wrong: (sort_by(.timestamp) | last | .timestamp),
75124
total_attempts: 0
76125
})
77-
')
126+
' 2>&1)
127+
if [ $? -ne 0 ]; then
128+
log_error "$SCRIPT_NAME" "jq failed computing wrong concepts: $WRONG_CONCEPTS"
129+
WRONG_CONCEPTS="[]"
130+
fi
78131

79132
# For each wrong concept, check if it's due for review
80-
for ROW in $(echo "$WRONG_CONCEPTS" | jq -c '.[]'); do
81-
CONCEPT=$(echo "$ROW" | jq -r '.concept')
82-
WRONG_COUNT=$(echo "$ROW" | jq -r '.wrong_count')
83-
LAST_WRONG=$(echo "$ROW" | jq -r '.last_wrong')
133+
for ROW in $(printf '%s' "$WRONG_CONCEPTS" | jq -c '.[]' 2>/dev/null); do
134+
CONCEPT=$(printf '%s' "$ROW" | jq -r '.concept' 2>&1)
135+
if [ $? -ne 0 ]; then
136+
log_error "$SCRIPT_NAME" "jq failed reading concept from wrong row: $CONCEPT"
137+
continue
138+
fi
84139

85-
# Calculate days since last wrong answer
86-
LAST_WRONG_DATE=$(echo "$LAST_WRONG" | cut -d'T' -f1)
140+
WRONG_COUNT=$(printf '%s' "$ROW" | jq -r '.wrong_count' 2>&1)
141+
if [ $? -ne 0 ]; then
142+
log_error "$SCRIPT_NAME" "jq failed reading wrong_count: $WRONG_COUNT"
143+
continue
144+
fi
145+
146+
LAST_WRONG=$(printf '%s' "$ROW" | jq -r '.last_wrong' 2>&1)
147+
if [ $? -ne 0 ]; then
148+
log_error "$SCRIPT_NAME" "jq failed reading last_wrong: $LAST_WRONG"
149+
continue
150+
fi
151+
152+
# Calculate days since last wrong answer (handles both GNU and BSD date)
153+
LAST_WRONG_DATE=$(printf '%s' "$LAST_WRONG" | cut -d'T' -f1)
87154
LAST_EPOCH=$(date -j -f "%Y-%m-%d" "$LAST_WRONG_DATE" +%s 2>/dev/null || date -d "$LAST_WRONG_DATE" +%s 2>/dev/null)
88155
if [ -n "$LAST_EPOCH" ]; then
89156
DAYS_SINCE=$(( (NOW_EPOCH - LAST_EPOCH) / 86400 ))
90157
else
158+
log_error "$SCRIPT_NAME" "Could not parse date '$LAST_WRONG_DATE' for spaced repetition; defaulting days_since=999"
91159
DAYS_SINCE=999
92160
fi
93161

@@ -100,9 +168,13 @@ if [ "$QUIZ_HISTORY" != "[]" ]; then
100168
fi
101169

102170
# Check if enough time has passed and concept hasn't been mastered since
103-
CORRECT_SINCE=$(echo "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" --arg lw "$LAST_WRONG" '
171+
CORRECT_SINCE=$(printf '%s' "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" --arg lw "$LAST_WRONG" '
104172
[.[] | select(.concept == $c and .result == "correct" and .timestamp > $lw)] | length
105-
')
173+
' 2>&1)
174+
if [ $? -ne 0 ]; then
175+
log_error "$SCRIPT_NAME" "jq failed computing correct_since for $CONCEPT: $CORRECT_SINCE"
176+
CORRECT_SINCE=0
177+
fi
106178

107179
if [ "$DAYS_SINCE" -ge "$REVIEW_INTERVAL" ] && [ "$CORRECT_SINCE" -lt 3 ]; then
108180
SPACED_REP_CONCEPT="$CONCEPT"
@@ -118,23 +190,36 @@ if [ -n "$SPACED_REP_CONCEPT" ] && [ -f "$QUIZ_BANK" ]; then
118190
.quizzes[$concept] // [] |
119191
map(select(.belt == $belt or .belt == "white")) |
120192
first // null
121-
' "$QUIZ_BANK")
193+
' "$QUIZ_BANK" 2>&1)
194+
if [ $? -ne 0 ]; then
195+
log_error "$SCRIPT_NAME" "jq failed reading static question for $SPACED_REP_CONCEPT: $STATIC_Q"
196+
STATIC_Q="null"
197+
fi
198+
199+
ESCAPED_CONCEPT=$(json_escape "$SPACED_REP_CONCEPT")
200+
ESCAPED_REASON=$(json_escape "$SPACED_REP_REASON")
201+
ESCAPED_BELT=$(json_escape "$BELT")
122202

123203
if [ "$STATIC_Q" != "null" ] && [ -n "$STATIC_Q" ]; then
124-
echo "{\"mode\":\"spaced_repetition\",\"concept\":\"$SPACED_REP_CONCEPT\",\"reason\":\"$SPACED_REP_REASON\",\"static_question\":$STATIC_Q,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
125-
exit 0
204+
printf '{"mode":"spaced_repetition","concept":%s,"reason":%s,"static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \
205+
"$ESCAPED_CONCEPT" "$ESCAPED_REASON" "$STATIC_Q" "$ESCAPED_BELT" "$QUIZ_FORMAT"
126206
else
127-
echo "{\"mode\":\"spaced_repetition\",\"concept\":\"$SPACED_REP_CONCEPT\",\"reason\":\"$SPACED_REP_REASON\",\"static_question\":null,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
128-
exit 0
207+
printf '{"mode":"spaced_repetition","concept":%s,"reason":%s,"static_question":null,"belt":%s,"quiz_format":"%s"}\n' \
208+
"$ESCAPED_CONCEPT" "$ESCAPED_REASON" "$ESCAPED_BELT" "$QUIZ_FORMAT"
129209
fi
210+
exit 0
130211
fi
131212

132213
# ─── PRIORITY 2: Unquizzed session concepts ───
133214
# Concepts from this session that haven't been quizzed yet
134215
UNQUIZZED_CONCEPT=""
135216
if [ "$SESSION_CONCEPTS" != "[]" ]; then
136-
for CONCEPT in $(echo "$SESSION_CONCEPTS" | jq -r '.[]'); do
137-
BEEN_QUIZZED=$(echo "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" '[.[] | select(.concept == $c)] | length')
217+
for CONCEPT in $(printf '%s' "$SESSION_CONCEPTS" | jq -r '.[]' 2>/dev/null); do
218+
BEEN_QUIZZED=$(printf '%s' "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" '[.[] | select(.concept == $c)] | length' 2>&1)
219+
if [ $? -ne 0 ]; then
220+
log_error "$SCRIPT_NAME" "jq failed checking quiz history for $CONCEPT: $BEEN_QUIZZED"
221+
continue
222+
fi
138223
if [ "$BEEN_QUIZZED" -eq 0 ]; then
139224
UNQUIZZED_CONCEPT="$CONCEPT"
140225
break
@@ -147,15 +232,23 @@ if [ -n "$UNQUIZZED_CONCEPT" ] && [ -f "$QUIZ_BANK" ]; then
147232
.quizzes[$concept] // [] |
148233
map(select(.belt == $belt or .belt == "white")) |
149234
first // null
150-
' "$QUIZ_BANK")
235+
' "$QUIZ_BANK" 2>&1)
236+
if [ $? -ne 0 ]; then
237+
log_error "$SCRIPT_NAME" "jq failed reading static question for $UNQUIZZED_CONCEPT: $STATIC_Q"
238+
STATIC_Q="null"
239+
fi
240+
241+
ESCAPED_CONCEPT=$(json_escape "$UNQUIZZED_CONCEPT")
242+
ESCAPED_BELT=$(json_escape "$BELT")
151243

152244
if [ "$STATIC_Q" != "null" ] && [ -n "$STATIC_Q" ]; then
153-
echo "{\"mode\":\"static\",\"concept\":\"$UNQUIZZED_CONCEPT\",\"reason\":\"New concept from this session β€” not yet quizzed.\",\"static_question\":$STATIC_Q,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
154-
exit 0
245+
printf '{"mode":"static","concept":%s,"reason":"New concept from this session β€” not yet quizzed.","static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \
246+
"$ESCAPED_CONCEPT" "$STATIC_Q" "$ESCAPED_BELT" "$QUIZ_FORMAT"
155247
else
156-
echo "{\"mode\":\"dynamic\",\"concept\":\"$UNQUIZZED_CONCEPT\",\"reason\":\"New concept from this session β€” no static question available, generate dynamically.\",\"static_question\":null,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
157-
exit 0
248+
printf '{"mode":"dynamic","concept":%s,"reason":"New concept from this session β€” no static question available, generate dynamically.","static_question":null,"belt":%s,"quiz_format":"%s"}\n' \
249+
"$ESCAPED_CONCEPT" "$ESCAPED_BELT" "$QUIZ_FORMAT"
158250
fi
251+
exit 0
159252
fi
160253

161254
# ─── PRIORITY 3: Least-quizzed lifetime concepts ───
@@ -166,20 +259,34 @@ if [ "$CONCEPTS_SEEN" != "[]" ]; then
166259
.[] as $concept |
167260
($history | [.[] | select(.concept == $concept)] | length) as $count |
168261
{concept: $concept, count: $count}
169-
' <<< "$CONCEPTS_SEEN" | jq -s 'sort_by(.count) | first | .concept // null' 2>/dev/null)
262+
' <<< "$CONCEPTS_SEEN" 2>&1 | jq -s 'sort_by(.count) | first | .concept // null' 2>&1)
263+
if [ $? -ne 0 ]; then
264+
log_error "$SCRIPT_NAME" "jq failed computing least-quizzed concept: $LEAST_QUIZZED"
265+
LEAST_QUIZZED=""
266+
fi
170267
fi
171268

172269
if [ -n "$LEAST_QUIZZED" ] && [ "$LEAST_QUIZZED" != "null" ] && [ -f "$QUIZ_BANK" ]; then
173270
STATIC_Q=$(jq -c --arg concept "$LEAST_QUIZZED" --arg belt "$BELT" '
174271
.quizzes[$concept] // [] |
175272
map(select(.belt == $belt or .belt == "white")) |
176273
first // null
177-
' "$QUIZ_BANK")
274+
' "$QUIZ_BANK" 2>&1)
275+
if [ $? -ne 0 ]; then
276+
log_error "$SCRIPT_NAME" "jq failed reading static question for $LEAST_QUIZZED: $STATIC_Q"
277+
STATIC_Q="null"
278+
fi
279+
280+
ESCAPED_CONCEPT=$(json_escape "$LEAST_QUIZZED")
281+
ESCAPED_BELT=$(json_escape "$BELT")
178282

179-
echo "{\"mode\":\"static\",\"concept\":\"$LEAST_QUIZZED\",\"reason\":\"Reinforcing least-practiced concept.\",\"static_question\":$STATIC_Q,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
283+
printf '{"mode":"static","concept":%s,"reason":"Reinforcing least-practiced concept.","static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \
284+
"$ESCAPED_CONCEPT" "$STATIC_Q" "$ESCAPED_BELT" "$QUIZ_FORMAT"
180285
exit 0
181286
fi
182287

183288
# ─── FALLBACK: Dynamic generation ───
184-
echo "{\"mode\":\"dynamic\",\"concept\":null,\"reason\":\"No specific concept to target β€” generate from current session context.\",\"static_question\":null,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}"
289+
ESCAPED_BELT=$(json_escape "$BELT")
290+
printf '{"mode":"dynamic","concept":null,"reason":"No specific concept to target β€” generate from current session context.","static_question":null,"belt":%s,"quiz_format":"%s"}\n' \
291+
"$ESCAPED_BELT" "$QUIZ_FORMAT"
185292
exit 0

0 commit comments

Comments
Β (0)